From 73ad1487f17d80f8ebd72a8e0c637289c8a3c337 Mon Sep 17 00:00:00 2001 From: Lina Butler Date: Sat, 13 Jun 2026 19:31:11 -0700 Subject: [PATCH] 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. --- Cargo.lock | 108 +++ Cargo.toml | 6 + README.md | 20 +- ploidy-codegen-rust/src/cargo.rs | 84 +- ploidy-codegen-rust/src/client.rs | 8 +- ploidy-codegen-rust/src/graph.rs | 6 +- ploidy-codegen-rust/src/operation.rs | 1057 +++++++++++++++++++------- ploidy-codegen-rust/src/resource.rs | 372 ++++++++- ploidy-codegen-rust/src/statics.rs | 6 + ploidy-util/Cargo.toml | 11 + ploidy-util/src/error.rs | 160 +++- ploidy-util/src/lib.rs | 4 + ploidy-util/src/trace.rs | 24 + 13 files changed, 1490 insertions(+), 376 deletions(-) create mode 100644 ploidy-util/src/trace.rs diff --git a/Cargo.lock b/Cargo.lock index f1ac61c..558ad45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 6491cc4..e82d96b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index a1750d4..e7e00ca 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ploidy-codegen-rust/src/cargo.rs b/ploidy-codegen-rust/src/cargo.rs index 142d0bb..dacbf26 100644 --- a/ploidy-codegen-rust/src/cargo.rs +++ b/ploidy-codegen-rust/src/cargo.rs @@ -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 diff --git a/ploidy-codegen-rust/src/client.rs b/ploidy-codegen-rust/src/client.rs index 39e4d20..7ca6642 100644 --- a/ploidy-codegen-rust/src/client.rs +++ b/ploidy-codegen-rust/src/client.rs @@ -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) } diff --git a/ploidy-codegen-rust/src/graph.rs b/ploidy-codegen-rust/src/graph.rs index 407550a..6fdc6b5 100644 --- a/ploidy-codegen-rust/src/graph.rs +++ b/ploidy-codegen-rust/src/graph.rs @@ -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))) diff --git a/ploidy-codegen-rust/src/operation.rs b/ploidy-codegen-rust/src/operation.rs index 49dae85..dab7c93 100644 --- a/ploidy-codegen-rust/src/operation.rs +++ b/ploidy-codegen-rust/src/operation.rs @@ -166,47 +166,67 @@ impl ToTokens for CodegenOperation<'_> { None => quote! { () }, }; - let build_url = self.url(); + let url = self.url(); - let build_query = self.query(); + let query = self.query(); - let http_method = CodegenMethod(self.op.method()); - - let build_request = match self.op.request() { - Some(RequestView::Json(_)) => quote! { - let response = self.client - .#http_method(url) - .headers(self.headers.clone()) - .json(&request.into()) - .send() - .await? - .error_for_status()?; - }, - Some(RequestView::Multipart) => quote! { - let response = self.client - .#http_method(url) - .headers(self.headers.clone()) - .multipart(form) - .send() - .await? - .error_for_status()?; - }, - None => quote! { - let response = self.client - .#http_method(url) - .headers(self.headers.clone()) - .send() - .await? - .error_for_status()?; - }, + let request = { + let method = CodegenMethod(self.op.method()); + let builder = match self.op.request() { + Some(RequestView::Json(_)) => quote! { + let request = self.client + .#method(url) + .headers(self.headers.clone()) + .json(&request.into()); + }, + Some(RequestView::Multipart) => quote! { + let request = self.client + .#method(url) + .headers(self.headers.clone()) + .multipart(form); + }, + None => quote! { + let request = self.client + .#method(url) + .headers(self.headers.clone()); + }, + }; + quote! { + #[cfg(feature = "tracing")] + { + ::tracing::record_all!(::tracing::Span::current(), + server.address = url.host_str(), + server.port = url.port_or_known_default(), + // We intentionally include the full URL, + // without redaction. + url.full = url.as_str(), + ); + } + let request = { + #builder + #[cfg(feature = "trace-context")] + let request = ::ploidy_util::trace::propagate( + ::tracing::Span::current(), + request, + ); + request + }; + let response = request.send().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 parse_response = if self.op.response().is_some() { + let response = if self.op.response().is_some() { quote! { 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) } } else { @@ -218,6 +238,32 @@ impl ToTokens for CodegenOperation<'_> { let method_name = CodegenIdentUsage::Method(self.graph.ident(self.op.id())); + let instrument = { + let name = format!("{} {}", self.op.method().as_str(), self.op.path()); + let template = self.op.path().to_string(); + let method = self.op.method().as_str(); + let mut fields = vec![ + quote!(otel.name = #name), + quote!(otel.kind = "client"), + quote!(url.template = #template), + quote!(http.request.method = #method), + quote!(server.address, server.port, url.full, http.response.status_code, error.type), + ]; + fields.extend(paths.iter().map(|param| { + let param = CodegenIdentUsage::Param( + self.graph + .ident(IdentMapping::Path(self.op.id(), param.name())), + ); + quote!(#param = %#param) + })); + quote! { + #[cfg_attr(feature = "tracing", ::tracing::instrument( + skip_all, + fields(#(#fields),*) + ))] + } + }; + let doc = { let url = format!(" {} {}", self.op.method().as_str(), self.op.path()); match self.op.description() { @@ -237,14 +283,24 @@ impl ToTokens for CodegenOperation<'_> { tokens.append_all(quote! { #doc + #instrument pub async fn #method_name( &self, #(#params),* ) -> Result<#return_type, crate::error::Error> { - #build_url - #build_query - #build_request - #parse_response + let result: Result<_, crate::error::Error> = async move { + #url + #query + #request + #response + }.await; + #[cfg(feature = "tracing")] + if let Err(err) = &result { + ::tracing::record_all!(::tracing::Span::current(), + error.type = %err.category(), + ); + } + result } }); } @@ -321,38 +377,88 @@ mod tests { #[doc = " Gets an item."] #[doc = ""] #[doc = " GET /items/{item_id}"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "GET /items/{item_id}", + otel.kind = "client", + url.template = "/items/{item_id}", + http.request.method = "GET", + server.address, + server.port, + url.full, + http.response.status_code, + error.type, + item_id = %item_id + ) + ) + )] pub async fn get_item( &self, item_id: &str, query: ¶meters::GetItemQuery ) -> Result<(), crate::error::Error> { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .push("items") - .push(item_id); - }); - url - }; - let url = ::ploidy_util::serde::Serialize::serialize( - query, - ::ploidy_util::QuerySerializer::new( - url, - parameters::GetItemQuery::STYLES, - ), - )?; - let response = self - .client - .get(url) - .headers(self.headers.clone()) - .send() - .await? - .error_for_status()?; - let _ = response; - Ok(()) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .push("items") + .push(item_id); + }); + url + }; + let url = ::ploidy_util::serde::Serialize::serialize( + query, + ::ploidy_util::QuerySerializer::new( + url, + parameters::GetItemQuery::STYLES, + ), + )?; + #[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?; + #[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); @@ -391,36 +497,85 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " GET /items"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "GET /items", + otel.kind = "client", + url.template = "/items", + http.request.method = "GET", + server.address, + server.port, + url.full, + http.response.status_code, + error.type + ) + ) + )] pub async fn get_items( &self, query: ¶meters::GetItemsQuery ) -> Result<(), crate::error::Error> { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .push("items"); - }); - url - }; - let url = ::ploidy_util::serde::Serialize::serialize( - query, - ::ploidy_util::QuerySerializer::new( - url, - parameters::GetItemsQuery::STYLES, - ), - )?; - let response = self - .client - .get(url) - .headers(self.headers.clone()) - .send() - .await? - .error_for_status()?; - let _ = response; - Ok(()) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .push("items"); + }); + url + }; + let url = ::ploidy_util::serde::Serialize::serialize( + query, + ::ploidy_util::QuerySerializer::new( + url, + parameters::GetItemsQuery::STYLES, + ), + )?; + #[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?; + #[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); @@ -464,38 +619,88 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " GET /search/{query}"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "GET /search/{query}", + otel.kind = "client", + url.template = "/search/{query}", + http.request.method = "GET", + server.address, + server.port, + url.full, + http.response.status_code, + error.type, + query_2 = %query_2 + ) + ) + )] pub async fn search( &self, query_2: &str, query: ¶meters::SearchQuery ) -> Result<(), crate::error::Error> { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .push("search") - .push(query_2); - }); - url - }; - let url = ::ploidy_util::serde::Serialize::serialize( - query, - ::ploidy_util::QuerySerializer::new( - url, - parameters::SearchQuery::STYLES, - ), - )?; - let response = self - .client - .get(url) - .headers(self.headers.clone()) - .send() - .await? - .error_for_status()?; - let _ = response; - Ok(()) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .push("search") + .push(query_2); + }); + url + }; + let url = ::ploidy_util::serde::Serialize::serialize( + query, + ::ploidy_util::QuerySerializer::new( + url, + parameters::SearchQuery::STYLES, + ), + )?; + #[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?; + #[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); @@ -556,43 +761,92 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " PUT /items/{item_id}"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "PUT /items/{item_id}", + otel.kind = "client", + url.template = "/items/{item_id}", + http.request.method = "PUT", + server.address, + server.port, + url.full, + http.response.status_code, + error.type, + item_id = %item_id + ) + ) + )] pub async fn update_item( &self, item_id: &str, query: ¶meters::UpdateItemQuery, request: impl Into ) -> Result { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .push("items") - .push(item_id); - }); - url - }; - let url = ::ploidy_util::serde::Serialize::serialize( - query, - ::ploidy_util::QuerySerializer::new( - url, - parameters::UpdateItemQuery::STYLES, - ), - )?; - let response = self - .client - .put(url) - .headers(self.headers.clone()) - .json(&request.into()) - .send() - .await? - .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)?; - Ok(result) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .push("items") + .push(item_id); + }); + url + }; + let url = ::ploidy_util::serde::Serialize::serialize( + query, + ::ploidy_util::QuerySerializer::new( + url, + parameters::UpdateItemQuery::STYLES, + ), + )?; + #[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 + .put(url) + .headers(self.headers.clone()) + .json(&request.into()); + #[cfg(feature = "trace-context")] + let request = ::ploidy_util::trace::propagate( + ::tracing::Span::current(), + request, + ); + request + }; + let response = request + .send() + .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)?; + 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); @@ -633,30 +887,80 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " GET /items/{item_id}"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "GET /items/{item_id}", + otel.kind = "client", + url.template = "/items/{item_id}", + http.request.method = "GET", + server.address, + server.port, + url.full, + http.response.status_code, + error.type, + item_id = %item_id + ) + ) + )] pub async fn get_item( &self, item_id: &str ) -> Result<(), crate::error::Error> { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .push("items") - .push(item_id); - }); - url - }; - let response = self - .client - .get(url) - .headers(self.headers.clone()) - .send() - .await? - .error_for_status()?; - let _ = response; - Ok(()) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .push("items") + .push(item_id); + }); + url + }; + #[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?; + #[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); @@ -691,30 +995,80 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " GET /items/{item_id}"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "GET /items/{item_id}", + otel.kind = "client", + url.template = "/items/{item_id}", + http.request.method = "GET", + server.address, + server.port, + url.full, + http.response.status_code, + error.type, + item_id = %item_id + ) + ) + )] pub async fn get_item( &self, item_id: &str ) -> Result<(), crate::error::Error> { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .push("items") - .push(item_id); - }); - url - }; - let response = self - .client - .get(url) - .headers(self.headers.clone()) - .send() - .await? - .error_for_status()?; - let _ = response; - Ok(()) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .push("items") + .push(item_id); + }); + url + }; + #[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?; + #[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); @@ -765,36 +1119,84 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " POST /v1/messages?beta=true&expand="] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "POST /v1/messages?beta=true&expand=", + otel.kind = "client", + url.template = "/v1/messages?beta=true&expand=", + http.request.method = "POST", + server.address, + server.port, + url.full, + http.response.status_code, + error.type + ) + ) + )] pub async fn beta_create_message( &self, request: impl Into ) -> Result { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .extend(&["v1", "messages"]); - }); - url.query_pairs_mut() - .append_pair("beta", "true") - .append_pair("expand", ""); - url - }; - let response = self - .client - .post(url) - .headers(self.headers.clone()) - .json(&request.into()) - .send() - .await? - .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)?; - Ok(result) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .extend(&["v1", "messages"]); + }); + url.query_pairs_mut() + .append_pair("beta", "true") + .append_pair("expand", ""); + url + }; + #[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 + .post(url) + .headers(self.headers.clone()) + .json(&request.into()); + #[cfg(feature = "trace-context")] + let request = ::ploidy_util::trace::propagate( + ::tracing::Span::current(), + request, + ); + request + }; + let response = request + .send() + .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)?; + 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); @@ -849,43 +1251,91 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " POST /v1/messages?beta=true"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "POST /v1/messages?beta=true", + otel.kind = "client", + url.template = "/v1/messages?beta=true", + http.request.method = "POST", + server.address, + server.port, + url.full, + http.response.status_code, + error.type + ) + ) + )] pub async fn beta_create_message( &self, query: ¶meters::BetaCreateMessageQuery, request: impl Into ) -> Result { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .extend(&["v1", "messages"]); - }); - url.query_pairs_mut() - .append_pair("beta", "true"); - url - }; - let url = ::ploidy_util::serde::Serialize::serialize( - query, - ::ploidy_util::QuerySerializer::new( - url, - parameters::BetaCreateMessageQuery::STYLES, - ), - )?; - let response = self - .client - .post(url) - .headers(self.headers.clone()) - .json(&request.into()) - .send() - .await? - .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)?; - Ok(result) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .extend(&["v1", "messages"]); + }); + url.query_pairs_mut() + .append_pair("beta", "true"); + url + }; + let url = ::ploidy_util::serde::Serialize::serialize( + query, + ::ploidy_util::QuerySerializer::new( + url, + parameters::BetaCreateMessageQuery::STYLES, + ), + )?; + #[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 + .post(url) + .headers(self.headers.clone()) + .json(&request.into()); + #[cfg(feature = "trace-context")] + let request = ::ploidy_util::trace::propagate( + ::tracing::Span::current(), + request, + ); + request + }; + let response = request + .send() + .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)?; + 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); @@ -939,43 +1389,92 @@ mod tests { let actual: syn::ImplItemFn = parse_quote!(#codegen); let expected: syn::ImplItemFn = parse_quote! { #[doc = " GET /v1/models/{model_id}?beta=true"] + #[cfg_attr( + feature = "tracing", + ::tracing::instrument( + skip_all, + fields( + otel.name = "GET /v1/models/{model_id}?beta=true", + otel.kind = "client", + url.template = "/v1/models/{model_id}?beta=true", + http.request.method = "GET", + server.address, + server.port, + url.full, + http.response.status_code, + error.type, + model_id = %model_id + ) + ) + )] pub async fn beta_get_model( &self, model_id: &str, query: ¶meters::BetaGetModelQuery ) -> Result { - let url = { - let mut url = self.base_url.clone(); - let _ = url - .path_segments_mut() - .map(|mut segments| { - segments.pop_if_empty() - .extend(&["v1", "models"]) - .push(model_id); - }); - url.query_pairs_mut() - .append_pair("beta", "true"); - url - }; - let url = ::ploidy_util::serde::Serialize::serialize( - query, - ::ploidy_util::QuerySerializer::new( - url, - parameters::BetaGetModelQuery::STYLES, - ), - )?; - let response = self - .client - .get(url) - .headers(self.headers.clone()) - .send() - .await? - .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)?; - Ok(result) + let result: Result<_, crate::error::Error> = async move { + let url = { + let mut url = self.base_url.clone(); + let _ = url + .path_segments_mut() + .map(|mut segments| { + segments.pop_if_empty() + .extend(&["v1", "models"]) + .push(model_id); + }); + url.query_pairs_mut() + .append_pair("beta", "true"); + url + }; + let url = ::ploidy_util::serde::Serialize::serialize( + query, + ::ploidy_util::QuerySerializer::new( + url, + parameters::BetaGetModelQuery::STYLES, + ), + )?; + #[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?; + #[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)?; + 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); diff --git a/ploidy-codegen-rust/src/resource.rs b/ploidy-codegen-rust/src/resource.rs index 4f454f3..781bf7c 100644 --- a/ploidy-codegen-rust/src/resource.rs +++ b/ploidy-codegen-rust/src/resource.rs @@ -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::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::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: ¶meters::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: ¶meters::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: ¶meters::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); diff --git a/ploidy-codegen-rust/src/statics.rs b/ploidy-codegen-rust/src/statics.rs index 4bcc3b0..e5efabc 100644 --- a/ploidy-codegen-rust/src/statics.rs +++ b/ploidy-codegen-rust/src/statics.rs @@ -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; diff --git a/ploidy-util/Cargo.toml b/ploidy-util/Cargo.toml index 819b550..12a175c 100644 --- a/ploidy-util/Cargo.toml +++ b/ploidy-util/Cargo.toml @@ -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", +] diff --git a/ploidy-util/src/error.rs b/ploidy-util/src/error.rs index b29c7c4..6b66f6d 100644 --- a/ploidy-util/src/error.rs +++ b/ploidy-util/src/error.rs @@ -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) -> 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) -> 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 for Error { + fn from(err: InvalidUri) -> Self { + Self::Build(BuildError::Path(err)) + } +} + +impl From for Error { + fn from(err: QueryParamError) -> Self { + Self::Build(BuildError::QueryParam(err)) + } +} + +impl From for Error { + fn from(err: UrlParseError) -> Self { + Self::Build(BuildError::Url(err)) + } +} + +impl From 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 for Error { + #[cold] + fn from(err: serde_json::Error) -> Self { + Self::Body(BodyError::Json(err)) + } +} + +impl From> for Error { + #[cold] + fn from(err: serde_path_to_error::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), + JsonWithPath(serde_path_to_error::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", + }) + } } diff --git a/ploidy-util/src/lib.rs b/ploidy-util/src/lib.rs index f4ee63e..84e7cfb 100644 --- a/ploidy-util/src/lib.rs +++ b/ploidy-util/src/lib.rs @@ -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; diff --git a/ploidy-util/src/trace.rs b/ploidy-util/src/trace.rs new file mode 100644 index 0000000..250d633 --- /dev/null +++ b/ploidy-util/src/trace.rs @@ -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) +}