Skip to content

Commit bb63a3f

Browse files
authored
expose span_exporter() function to build custom span processor (#20)
* expose `span_exporter()` function to build custom span processor * add CHANGELOG * don't try to export to logfire from CI
1 parent dfc9a3e commit bb63a3f

File tree

9 files changed

+308
-214
lines changed

9 files changed

+308
-214
lines changed

.github/workflows/main.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
- '**'
99
pull_request: {}
1010

11+
env:
12+
# avoid attempting to send logs to logfire from tests
13+
LOGFIRE_SEND_TO_LOGFIRE: no
14+
1115
jobs:
1216
lint:
1317
runs-on: ubuntu-latest
@@ -74,6 +78,9 @@ jobs:
7478

7579
- run: cargo llvm-cov --all-features --codecov --output-path codecov.json
7680

81+
# rust doctests (not included in cargo llvm-cov, see https://github.com/taiki-e/cargo-llvm-cov/issues/2)
82+
- run: cargo test --doc
83+
7784
- uses: codecov/codecov-action@v3
7885
with:
7986
token: ${{ secrets.CODECOV_TOKEN }}

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ repos:
2020
pass_filenames: false
2121
- id: clippy
2222
name: Clippy
23-
entry: cargo clippy -- -D warnings
23+
entry: cargo clippy --all-features -- -D warnings
2424
types: [rust]
2525
language: system
2626
pass_filenames: false
2727
- id: test
2828
name: Test
29-
entry: cargo test
29+
entry: cargo test --all-features
3030
types: [rust]
3131
language: system
3232
pass_filenames: false

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## Unreleased
2+
3+
- Add `span_exporter()` helper to build a span exporter directly in [#20](https://github.com/pydantic/logfire-rust/pull/20)
4+
- Fix `set_resource` not being called on span processors added with `with_additional_span_processor()` in [#20](https://github.com/pydantic/logfire-rust/pull/20)
5+
6+
## [v0.1.0] (2025-03-13)
7+
8+
Initial release.
9+
10+
[v0.1.0]: https://github.com/pydantic/logfire-rust/commits/v0.1.0

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ repository = "https://github.com/pydantic/logfire-rust"
1313
log = "0.4"
1414
env_filter = "0.1"
1515

16+
# deps for grpc export
17+
http = { version = "1.2", optional = true }
18+
tonic = { version = "0.12", optional = true }
19+
1620
rand = "0.9.0"
1721

1822
opentelemetry = { version = "0.28", default-features = false, features = ["trace"] }
@@ -39,7 +43,7 @@ ulid = "1.2.0"
3943
default = ["export-http-protobuf"]
4044
serde = ["dep:serde"]
4145
# FIXME might need rustls feature on all of these?
42-
export-grpc = ["opentelemetry-otlp/grpc-tonic", "opentelemetry-otlp/tls"]
46+
export-grpc = ["opentelemetry-otlp/grpc-tonic", "opentelemetry-otlp/tls", "dep:http", "dep:tonic"]
4347
export-http-protobuf = ["opentelemetry-otlp/http-proto", "opentelemetry-otlp/reqwest-blocking-client", "opentelemetry-otlp/reqwest-rustls"]
4448
export-http-json = ["opentelemetry-otlp/http-json", "opentelemetry-otlp/reqwest-blocking-client", "opentelemetry-otlp/reqwest-rustls"]
4549

@@ -52,3 +56,4 @@ unwrap_used = "deny"
5256
# in general we lint against the pedantic group, but we will whitelist
5357
# certain lints which we don't want to enforce (for now)
5458
pedantic = { level = "deny", priority = -1 }
59+
implicit_hasher = "allow"

src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ impl SpanProcessor for BoxedSpanProcessor {
187187
fn shutdown(&self) -> opentelemetry_sdk::error::OTelSdkResult {
188188
self.0.shutdown()
189189
}
190+
191+
fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
192+
self.0.set_resource(resource);
193+
}
190194
}
191195

192196
/// Wrapper around an `IdGenerator` to use in `id_generator`.

src/exporters.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
//! Helper functions to configure mo
2+
use std::collections::HashMap;
3+
4+
use opentelemetry_otlp::{MetricExporter, Protocol};
5+
use opentelemetry_sdk::{metrics::exporter::PushMetricExporter, trace::SpanExporter};
6+
7+
use crate::{
8+
ConfigureError, get_optional_env,
9+
internal::exporters::remove_pending::RemovePendingSpansExporter,
10+
};
11+
12+
macro_rules! feature_required {
13+
($feature_name:literal, $functionality:expr, $if_enabled:expr) => {{
14+
#[cfg(feature = $feature_name)]
15+
{
16+
let _ = $functionality; // to avoid unused code warning
17+
$if_enabled
18+
}
19+
20+
#[cfg(not(feature = $feature_name))]
21+
{
22+
return Err(ConfigureError::LogfireFeatureRequired {
23+
feature_name: $feature_name,
24+
functionality: $functionality,
25+
});
26+
}
27+
}};
28+
}
29+
30+
/// Build a [`SpanExporter`][opentelemetry::trace::SpanExporter] for passing to
31+
/// [`with_additional_span_processor()`][crate::LogfireConfigBuilder::with_additional_span_processor].
32+
///
33+
/// This uses `OTEL_EXPORTER_OTLP_PROTOCOL` and `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` environment
34+
/// variables to determine the protocol to use (or otherwise defaults to [`Protocol::HttpBinary`]).
35+
///
36+
/// # Errors
37+
///
38+
/// Returns an error if the protocol specified by the env var is not supported or if the required feature is not enabled for
39+
/// the given protocol.
40+
///
41+
/// Returns an error if the endpoint is not a valid URI.
42+
///
43+
/// Returns an error if any headers are not valid HTTP headers.
44+
pub fn span_exporter(
45+
endpoint: &str,
46+
headers: Option<HashMap<String, String>>,
47+
) -> Result<impl SpanExporter + use<>, ConfigureError> {
48+
let (source, protocol) = protocol_from_env("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")?;
49+
50+
let builder = opentelemetry_otlp::SpanExporter::builder();
51+
52+
// FIXME: it would be nice to let `opentelemetry-rust` handle this; ideally we could detect if
53+
// OTEL_EXPORTER_OTLP_PROTOCOL or OTEL_EXPORTER_OTLP_TRACES_PROTOCOL is set and let the SDK
54+
// make a builder. (If unset, we could supply our preferred exporter.)
55+
//
56+
// But at the moment otel-rust ignores these env vars; see
57+
// https://github.com/open-telemetry/opentelemetry-rust/issues/1983
58+
let span_exporter =
59+
match protocol {
60+
Protocol::Grpc => {
61+
feature_required!("export-grpc", source, {
62+
use opentelemetry_otlp::WithTonicConfig;
63+
builder
64+
.with_tonic()
65+
.with_channel(
66+
tonic::transport::Channel::builder(endpoint.try_into().map_err(
67+
|e: http::uri::InvalidUri| ConfigureError::Other(e.into()),
68+
)?)
69+
.connect_lazy(),
70+
)
71+
.with_metadata(build_metadata_from_headers(headers.as_ref())?)
72+
.build()?
73+
})
74+
}
75+
Protocol::HttpBinary => {
76+
feature_required!("export-http-protobuf", source, {
77+
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
78+
builder
79+
.with_http()
80+
.with_protocol(Protocol::HttpBinary)
81+
.with_headers(headers.unwrap_or_default())
82+
.with_endpoint(format!("{endpoint}/v1/traces"))
83+
.build()?
84+
})
85+
}
86+
Protocol::HttpJson => {
87+
feature_required!("export-http-json", source, {
88+
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
89+
builder
90+
.with_http()
91+
.with_protocol(Protocol::HttpBinary)
92+
.with_headers(headers.unwrap_or_default())
93+
.with_endpoint(format!("{endpoint}/v1/traces"))
94+
.build()?
95+
})
96+
}
97+
};
98+
Ok(RemovePendingSpansExporter::new(span_exporter))
99+
}
100+
101+
// TODO: make this public?
102+
pub(crate) fn metric_exporter(
103+
endpoint: &str,
104+
headers: Option<HashMap<String, String>>,
105+
) -> Result<impl PushMetricExporter + use<>, ConfigureError> {
106+
let (source, protocol) = protocol_from_env("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL")?;
107+
108+
let builder =
109+
MetricExporter::builder().with_temporality(opentelemetry_sdk::metrics::Temporality::Delta);
110+
111+
// FIXME: it would be nice to let `opentelemetry-rust` handle this; ideally we could detect if
112+
// OTEL_EXPORTER_OTLP_PROTOCOL or OTEL_EXPORTER_OTLP_METRICS_PROTOCOL is set and let the SDK
113+
// make a builder. (If unset, we could supply our preferred exporter.)
114+
//
115+
// But at the moment otel-rust ignores these env vars; see
116+
// https://github.com/open-telemetry/opentelemetry-rust/issues/1983
117+
match protocol {
118+
Protocol::Grpc => {
119+
feature_required!("export-grpc", source, {
120+
use opentelemetry_otlp::WithTonicConfig;
121+
Ok(builder
122+
.with_tonic()
123+
.with_channel(
124+
tonic::transport::Channel::builder(
125+
endpoint.try_into().map_err(|e: http::uri::InvalidUri| {
126+
ConfigureError::Other(e.into())
127+
})?,
128+
)
129+
.connect_lazy(),
130+
)
131+
.with_metadata(build_metadata_from_headers(headers.as_ref())?)
132+
.build()?)
133+
})
134+
}
135+
Protocol::HttpBinary => {
136+
feature_required!("export-http-protobuf", source, {
137+
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
138+
Ok(builder
139+
.with_http()
140+
.with_protocol(Protocol::HttpBinary)
141+
.with_headers(headers.unwrap_or_default())
142+
.with_endpoint(format!("{endpoint}/v1/metrics"))
143+
.build()?)
144+
})
145+
}
146+
Protocol::HttpJson => {
147+
feature_required!("export-http-json", source, {
148+
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
149+
Ok(builder
150+
.with_http()
151+
.with_protocol(Protocol::HttpBinary)
152+
.with_headers(headers.unwrap_or_default())
153+
.with_endpoint(format!("{endpoint}/v1/metrics"))
154+
.build()?)
155+
})
156+
}
157+
}
158+
}
159+
160+
#[cfg(feature = "export-grpc")]
161+
fn build_metadata_from_headers(
162+
headers: Option<&HashMap<String, String>>,
163+
) -> Result<tonic::metadata::MetadataMap, ConfigureError> {
164+
let Some(headers) = headers else {
165+
return Ok(tonic::metadata::MetadataMap::new());
166+
};
167+
168+
let mut header_map = http::HeaderMap::new();
169+
for (key, value) in headers {
170+
header_map.insert(
171+
http::HeaderName::try_from(key).map_err(|e| ConfigureError::Other(e.into()))?,
172+
http::HeaderValue::try_from(value).map_err(|e| ConfigureError::Other(e.into()))?,
173+
);
174+
}
175+
Ok(tonic::metadata::MetadataMap::from_headers(header_map))
176+
}
177+
178+
// current default logfire protocol is to export over HTTP in binary format
179+
const DEFAULT_LOGFIRE_PROTOCOL: Protocol = Protocol::HttpBinary;
180+
181+
// standard OTLP protocol values in configuration
182+
const OTEL_EXPORTER_OTLP_PROTOCOL_GRPC: &str = "grpc";
183+
const OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF: &str = "http/protobuf";
184+
const OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_JSON: &str = "http/json";
185+
186+
/// Temporary workaround for lack of <https://github.com/open-telemetry/opentelemetry-rust/pull/2758>
187+
fn protocol_from_str(value: &str) -> Result<Protocol, ConfigureError> {
188+
match value {
189+
OTEL_EXPORTER_OTLP_PROTOCOL_GRPC => Ok(Protocol::Grpc),
190+
OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF => Ok(Protocol::HttpBinary),
191+
OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_JSON => Ok(Protocol::HttpJson),
192+
_ => Err(ConfigureError::Other(
193+
format!("unsupported protocol: {value}").into(),
194+
)),
195+
}
196+
}
197+
198+
/// Get a protocol from the environment (or default value), returning a string describing the source
199+
/// plus the parsed protocol.
200+
fn protocol_from_env(data_env_var: &str) -> Result<(String, Protocol), ConfigureError> {
201+
// try both data-specific env var and general protocol
202+
[data_env_var, "OTEL_EXPORTER_OTLP_PROTOCOL"]
203+
.into_iter()
204+
.find_map(|var_name| match get_optional_env(var_name, None) {
205+
Ok(Some(value)) => Some(Ok((var_name, value))),
206+
Ok(None) => None,
207+
Err(e) => Some(Err(e)),
208+
})
209+
.transpose()?
210+
.map_or_else(
211+
|| {
212+
Ok((
213+
"the default logfire export protocol".to_string(),
214+
DEFAULT_LOGFIRE_PROTOCOL,
215+
))
216+
},
217+
|(var_name, value)| Ok((format!("`{var_name}={value}`"), protocol_from_str(&value)?)),
218+
)
219+
}

src/internal/exporters/remove_pending.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ impl<Inner: SpanExporter> SpanExporter for RemovePendingSpansExporter<Inner> {
4646
spans.extend(spans_by_id.into_values());
4747
self.0.export(spans)
4848
}
49+
50+
fn shutdown(&mut self) -> OTelSdkResult {
51+
self.0.shutdown()
52+
}
53+
54+
fn force_flush(&mut self) -> OTelSdkResult {
55+
self.0.force_flush()
56+
}
57+
58+
fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
59+
self.0.set_resource(resource);
60+
}
4961
}
5062

5163
#[cfg(test)]

0 commit comments

Comments
 (0)