feat(codegen): Emit opt-in tracing spans, propagate trace context.

Generated clients now support `tracing` and `trace-context` features,
both off by default.

Enabling `tracing` adds an `#[instrument]` span to every operation
method, and includes OTel HTTP semantic conventions fields plus
path parameters in each span's attributes.

`trace-context` enables `tracing` and injects trace context headers
into each request.

Rework `ploidy_util::Error` to use semantic error categories.
Generated clients re-export this type, so this is a breaking change
to generated code.
This commit is contained in:
Lina Butler
2026-06-13 19:31:11 -07:00
parent 276fc2905d
commit 73ad1487f1
13 changed files with 1490 additions and 376 deletions
Generated
+108
View File
@@ -112,6 +112,17 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -917,6 +928,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -1085,6 +1102,31 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror",
]
[[package]]
name = "opentelemetry-http"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
]
[[package]]
name = "owo-colors"
version = "4.3.0"
@@ -1223,6 +1265,8 @@ dependencies = [
"chrono",
"http",
"itertools",
"opentelemetry",
"opentelemetry-http",
"percent-encoding",
"ploidy-pointer",
"reqwest",
@@ -1231,6 +1275,8 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"thiserror",
"tracing",
"tracing-opentelemetry",
"url",
"uuid",
]
@@ -1736,6 +1782,15 @@ dependencies = [
"serde",
]
[[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"
@@ -1910,6 +1965,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -2063,9 +2127,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -2073,6 +2149,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
dependencies = [
"js-sys",
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"sharded-slab",
"thread_local",
"tracing-core",
]
[[package]]
@@ -2160,6 +2262,12 @@ dependencies = [
"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"
+6
View File
@@ -20,6 +20,10 @@ rust-version = "1.89"
[workspace.dependencies]
either = "1"
indoc = "2"
opentelemetry = { version = "0.32", default-features = false, features = [
"trace",
] }
opentelemetry-http = { version = "0.32", default-features = false }
ploidy-codegen-rust = { path = "ploidy-codegen-rust", version = "0.14.2" }
ploidy-core = { path = "ploidy-core", version = "0.14.2" }
ploidy-pointer = { path = "ploidy-pointer", version = "0.14.2" }
@@ -30,6 +34,8 @@ rustc-hash = "2"
serde = "1"
serde_json = "1"
toml_edit = { version = "0.25", features = ["serde"] }
tracing = "0.1"
tracing-opentelemetry = { version = "0.33", default-features = false }
[workspace.lints.rust]
unsafe_code = "forbid"
+13 -7
View File
@@ -354,7 +354,7 @@ Given a spec with an operation like:
$ref: "#/components/schemas/User"
```
...you can use the generated client to call that operation like:
...you can use the client to call that operation like:
```rust
use my_api_client::{Client, Error};
@@ -373,12 +373,16 @@ async fn main() -> Result<(), Error> {
}
```
> [!NOTE]
> `with_user_agent`, `with_header`, and `with_sensitive_header` all set default headers for each request. Sensitive headers are excluded from debug output.
The client uses [Reqwest](https://docs.rs/reqwest) under the hood, and has several convenience methods:
The generated client uses [Reqwest](https://docs.rs/reqwest) under the hood. If you need to configure connection options, like proxies, timeouts, or TLS, build your own `reqwest::Client` and pass it to `Client::with_reqwest_client`.
* `with_user_agent()`, `with_header()`, and `with_sensitive_header()` set default headers for all requests. Sensitive headers are excluded from debug output.
* `request()` returns a raw [`RequestBuilder`](https://docs.rs/reqwest/latest/reqwest/struct.RequestBuilder.html) with the client's base URL and default headers already applied. Use this for requests that the operation methods don't cover.
* `Client::with_reqwest_client()` creates a client with a custom `reqwest::Client`. Use this to configure connection options like proxies, timeouts, and TLS.
For requests that the typed methods don't cover, `Client::request` returns a raw `RequestBuilder` with the client's base URL and default headers already applied.
Generated clients also have two opt-in observability features:
* `tracing` instruments each operation method with a [`tracing` span](https://docs.rs/tracing/latest/tracing/span/index.html).
* `trace-context` enables `tracing` and propagates trace context using your application's [OpenTelemetry propagator](https://docs.rs/opentelemetry/latest/opentelemetry/global/fn.get_text_map_propagator.html).
### Smart boxing
@@ -493,13 +497,15 @@ pub struct Customer {
}
```
All features are enabled by default, so the generated crate works out of the box. To enable just a subset of the generated features:
All resource features are enabled by default, so the generated crate works out of the box. To enable just a subset of the generated resources:
```toml
[dependencies]
my-api-client = { version = "1", default-features = false, features = ["orders"] }
```
The observability features are not enabled by default.
### Choosing the right tool
Ploidy focuses on generating Rust clients from modern OpenAPI specs. The broader ecosystem has strong options for other needs:
@@ -540,7 +546,7 @@ Ploidy is opinionated by design. We'd rather get the defaults right than expose
| Query parameters | Supported | `{OperationId}Query` struct argument |
| Query `style` | Supported | `form`, `spaceDelimited`, `pipeDelimited`, `deepObject` |
| Header and cookie parameters | Unsupported | - |
| Request bodies | Partial | `application/json` and `*/*` schemas become typed arguments; `multipart/form-data` becomes `reqwest::multipart::Form` |
| Request bodies | Partial | `application/json` and `*/*` schemas become typed model arguments; `multipart/form-data` becomes `reqwest::multipart::Form` |
| Responses | Partial | The first `application/json` or `*/*` schema from either the lowest 2xx response or `default` becomes the return value; other response schemas are ignored |
## Contributing
+69 -15
View File
@@ -69,9 +69,9 @@ impl<'a> CodegenCargoManifest<'a> {
}
// Build the `features` section of the manifest.
let mut features: BTreeMap<_, _> = deps_by_resource
.iter()
.map(|(resource, deps)| {
let mut features = BTreeMap::new();
if !deps_by_resource.is_empty() {
features.extend(deps_by_resource.iter().map(|(resource, deps)| {
(
AsFeatureName(*resource).to_string(),
FeatureDependencies(
@@ -80,12 +80,8 @@ impl<'a> CodegenCargoManifest<'a> {
.collect_vec(),
),
)
})
.collect();
if features.is_empty() {
BTreeMap::new()
} else {
// `default` enables all other features.
}));
// `default` enables all resource features.
features.insert(
"default".to_owned(),
FeatureDependencies(
@@ -95,8 +91,21 @@ impl<'a> CodegenCargoManifest<'a> {
.collect_vec(),
),
);
features
}
// `tracing` enables per-method spans; `trace-context` adds
// trace context propagation. Both are opt-in.
features.insert(
"tracing".to_owned(),
FeatureDependencies(vec!["ploidy-util/tracing".to_owned()]),
);
features.insert(
"trace-context".to_owned(),
FeatureDependencies(vec![
"tracing".to_owned(),
"ploidy-util/trace-context".to_owned(),
]),
);
features
};
self.manifest.clone().apply(CargoManifestDiff {
@@ -611,6 +620,10 @@ mod tests {
[dependencies]
serde = "1.0.0"
ploidy-util = "{PLOIDY_VERSION}"
[features]
trace-context = ["tracing", "ploidy-util/trace-context"]
tracing = ["ploidy-util/tracing"]
"#},
);
}
@@ -713,7 +726,7 @@ mod tests {
let features = manifest.features();
let keys = features.keys().copied().collect_vec();
assert_matches!(&*keys, ["customer", "default"]);
assert_matches!(&*keys, ["customer", "default", "trace-context", "tracing"]);
}
#[test]
@@ -741,7 +754,7 @@ mod tests {
let features = manifest.features();
let keys = features.keys().copied().collect_vec();
assert_matches!(&*keys, ["default", "pets"]);
assert_matches!(&*keys, ["default", "pets", "trace-context", "tracing"]);
}
#[test]
@@ -777,12 +790,53 @@ mod tests {
let features = manifest.features();
let keys = features.keys().copied().collect_vec();
assert_matches!(&*keys, ["default", "oauth-2-token-2", "oauth2-token"]);
assert_matches!(
&*keys,
[
"default",
"oauth-2-token-2",
"oauth2-token",
"trace-context",
"tracing"
]
);
assert_eq!(features["default"], ["oauth-2-token-2", "oauth2-token"]);
}
#[test]
fn test_unnamed_schema_creates_no_features() {
fn test_resource_named_tracing_does_not_collide_with_tracing_feature() {
let doc = Document::from_yaml(indoc::indoc! {"
openapi: 3.0.0
info:
title: Test
version: 1.0.0
components:
schemas:
Trace:
type: object
x-resourceId: tracing
properties:
id:
type: string
"})
.unwrap();
let arena = Arena::new();
let spec = Spec::from_doc(&arena, &doc).unwrap();
let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
// `tracing` is reserved for the tracing feature, so the
// resource's feature is uniquified to `tracing-2`.
let features = manifest.features();
let keys = features.keys().copied().collect_vec();
assert_matches!(&*keys, ["default", "trace-context", "tracing", "tracing-2"]);
assert_eq!(features["default"], ["tracing-2"]);
assert_eq!(features["tracing"], ["ploidy-util/tracing"]);
}
#[test]
fn test_unnamed_schema_creates_no_resource_features() {
let doc = Document::from_yaml(indoc::indoc! {"
openapi: 3.0.0
info:
@@ -805,7 +859,7 @@ mod tests {
let features = manifest.features();
let keys = features.keys().copied().collect_vec();
assert_matches!(&*keys, []);
assert_matches!(&*keys, ["trace-context", "tracing"]);
}
// MARK: Schema feature dependencies
+4 -4
View File
@@ -72,10 +72,10 @@ impl ToTokens for CodegenClientModule<'_> {
{
let name = name
.try_into()
.map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
.map_err(crate::error::Error::bad_header_name)?;
let value = value
.try_into()
.map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
.map_err(|err| crate::error::Error::bad_header_value(name.clone(), err))?;
self.headers.insert(name, value);
Ok(Self {
client: self.client,
@@ -105,10 +105,10 @@ impl ToTokens for CodegenClientModule<'_> {
{
let name = name
.try_into()
.map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
.map_err(crate::error::Error::bad_header_name)?;
let mut value: ::ploidy_util::http::HeaderValue = value
.try_into()
.map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
.map_err(|err| crate::error::Error::bad_header_value(name.clone(), err))?;
value.set_sensitive(true);
self.with_header(name, value)
}
+4 -2
View File
@@ -146,8 +146,10 @@ fn ident_map<'a>(cooked: &CookedGraph<'a>) -> IdentMap<'a> {
.filter_map(|op| op.resource())
.chain(cooked.schemas().filter_map(|ty| ty.resource()))
.collect();
// Resources become feature names; `default` is a special feature name.
let mut scope = UniqueIdents::with_reserved(cooked.arena(), &["default"]);
// Resources become feature names; `default`, `tracing`, and
// `trace-context` are special feature names.
let mut scope =
UniqueIdents::with_reserved(cooked.arena(), &["default", "tracing", "trace-context"]);
resources
.into_iter()
.map(move |name| (IdentMapKey::Resource(name), scope.claim(name)))
File diff suppressed because it is too large Load Diff
+332 -40
View File
@@ -186,9 +186,27 @@ mod tests {
let expected: syn::File = parse_quote! {
impl crate::client::Client {
#[doc = " GET /customers"]
#[cfg_attr(
feature = "tracing",
::tracing::instrument(
skip_all,
fields(
otel.name = "GET /customers",
otel.kind = "client",
url.template = "/customers",
http.request.method = "GET",
server.address,
server.port,
url.full,
http.response.status_code,
error.type
)
)
)]
pub async fn list_customers(
&self,
) -> Result<::std::vec::Vec<crate::types::Customer>, crate::error::Error> {
let result: Result<_, crate::error::Error> = async move {
let url = {
let mut url = self.base_url.clone();
let _ = url
@@ -199,19 +217,49 @@ mod tests {
});
url
};
let response = self
.client
.get(url)
.headers(self.headers.clone())
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
server.address = url.host_str(),
server.port = url.port_or_known_default(),
url.full = url.as_str(),
);
}
let request = {
let request = self
.client
.get(url)
.headers(self.headers.clone());
#[cfg(feature = "trace-context")]
let request = ::ploidy_util::trace::propagate(
::tracing::Span::current(),
request,
);
request
};
let response = request
.send()
.await?
.error_for_status()?;
.await?;
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
http.response.status_code = response.status().as_u16()
);
}
let response = response.error_for_status()?;
let body = response.bytes().await?;
let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
.map_err(crate::error::JsonError::from)?;
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
Ok(result)
}.await;
#[cfg(feature = "tracing")]
if let Err(err) = &result {
::tracing::record_all!(::tracing::Span::current(),
error.type = %err.category(),
);
}
result
}
}
};
assert_eq!(actual, expected);
@@ -272,9 +320,27 @@ mod tests {
impl crate::client::Client {
#[cfg(feature = "customer")]
#[doc = " GET /orders"]
#[cfg_attr(
feature = "tracing",
::tracing::instrument(
skip_all,
fields(
otel.name = "GET /orders",
otel.kind = "client",
url.template = "/orders",
http.request.method = "GET",
server.address,
server.port,
url.full,
http.response.status_code,
error.type
)
)
)]
pub async fn list_orders(
&self,
) -> Result<::std::vec::Vec<crate::types::Order>, crate::error::Error> {
let result: Result<_, crate::error::Error> = async move {
let url = {
let mut url = self.base_url.clone();
let _ = url
@@ -285,19 +351,49 @@ mod tests {
});
url
};
let response = self
.client
.get(url)
.headers(self.headers.clone())
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
server.address = url.host_str(),
server.port = url.port_or_known_default(),
url.full = url.as_str(),
);
}
let request = {
let request = self
.client
.get(url)
.headers(self.headers.clone());
#[cfg(feature = "trace-context")]
let request = ::ploidy_util::trace::propagate(
::tracing::Span::current(),
request,
);
request
};
let response = request
.send()
.await?
.error_for_status()?;
.await?;
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
http.response.status_code = response.status().as_u16()
);
}
let response = response.error_for_status()?;
let body = response.bytes().await?;
let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
.map_err(crate::error::JsonError::from)?;
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
Ok(result)
}.await;
#[cfg(feature = "tracing")]
if let Err(err) = &result {
::tracing::record_all!(::tracing::Span::current(),
error.type = %err.category(),
);
}
result
}
}
};
assert_eq!(actual, expected);
@@ -344,10 +440,28 @@ mod tests {
let expected: syn::File = parse_quote! {
impl crate::client::Client {
#[doc = " GET /customers"]
#[cfg_attr(
feature = "tracing",
::tracing::instrument(
skip_all,
fields(
otel.name = "GET /customers",
otel.kind = "client",
url.template = "/customers",
http.request.method = "GET",
server.address,
server.port,
url.full,
http.response.status_code,
error.type
)
)
)]
pub async fn list_customers(
&self,
query: &parameters::ListCustomersQuery
) -> Result<(), crate::error::Error> {
let result: Result<_, crate::error::Error> = async move {
let url = {
let mut url = self.base_url.clone();
let _ = url
@@ -365,16 +479,47 @@ mod tests {
parameters::ListCustomersQuery::STYLES,
),
)?;
let response = self
.client
.get(url)
.headers(self.headers.clone())
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
server.address = url.host_str(),
server.port = url.port_or_known_default(),
url.full = url.as_str(),
);
}
let request = {
let request = self
.client
.get(url)
.headers(self.headers.clone());
#[cfg(feature = "trace-context")]
let request = ::ploidy_util::trace::propagate(
::tracing::Span::current(),
request,
);
request
};
let response = request
.send()
.await?
.error_for_status()?;
.await?;
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
http.response.status_code = response.status().as_u16()
);
}
let response = response.error_for_status()?;
let _ = response;
Ok(())
}.await;
#[cfg(feature = "tracing")]
if let Err(err) = &result {
::tracing::record_all!(::tracing::Span::current(),
error.type = %err.category(),
);
}
result
}
}
pub mod parameters {
mod list_customers_query {
@@ -447,10 +592,28 @@ mod tests {
let expected: syn::File = parse_quote! {
impl crate::client::Client {
#[doc = " GET /customers"]
#[cfg_attr(
feature = "tracing",
::tracing::instrument(
skip_all,
fields(
otel.name = "GET /customers",
otel.kind = "client",
url.template = "/customers",
http.request.method = "GET",
server.address,
server.port,
url.full,
http.response.status_code,
error.type
)
)
)]
pub async fn list_customers(
&self,
query: &parameters::ListCustomersQuery
) -> Result<(), crate::error::Error> {
let result: Result<_, crate::error::Error> = async move {
let url = {
let mut url = self.base_url.clone();
let _ = url
@@ -468,21 +631,70 @@ mod tests {
parameters::ListCustomersQuery::STYLES,
),
)?;
let response = self
.client
.get(url)
.headers(self.headers.clone())
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
server.address = url.host_str(),
server.port = url.port_or_known_default(),
url.full = url.as_str(),
);
}
let request = {
let request = self
.client
.get(url)
.headers(self.headers.clone());
#[cfg(feature = "trace-context")]
let request = ::ploidy_util::trace::propagate(
::tracing::Span::current(),
request,
);
request
};
let response = request
.send()
.await?
.error_for_status()?;
.await?;
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
http.response.status_code = response.status().as_u16()
);
}
let response = response.error_for_status()?;
let _ = response;
Ok(())
}.await;
#[cfg(feature = "tracing")]
if let Err(err) = &result {
::tracing::record_all!(::tracing::Span::current(),
error.type = %err.category(),
);
}
result
}
#[doc = " GET /customers/search"]
#[cfg_attr(
feature = "tracing",
::tracing::instrument(
skip_all,
fields(
otel.name = "GET /customers/search",
otel.kind = "client",
url.template = "/customers/search",
http.request.method = "GET",
server.address,
server.port,
url.full,
http.response.status_code,
error.type
)
)
)]
pub async fn search_customers(
&self,
query: &parameters::SearchCustomersQuery
) -> Result<(), crate::error::Error> {
let result: Result<_, crate::error::Error> = async move {
let url = {
let mut url = self.base_url.clone();
let _ = url
@@ -500,16 +712,47 @@ mod tests {
parameters::SearchCustomersQuery::STYLES,
),
)?;
let response = self
.client
.get(url)
.headers(self.headers.clone())
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
server.address = url.host_str(),
server.port = url.port_or_known_default(),
url.full = url.as_str(),
);
}
let request = {
let request = self
.client
.get(url)
.headers(self.headers.clone());
#[cfg(feature = "trace-context")]
let request = ::ploidy_util::trace::propagate(
::tracing::Span::current(),
request,
);
request
};
let response = request
.send()
.await?
.error_for_status()?;
.await?;
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
http.response.status_code = response.status().as_u16()
);
}
let response = response.error_for_status()?;
let _ = response;
Ok(())
}.await;
#[cfg(feature = "tracing")]
if let Err(err) = &result {
::tracing::record_all!(::tracing::Span::current(),
error.type = %err.category(),
);
}
result
}
}
pub mod parameters {
mod list_customers_query {
@@ -573,9 +816,27 @@ mod tests {
let expected: syn::File = parse_quote! {
impl crate::client::Client {
#[doc = " GET /customers"]
#[cfg_attr(
feature = "tracing",
::tracing::instrument(
skip_all,
fields(
otel.name = "GET /customers",
otel.kind = "client",
url.template = "/customers",
http.request.method = "GET",
server.address,
server.port,
url.full,
http.response.status_code,
error.type
)
)
)]
pub async fn list_customers(
&self,
) -> Result<(), crate::error::Error> {
let result: Result<_, crate::error::Error> = async move {
let url = {
let mut url = self.base_url.clone();
let _ = url
@@ -586,16 +847,47 @@ mod tests {
});
url
};
let response = self
.client
.get(url)
.headers(self.headers.clone())
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
server.address = url.host_str(),
server.port = url.port_or_known_default(),
url.full = url.as_str(),
);
}
let request = {
let request = self
.client
.get(url)
.headers(self.headers.clone());
#[cfg(feature = "trace-context")]
let request = ::ploidy_util::trace::propagate(
::tracing::Span::current(),
request,
);
request
};
let response = request
.send()
.await?
.error_for_status()?;
.await?;
#[cfg(feature = "tracing")]
{
::tracing::record_all!(::tracing::Span::current(),
http.response.status_code = response.status().as_u16()
);
}
let response = response.error_for_status()?;
let _ = response;
Ok(())
}.await;
#[cfg(feature = "tracing")]
if let Err(err) = &result {
::tracing::record_all!(::tracing::Span::current(),
error.type = %err.category(),
);
}
result
}
}
};
assert_eq!(actual, expected);
+6
View File
@@ -12,6 +12,12 @@ impl ToTokens for CodegenLibrary {
pub mod client;
pub mod error;
#[cfg(feature = "tracing")]
extern crate self as tracing;
#[cfg(feature = "tracing")]
pub(crate) use ::ploidy_util::tracing::*;
// Re-export `ploidy-util`, so that consumers don't need to
// depend on it directly.
pub use ::ploidy_util as util;
+11
View File
@@ -14,6 +14,8 @@ base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
http = "1"
itertools = "0.14"
opentelemetry = { workspace = true, optional = true }
opentelemetry-http = { workspace = true, optional = true }
percent-encoding = "2.3"
ploidy-pointer = { workspace = true, features = [
"chrono",
@@ -37,8 +39,17 @@ serde_bytes = "0.11"
serde_json = { workspace = true }
serde_path_to_error = "0.1"
thiserror = "2"
tracing = { workspace = true, optional = true }
tracing-opentelemetry = { workspace = true, optional = true }
url = { version = "2", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] }
[features]
did-you-mean = ["ploidy-pointer/did-you-mean"]
tracing = ["dep:tracing"]
trace-context = [
"tracing",
"dep:opentelemetry",
"dep:opentelemetry-http",
"dep:tracing-opentelemetry",
]
+131 -29
View File
@@ -1,41 +1,143 @@
/// Transport-level error types.
use std::fmt::{Display, Formatter, Result as FmtResult};
use http::{HeaderName, StatusCode, uri::InvalidUri};
use url::ParseError as UrlParseError;
use crate::query::QueryParamError;
/// A client error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Network or connection error.
#[error("Network error")]
Network(#[from] reqwest::Error),
#[error("error building request")]
Build(#[from] BuildError),
/// Invalid JSON in request or response.
#[error("Malformed JSON")]
Json(#[from] JsonError),
#[error("HTTP transport error")]
Transport(#[source] reqwest::Error),
/// Invalid URL.
#[error("Malformed URL")]
Url(#[from] url::ParseError),
#[error("HTTP status error ({0})")]
Status(StatusCode),
/// Invalid query parameter.
#[error("Invalid query parameter")]
QueryParam(#[from] crate::QueryParamError),
/// Invalid request path.
#[error("Invalid request path")]
InvalidPath(#[from] crate::http::uri::InvalidUri),
/// Invalid HTTP header name.
#[error("Invalid header name")]
BadHeaderName(#[source] http::Error),
/// Invalid HTTP header value.
#[error("Invalid value for header `{0}`")]
BadHeaderValue(http::HeaderName, #[source] http::Error),
#[error("invalid or unexpected response body")]
Body(#[from] BodyError),
}
/// Invalid or unexpected JSON, with or without a path
impl Error {
/// Creates an error for an invalid HTTP header name.
#[cold]
pub fn bad_header_name(err: impl Into<http::Error>) -> Self {
Self::Build(BuildError::HeaderName(err.into()))
}
/// Creates an error for an invalid HTTP header value.
#[cold]
pub fn bad_header_value(name: HeaderName, err: impl Into<http::Error>) -> Self {
Self::Build(BuildError::HeaderValue(name, err.into()))
}
/// Returns the telemetry category for this error.
pub fn category(&self) -> ErrorCategory {
match self {
Self::Build(_) => ErrorCategory::Build,
Self::Transport(err) if err.is_timeout() => ErrorCategory::Timeout,
Self::Transport(err) if err.is_connect() => ErrorCategory::Connect,
Self::Transport(_) => ErrorCategory::Transport,
&Self::Status(status) => ErrorCategory::Status(status),
Self::Body(_) => ErrorCategory::Body,
}
}
}
impl From<InvalidUri> for Error {
fn from(err: InvalidUri) -> Self {
Self::Build(BuildError::Path(err))
}
}
impl From<QueryParamError> for Error {
fn from(err: QueryParamError) -> Self {
Self::Build(BuildError::QueryParam(err))
}
}
impl From<UrlParseError> for Error {
fn from(err: UrlParseError) -> Self {
Self::Build(BuildError::Url(err))
}
}
impl From<reqwest::Error> for Error {
#[cold]
fn from(err: reqwest::Error) -> Self {
if err.is_builder() {
Self::Build(BuildError::Request(err))
} else if let Some(status) = err.status() {
Self::Status(status)
} else {
Self::Transport(err)
}
}
}
impl From<serde_json::Error> for Error {
#[cold]
fn from(err: serde_json::Error) -> Self {
Self::Body(BodyError::Json(err))
}
}
impl From<serde_path_to_error::Error<serde_json::Error>> for Error {
#[cold]
fn from(err: serde_path_to_error::Error<serde_json::Error>) -> Self {
Self::Body(BodyError::JsonWithPath(err))
}
}
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("invalid URL")]
Url(#[source] UrlParseError),
#[error("invalid query parameter")]
QueryParam(#[source] QueryParamError),
#[error("invalid request path")]
Path(#[source] InvalidUri),
#[error("invalid header name")]
HeaderName(#[source] http::Error),
#[error("invalid value for header `{0}`")]
HeaderValue(HeaderName, #[source] http::Error),
#[error(transparent)]
Request(reqwest::Error),
}
/// Invalid or unexpected response body, with or without a path
/// to the unexpected section.
#[derive(Debug, thiserror::Error)]
pub enum JsonError {
pub enum BodyError {
#[error(transparent)]
Json(#[from] serde_json::Error),
Json(serde_json::Error),
#[error(transparent)]
JsonWithPath(#[from] serde_path_to_error::Error<serde_json::Error>),
JsonWithPath(serde_path_to_error::Error<serde_json::Error>),
}
/// The telemetry category for an [`Error`].
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorCategory {
Build,
Connect,
Timeout,
Transport,
Status(StatusCode),
Body,
}
impl Display for ErrorCategory {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.write_str(match self {
Self::Build => "build",
Self::Connect => "connect",
Self::Timeout => "timeout",
Self::Transport => "transport",
Self::Status(status) => status.as_str(),
Self::Body => "body",
})
}
}
+4
View File
@@ -3,6 +3,8 @@ pub mod binary;
pub mod date_time;
pub mod error;
pub mod query;
#[cfg(feature = "trace-context")]
pub mod trace;
pub use absent::{AbsentError, AbsentOr, AbsentOrExt, FieldAbsentError};
pub use binary::{Base64, Base64Error};
@@ -20,5 +22,7 @@ pub use serde;
pub use serde_bytes;
pub use serde_json;
pub use serde_path_to_error;
#[cfg(feature = "tracing")]
pub use tracing;
pub use url;
pub use uuid;
+24
View File
@@ -0,0 +1,24 @@
//! Tracing support for generated clients.
use http::HeaderMap;
use opentelemetry::global::get_text_map_propagator;
use opentelemetry_http::HeaderInjector;
use reqwest::RequestBuilder;
use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt;
/// Adds trace context request headers, if there is one and
/// a global [`TextMapPropagator`] is [set].
///
/// [`TextMapPropagator`]: opentelemetry::propagation::TextMapPropagator
/// [set]: opentelemetry::global::set_text_map_propagator
pub fn propagate(span: Span, request: RequestBuilder) -> RequestBuilder {
let context = span.context();
let mut headers = HeaderMap::new();
get_text_map_propagator(|p| {
p.inject_context(&context, &mut HeaderInjector(&mut headers));
});
// We intentionally use `request.headers()` to replace any
// existing trace headers.
request.headers(headers)
}