Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 10 additions & 374 deletions Cargo.lock

Large diffs are not rendered by default.

23 changes: 5 additions & 18 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ resolver = "2"
members = ["crates/*"]

[workspace.package]
version = "0.2.0"
version = "0.3.0"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/partly/api-proxy"
repository = "https://github.com/thepartly/api-proxy"
authors = ["Partly <jo@partly.com>"]
rust-version = "1.85"

Expand All @@ -16,9 +16,9 @@ rust-version = "1.85"
# - `cargo publish` / `cargo release` use `version` from the registry.
# Keep these versions in lock-step with `workspace.package.version` above;
# `release.toml` (shared-version = true) enforces that on release.
partly-proxy-types = { version = "0.2.0", path = "crates/partly-proxy-types" }
partly-proxy-storage-jsonl = { version = "0.2.0", path = "crates/partly-proxy-storage-jsonl" }
partly-proxy-storage-sqlite = { version = "0.2.0", path = "crates/partly-proxy-storage-sqlite" }
partly-proxy-types = { version = "0.3.0", path = "crates/partly-proxy-types" }
partly-proxy-storage-jsonl = { version = "0.3.0", path = "crates/partly-proxy-storage-jsonl" }
partly-proxy-storage-sqlite = { version = "0.3.0", path = "crates/partly-proxy-storage-sqlite" }
# `partly-proxy-echo` is `publish = false`; only consumed as a dev-dep
# inside the workspace. Path-only is fine since dev-deps without a
# version are stripped from the published manifest.
Expand Down Expand Up @@ -87,19 +87,6 @@ rcgen = "0.13"
# bodies via `to_async(&runtime).iter_custom(...)`.
criterion = { version = "0.5", features = ["async_tokio"] }

# OpenTelemetry — version-suffixed renames so multiple minor versions can
# coexist in the workspace as the OTEL Rust crates churn. See the proxy
# lib's `otel_0_*` features. Add a new block per supported minor; do not
# remove old ones until consumers have migrated.
opentelemetry_0_27 = { package = "opentelemetry", version = "0.27" }
opentelemetry-http_0_27 = { package = "opentelemetry-http", version = "0.27" }
opentelemetry-semantic-conventions_0_27 = { package = "opentelemetry-semantic-conventions", version = "0.27", features = ["semconv_experimental"] }
tracing-opentelemetry_0_28 = { package = "tracing-opentelemetry", version = "0.28", default-features = false }

# Test-only OTEL SDK for the integration tests' in-memory span exporter.
# Not pulled in by the lib itself.
opentelemetry_sdk_0_27 = { package = "opentelemetry_sdk", version = "0.27", features = ["testing"] }

[profile.dev]
opt-level = 0

Expand Down
52 changes: 0 additions & 52 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,55 +129,3 @@ to them.
| Wait-for `AssertSeen` / `AssertCount` | Overshoot terminates fast |
| Hosting example (`examples/host.rs`) | Env-var-driven; ~30 lines |
| TypeScript client + vitest | Mock + real-binary e2e suites |
| OpenTelemetry (`otel_0_*` features) | W3C extraction + opt-in upstream injection |

## OpenTelemetry

Tracing/instrumentation support is feature-gated and off by default. The
library:

- Extracts the W3C `traceparent`/`tracestate` from inbound requests and
parents a server span on the incoming context (opt-out per upstream
via `without_otel_extraction`, or per request via `with_otel_filter`).
- Injects the resulting context onto the response so callers can
correlate (equivalent of the older `OtelInResponseLayer`).
- **Does not** inject context onto outbound requests unless explicitly
asked via `with_otel_propagation_to_upstream` on the upstream's
`ProxyConfig`.
- Records HTTP attributes per the current OTEL semantic conventions
(`http.request.method`, `http.response.status_code`, `http.route` =
upstream name, `url.path`, `url.scheme`, etc.) and maps 5xx responses
to `Status::Error`.

The library does **not** install a tracer provider, exporter,
propagator, or `tracing-subscriber`. That is the host binary's
responsibility — it has full control over which exporter, sampler, and
resource attributes to use. The lib just consults
`opentelemetry::global` for whatever the host has set up.

### Version pinning

The OTEL Rust crates ship breaking changes at every 0.x bump and
ecosystem crates can be stuck on different versions for months. One
Cargo feature per OTEL minor lets the lib track multiple minors as the
ecosystem migrates:

| Feature | `opentelemetry` minor | Sibling crates |
| ------------ | --------------------- | ------------------------------------------------------------------------------ |
| `otel_0_27` | 0.27.x | `opentelemetry-http` 0.27, `opentelemetry-semantic-conventions` 0.27, `tracing-opentelemetry` 0.28 |

Only one `otel_0_*` feature may be enabled at a time — `lib.rs` carries
a `compile_error!` guard that trips when more than one is selected
(the host's installed propagator must match the lib's compiled-in OTEL
version to round-trip context). Future versions are additive: a new
`otel_0_28` feature, new dep renames, and a new `v0_28.rs` impl module
all sit alongside the existing ones with no renames.

```toml
[dependencies]
partly-proxy-lib = { version = "0.1", features = ["otel_0_27"] }
```

In the host binary, install a tracer provider, propagator, and
`tracing-subscriber` against the matching `opentelemetry` minor; the
lib will see them via `opentelemetry::global`.
75 changes: 0 additions & 75 deletions SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,12 +639,6 @@ The crate does not ship a hosting binary — wiring `ProxyClusterBuilder` into a
- **Hop-by-hop headers.** Headers are forwarded as-is; `hyper`'s defaults handle `Content-Length` and `Transfer-Encoding` normalisation, but other hop-by-hop headers are not stripped.
- **One-shot replay.** Replay snapshots are reusable — there is no per-snapshot consumption tracking and no "all snapshots consumed" assertion.
- **Connection cap.** No semaphore on accepted connections; very high concurrency is bounded only by the OS and the upstream.
- **Telemetry.** Tracing is supported via `tracing`. OpenTelemetry support
is feature-gated (`otel_0_*` features, off by default) and provides
W3C TraceContext extraction, response-side injection, and opt-in
upstream injection — see §21. The library does **not** install a
tracer provider, exporter, propagator, or `tracing-subscriber`; the
host binary owns that.

---

Expand Down Expand Up @@ -676,72 +670,3 @@ The crate does not ship a hosting binary — wiring `ProxyClusterBuilder` into a
2. Bind the JSON-Lines TCP adapter to a known port.
3. Have a non-Rust test harness open a TCP connection and issue commands as JSON lines. For Playwright suites, use the first-party TypeScript client (see §12.3) instead of hand-rolling the protocol.
4. The harness can stub, pause, query traffic, assert, and shut the proxy down without ever linking the Rust crate.

---

## 21. OpenTelemetry

Feature-gated, off by default. One Cargo feature per `opentelemetry`
minor version (`otel_0_27`, future `otel_0_28`, …) so the crate can
track several minors side-by-side as the OTEL Rust stack evolves. The
features are mutually exclusive — enabling more than one trips a
`compile_error!` — because `opentelemetry::global::*` is non-reentrant
across minor versions.

### 21.1 Responsibility split

The library does **not** install any tracer-side machinery. That is
the host binary's job: it picks the exporter (OTLP/gRPC, OTLP/HTTP, …),
the sampler, the resource attributes, the `TracerProvider`, the
`TextMapPropagator`, and the `tracing-subscriber` composition. The lib
calls `opentelemetry::global::get_text_map_propagator` and
`tracing::Span` methods from `tracing-opentelemetry`; whatever the host
installs is what it gets, including a no-op tracer if the host doesn't
install one.

### 21.2 Propagation contract

When any `otel_0_*` feature is enabled:

- **Inbound extraction (opt-out).** Each inbound request creates a
server span. If the request carried a `traceparent`/`tracestate`,
the span is parented to that context. Disable per upstream via
`ProxyConfig::without_otel_extraction()`; skip individual requests
via `ProxyConfig::with_otel_filter(|method, uri| -> bool)`.
- **Response injection (always, when a span was created).** The proxy
emits a `traceparent` on the response so callers can correlate. This
is the W3C-native equivalent of the older
`axum-tracing-opentelemetry::OtelInResponseLayer`.
- **Outbound injection (opt-in).** Disabled by default. Enable per
upstream via `ProxyConfig::with_otel_propagation_to_upstream()`. When
off, the proxy does not add any tracing headers to forwarded
requests; client-supplied tracing headers still flow through
unchanged because the proxy forwards inbound headers as-is.

### 21.3 Span shape

Server span (kind `SERVER`):

- Name: `"{http.request.method} {http.route}"` where `http.route` is
the upstream's configured name (the closest analogue to a route a
proxy has and bounds attribute cardinality).
- Attributes: `http.request.method`, `http.response.status_code`,
`http.route`, `url.path`, `url.query`, `url.scheme`,
`server.address`/`server.port`, `client.address`/`client.port`,
`user_agent.original`, `network.protocol.version`, plus
`partly.proxy.upstream` as a namespaced custom attribute.
- `Status::Error` is set for 5xx responses; other outcomes leave the
status at `Unset`.

Client span (kind `CLIENT`, child of the server span):

- Name: `"{http.request.method}"` per OTEL HTTP-client semconv.
- Attributes: `http.request.method`, `url.full`, `server.address`/
`server.port` from the outbound URI, `http.response.status_code`,
`partly.proxy.upstream`.

### 21.4 Control plane

The TCP JSON-Lines control plane is **not** traced — it isn't
user-facing HTTP traffic and the cardinality wouldn't be useful. Only
the per-upstream listeners are wired to the OTEL helpers.
35 changes: 0 additions & 35 deletions crates/partly-proxy-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,6 @@ storage-jsonl = ["dep:partly-proxy-storage-jsonl"]
storage-sqlite = ["dep:partly-proxy-storage-sqlite"]
testing = []

# OpenTelemetry support. One feature per `opentelemetry` minor version so
# the lib can track multiple minors side-by-side as the OTEL Rust stack
# evolves. Enable at most one at a time — `lib.rs` enforces this with a
# `compile_error!`.
#
# The library does *not* install a tracer provider, propagator, exporter,
# or `tracing-subscriber`. The host binary is responsible for that. The
# feature only wires the lib's extraction / injection / span helpers.
#
# `_otel_any` is an internal alias enabled by every version feature so
# cfg gates inside the crate stay version-agnostic. Do not enable it
# directly.
_otel_any = []
otel_0_27 = [
"_otel_any",
"dep:opentelemetry_0_27",
"dep:opentelemetry-http_0_27",
"dep:opentelemetry-semantic-conventions_0_27",
"dep:tracing-opentelemetry_0_28",
]

[lints.rust]
unsafe_code = "forbid"

Expand Down Expand Up @@ -96,14 +75,6 @@ uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }

# OpenTelemetry — optional, version-suffixed. The active set is selected
# by the `otel_0_*` feature flags above. None of these are linked unless
# a version feature is enabled.
opentelemetry_0_27 = { workspace = true, optional = true }
opentelemetry-http_0_27 = { workspace = true, optional = true }
opentelemetry-semantic-conventions_0_27 = { workspace = true, optional = true }
tracing-opentelemetry_0_28 = { workspace = true, optional = true }

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tempfile = { workspace = true }
Expand All @@ -120,12 +91,6 @@ partly-proxy-storage-jsonl = { workspace = true }
partly-proxy-storage-sqlite = { workspace = true }
criterion = { workspace = true }

# Test-only OTEL SDK for the integration tests' in-memory span exporter.
# `opentelemetry_0_27` itself reaches the test crate through the optional
# regular dep when the matching feature is enabled, so it isn't repeated
# here.
opentelemetry_sdk_0_27 = { workspace = true }

[[example]]
name = "host"
path = "examples/host.rs"
Expand Down
79 changes: 1 addition & 78 deletions crates/partly-proxy-lib/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,60 +6,17 @@
//! point where each field is used (when the listener binds, when the outbound
//! client is built, etc.).

#[cfg(feature = "_otel_any")]
use std::sync::Arc;
use std::{net::SocketAddr, path::PathBuf, time::Duration};

#[cfg(feature = "_otel_any")]
use http::{Method, Uri};

/// Filter applied to incoming requests to decide whether to create an OTEL
/// server span. Returns `true` to trace, `false` to skip. Useful for
/// excluding health probes.
#[cfg(feature = "_otel_any")]
pub type OtelRequestFilter = Arc<dyn Fn(&Method, &Uri) -> bool + Send + Sync>;

/// One listener bound to one upstream — see `SPECIFICATION.md` §3.1.
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct ProxyConfig {
/// Address the listener binds to.
pub bind_addr: SocketAddr,
/// Upstream this listener forwards to.
pub upstream: UpstreamTarget,
/// If set, the listener terminates inbound TLS.
pub inbound_tls: Option<InboundTlsConfig>,
/// When `true` (default), each inbound request creates an OTEL server
/// span parented to any `traceparent`/`tracestate` it carried, and the
/// proxy injects the resulting context into the response headers.
/// Set to `false` to bypass OTEL entirely for this listener.
#[cfg(feature = "_otel_any")]
pub otel_extract: bool,
/// When `Some`, called per request to decide whether to create a span
/// (return `true` to trace, `false` to skip). Applied after
/// `otel_extract`. Default: `None` (trace every request).
#[cfg(feature = "_otel_any")]
pub otel_filter: Option<OtelRequestFilter>,
/// When `true`, the current span's context is injected into outbound
/// request headers before forwarding to the upstream. Default `false`:
/// the proxy does not modify outbound headers for tracing unless asked.
#[cfg(feature = "_otel_any")]
pub otel_propagate_upstream: bool,
}

impl std::fmt::Debug for ProxyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("ProxyConfig");
d.field("bind_addr", &self.bind_addr)
.field("upstream", &self.upstream)
.field("inbound_tls", &self.inbound_tls);
#[cfg(feature = "_otel_any")]
{
d.field("otel_extract", &self.otel_extract)
.field("otel_filter", &self.otel_filter.as_ref().map(|_| "<fn>"))
.field("otel_propagate_upstream", &self.otel_propagate_upstream);
}
d.finish()
}
}

impl ProxyConfig {
Expand All @@ -69,42 +26,8 @@ impl ProxyConfig {
bind_addr,
upstream,
inbound_tls: None,
#[cfg(feature = "_otel_any")]
otel_extract: true,
#[cfg(feature = "_otel_any")]
otel_filter: None,
#[cfg(feature = "_otel_any")]
otel_propagate_upstream: false,
}
}

/// Disable OTEL extraction (and the implicit response-header injection)
/// for this listener. No effect when no `otel_0_*` feature is enabled.
#[cfg(feature = "_otel_any")]
pub fn without_otel_extraction(mut self) -> Self {
self.otel_extract = false;
self
}

/// Install a request-level filter; returning `false` skips tracing for
/// that request. No effect when no `otel_0_*` feature is enabled.
#[cfg(feature = "_otel_any")]
pub fn with_otel_filter<F>(mut self, f: F) -> Self
where
F: Fn(&Method, &Uri) -> bool + Send + Sync + 'static,
{
self.otel_filter = Some(Arc::new(f));
self
}

/// Enable injection of the current trace context into outbound requests
/// forwarded to the upstream. No effect when no `otel_0_*` feature is
/// enabled.
#[cfg(feature = "_otel_any")]
pub fn with_otel_propagation_to_upstream(mut self) -> Self {
self.otel_propagate_upstream = true;
self
}
}

/// Outbound target description — see `SPECIFICATION.md` §3.2.
Expand Down
Loading
Loading