Skip to content

Commit a3d44e2

Browse files
jan-xyzcijothomas
andauthored
feat(tower): Add configurable route extraction (#528)
Co-authored-by: Cijo Thomas <cijo.thomas@gmail.com>
1 parent 3d0359a commit a3d44e2

File tree

4 files changed

+653
-25
lines changed

4 files changed

+653
-25
lines changed

opentelemetry-instrumentation-tower/CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## vNext
44

5+
### Added
6+
7+
* Configurable route extraction with built-in extractors:
8+
- `NoRouteExtractor` - No route, uses only HTTP method (e.g., `GET`), safest for cardinality
9+
- `PathExtractor` - Uses the URL path without query params (e.g., `/users/123`)
10+
- `AxumMatchedPathExtractor` - Uses Axum's `MatchedPath` for route templates (requires `axum` feature)
11+
- `FnRouteExtractor` - Custom function-based extraction via `with_route_extractor_fn()`
12+
* Default route extractor depends on features:
13+
- With `axum` feature: Uses `AxumMatchedPathExtractor` (route templates, low cardinality)
14+
- Without `axum` feature: Uses `NoRouteExtractor` (method only, safest)
15+
* Route extraction now provides both span names and `http.route` metric attribute from the same source
16+
517
### Changed
618

719
* **BREAKING**: Removed public `with_meter()` method. The middleware now uses global meter and tracer providers by
@@ -19,6 +31,36 @@
1931

2032
### Migration Guide
2133

34+
#### Route Extraction Configuration
35+
36+
```rust
37+
use opentelemetry_instrumentation_tower::{
38+
HTTPLayerBuilder,
39+
NoRouteExtractor,
40+
PathExtractor,
41+
};
42+
43+
// No route (default without axum feature) - span name: "GET"
44+
let layer = HTTPLayerBuilder::builder()
45+
.with_route_extractor(NoRouteExtractor)
46+
.build()
47+
.unwrap();
48+
49+
// Path (strips query params) - span name: "GET /users/123"
50+
let layer = HTTPLayerBuilder::builder()
51+
.with_route_extractor(PathExtractor)
52+
.build()
53+
.unwrap();
54+
55+
// Custom function - return Some(route) or None for method-only
56+
let layer = HTTPLayerBuilder::builder()
57+
.with_route_extractor_fn(|req: &http::Request<_>| {
58+
Some(req.uri().path().to_owned())
59+
})
60+
.build()
61+
.unwrap();
62+
```
63+
2264
#### API Changes
2365

2466
Before:
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "example-custom-route-extractor"
3+
version = "0.1.0-alpha.0"
4+
edition = "2021"
5+
rust-version = "1.75.0"
6+
7+
[dependencies]
8+
opentelemetry_instrumentation_tower = { path = "../../", package = "opentelemetry-instrumentation-tower", default-features = false }
9+
axum = { features = ["http1", "tokio"], version = "0.8", default-features = false }
10+
http = { version = "1", default-features = false }
11+
opentelemetry = { workspace = true }
12+
opentelemetry_sdk = { workspace = true, default-features = false }
13+
opentelemetry-otlp = { version = "0.31.0", features = ["grpc-tonic", "metrics", "trace"], default-features = false }
14+
tokio = { version = "1", features = ["rt-multi-thread"], default-features = false }
15+
16+
[lints]
17+
workspace = true
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//! Example: Custom Route Extraction with Match Tables
2+
//!
3+
//! This example demonstrates how to use `HTTPLayerBuilder::with_route_extractor_fn()`
4+
//! to implement custom route normalization logic that reduces cardinality by replacing
5+
//! known dynamic path segments with placeholders.
6+
//!
7+
//! Use case: Your API has user-specific endpoints like `/users/alice/profile` and
8+
//! `/users/bob/profile`. Without normalization, each user creates a unique span name
9+
//! and `http.route` attribute, causing cardinality explosion in your metrics backend.
10+
//!
11+
//! Solution: Use a match table to recognize known usernames and replace them with
12+
//! `{username}`, producing consistent routes like `/users/{username}/profile`.
13+
14+
use axum::extract::Path;
15+
use axum::routing::get;
16+
use axum::Router;
17+
use opentelemetry::global;
18+
use opentelemetry_instrumentation_tower::HTTPLayerBuilder;
19+
use opentelemetry_otlp::{MetricExporter, SpanExporter};
20+
use opentelemetry_sdk::{
21+
metrics::{PeriodicReader, SdkMeterProvider},
22+
trace::SdkTracerProvider,
23+
};
24+
use std::collections::HashSet;
25+
use std::sync::Arc;
26+
use std::time::Duration;
27+
28+
const SERVICE_NAME: &str = "example-custom-route-extractor";
29+
const _OTEL_METRIC_EXPORT_INTERVAL: Duration = Duration::from_secs(10);
30+
31+
fn init_otel_resource() -> opentelemetry_sdk::Resource {
32+
opentelemetry_sdk::Resource::builder()
33+
.with_service_name(SERVICE_NAME)
34+
.build()
35+
}
36+
37+
/// Handler for user profile requests.
38+
async fn get_user_profile(Path(username): Path<String>) -> String {
39+
format!("Profile for user: {username}")
40+
}
41+
42+
/// Handler for user posts.
43+
async fn get_user_posts(Path(username): Path<String>) -> String {
44+
format!("Posts by user: {username}")
45+
}
46+
47+
/// Handler for the index page.
48+
async fn index() -> &'static str {
49+
"Welcome! Try /users/alice/profile or /users/bob/posts"
50+
}
51+
52+
/// Custom route extractor that normalizes known usernames to `{username}`.
53+
///
54+
/// This function demonstrates a pattern-based approach to route normalization:
55+
/// 1. Parse the path into segments
56+
/// 2. Check if a segment matches a known pattern (username in this case)
57+
/// 3. Replace matching segments with a placeholder
58+
///
59+
/// For production use, you might:
60+
/// - Load usernames from a database or cache
61+
/// - Use regex patterns for more complex matching
62+
/// - Combine multiple normalization rules
63+
fn normalize_route(known_usernames: Arc<HashSet<String>>, path: &str) -> Option<String> {
64+
let mut result = String::with_capacity(path.len());
65+
66+
for (i, segment) in path.split('/').enumerate() {
67+
if i > 0 {
68+
result.push('/');
69+
}
70+
if known_usernames.contains(segment) {
71+
result.push_str("{username}");
72+
} else {
73+
result.push_str(segment);
74+
}
75+
}
76+
77+
Some(result)
78+
}
79+
80+
#[tokio::main]
81+
async fn main() {
82+
let meter_provider = {
83+
let exporter = MetricExporter::builder().with_tonic().build().unwrap();
84+
85+
let reader = PeriodicReader::builder(exporter)
86+
.with_interval(_OTEL_METRIC_EXPORT_INTERVAL)
87+
.build();
88+
89+
let provider = SdkMeterProvider::builder()
90+
.with_reader(reader)
91+
.with_resource(init_otel_resource())
92+
.build();
93+
94+
global::set_meter_provider(provider.clone());
95+
provider
96+
};
97+
98+
let tracer_provider = {
99+
let exporter = SpanExporter::builder().with_tonic().build().unwrap();
100+
101+
let provider = SdkTracerProvider::builder()
102+
.with_batch_exporter(exporter)
103+
.with_resource(init_otel_resource())
104+
.build();
105+
106+
global::set_tracer_provider(provider.clone());
107+
provider
108+
};
109+
110+
// Known usernames that should be normalized to {username}.
111+
// In production, this could be loaded from a database, config file,
112+
// or populated dynamically from authentication tokens.
113+
let known_usernames: Arc<HashSet<String>> = Arc::new(
114+
["alice", "bob", "charlie", "dave"]
115+
.iter()
116+
.map(|s| s.to_string())
117+
.collect(),
118+
);
119+
120+
// Create the HTTP layer with a custom route extractor.
121+
// The closure captures the known_usernames set and uses it to normalize routes.
122+
let otel_layer = HTTPLayerBuilder::builder()
123+
.with_route_extractor_fn({
124+
let usernames = known_usernames.clone();
125+
move |req: &http::Request<_>| normalize_route(usernames.clone(), req.uri().path())
126+
})
127+
.build()
128+
.unwrap();
129+
130+
// With this configuration:
131+
// - GET /users/alice/profile -> span name: "GET /users/{username}/profile"
132+
// - GET /users/bob/posts -> span name: "GET /users/{username}/posts"
133+
// - GET /users/unknown/profile -> span name: "GET /users/unknown/profile"
134+
// (unknown users are not normalized - you might want to handle this differently)
135+
136+
let app = Router::new()
137+
.route("/", get(index))
138+
.route("/users/{username}/profile", get(get_user_profile))
139+
.route("/users/{username}/posts", get(get_user_posts))
140+
.layer(otel_layer);
141+
142+
let listener = tokio::net::TcpListener::bind("0.0.0.0:5000").await.unwrap();
143+
println!("Server running on http://localhost:5000");
144+
println!("Try:");
145+
println!(" curl http://localhost:5000/users/alice/profile");
146+
println!(" curl http://localhost:5000/users/bob/posts");
147+
println!(" curl http://localhost:5000/users/unknown/profile");
148+
149+
let server = axum::serve(listener, app);
150+
151+
if let Err(err) = server.await {
152+
eprintln!("server error: {err}");
153+
}
154+
155+
// Gracefully shutdown the providers to ensure all spans and metrics are flushed.
156+
if let Err(err) = tracer_provider.shutdown() {
157+
eprintln!("tracer provider shutdown error: {err}");
158+
}
159+
if let Err(err) = meter_provider.shutdown() {
160+
eprintln!("meter provider shutdown error: {err}");
161+
}
162+
}

0 commit comments

Comments
 (0)