aws_smithy_runtime/client/
metrics.rs1use aws_smithy_async::time::{SharedTimeSource, TimeSource};
7use aws_smithy_observability::{
8 global::get_telemetry_provider, instruments::Histogram, AttributeValue, Attributes,
9 ObservabilityError,
10};
11use aws_smithy_runtime_api::client::{
12 interceptors::{dyn_dispatch_hint, Intercept, SharedInterceptor},
13 orchestrator::Metadata,
14 runtime_components::RuntimeComponentsBuilder,
15 runtime_plugin::RuntimePlugin,
16};
17use aws_smithy_types::config_bag::{FrozenLayer, Layer, Storable, StoreReplace};
18use aws_smithy_types::telemetry::{CapturedTelemetryAttributes, RequestedTelemetryAttributes};
19use std::{borrow::Cow, sync::Arc, time::SystemTime};
20
21fn add_outcome_attrs(
24 attrs: &mut Attributes,
25 context: &aws_smithy_runtime_api::client::interceptors::context::FinalizerInterceptorContextRef<
26 '_,
27 >,
28) {
29 if let Some(Err(err)) = context.output_or_error() {
32 let category = if err.is_timeout_error() {
33 "timeout"
34 } else if err.is_connector_error() {
35 "connector"
36 } else if err.is_response_error() {
37 "response"
38 } else if err.is_operation_error() {
39 "operation"
40 } else {
41 "other"
42 };
43 attrs.set("error.type", AttributeValue::String(category.into()));
44 }
45
46 if let Some(response) = context.response() {
48 attrs.set(
49 "http.status_code",
50 AttributeValue::I64(i64::from(response.status().as_u16())),
51 );
52 }
53}
54
55#[derive(Debug, Clone)]
57pub(crate) struct MeasurementsContainer {
58 call_start: SystemTime,
59 attempts: u32,
60 attempt_start: SystemTime,
61}
62
63impl Storable for MeasurementsContainer {
64 type Storer = StoreReplace<Self>;
65}
66
67#[derive(Debug, Clone)]
69pub(crate) struct OperationTelemetry {
70 pub(crate) operation_duration: Arc<dyn Histogram>,
71 pub(crate) attempt_duration: Arc<dyn Histogram>,
72 pub(crate) request_body_size: Arc<dyn Histogram>,
76 pub(crate) response_body_size: Arc<dyn Histogram>,
77}
78
79impl OperationTelemetry {
80 pub(crate) fn new(scope: &'static str) -> Result<Self, ObservabilityError> {
81 let meter = get_telemetry_provider()?
82 .meter_provider()
83 .get_meter(scope, None);
84
85 Ok(Self{
86 operation_duration: meter
87 .create_histogram("smithy.client.call.duration")
88 .set_units("s")
89 .set_description("Overall call duration (including retries and time to send or receive request and response body)")
90 .build(),
91 attempt_duration: meter
92 .create_histogram("smithy.client.call.attempt.duration")
93 .set_units("s")
94 .set_description("The time it takes to connect to the service, send the request, and get back HTTP status code and headers (including time queued waiting to be sent)")
95 .build(),
96 request_body_size: meter
97 .create_histogram("smithy.client.call.request.size")
98 .set_units("By")
99 .set_description("Size of the transferred request body, in bytes")
100 .build(),
101 response_body_size: meter
102 .create_histogram("smithy.client.call.response.size")
103 .set_units("By")
104 .set_description("Size of the transferred response body, in bytes")
105 .build(),
106 })
107 }
108}
109
110impl Storable for OperationTelemetry {
111 type Storer = StoreReplace<Self>;
112}
113
114#[derive(Debug)]
115pub(crate) struct MetricsInterceptor {
116 time_source: SharedTimeSource,
120}
121
122impl MetricsInterceptor {
123 pub(crate) fn new(time_source: SharedTimeSource) -> Result<Self, ObservabilityError> {
124 Ok(MetricsInterceptor { time_source })
125 }
126
127 pub(crate) fn get_attrs_from_cfg(
128 &self,
129 cfg: &aws_smithy_types::config_bag::ConfigBag,
130 ) -> Option<Attributes> {
131 let operation_metadata = cfg.load::<Metadata>();
132
133 if let Some(md) = operation_metadata {
134 let mut attributes = Attributes::new();
135 attributes.set("rpc.service", AttributeValue::String(md.service().into()));
136 attributes.set("rpc.method", AttributeValue::String(md.name().into()));
137
138 if let (Some(captured), Some(requested)) = (
142 cfg.load::<CapturedTelemetryAttributes>(),
143 cfg.load::<RequestedTelemetryAttributes>(),
144 ) {
145 for (name, value) in captured.iter() {
146 if requested.should_emit(name) {
147 attributes.set(name, AttributeValue::String(value.into()));
148 }
149 }
150 }
151
152 Some(attributes)
153 } else {
154 None
155 }
156 }
157
158 pub(crate) fn get_measurements_and_instruments<'a>(
159 &self,
160 cfg: &'a aws_smithy_types::config_bag::ConfigBag,
161 ) -> (&'a MeasurementsContainer, &'a OperationTelemetry) {
162 let measurements = cfg
163 .load::<MeasurementsContainer>()
164 .expect("set in `read_before_execution`");
165
166 let instruments = cfg
167 .load::<OperationTelemetry>()
168 .expect("set in RuntimePlugin");
169
170 (measurements, instruments)
171 }
172}
173
174#[dyn_dispatch_hint]
175impl Intercept for MetricsInterceptor {
176 fn name(&self) -> &'static str {
177 "MetricsInterceptor"
178 }
179
180 fn read_before_execution(
181 &self,
182 _context: &aws_smithy_runtime_api::client::interceptors::context::BeforeSerializationInterceptorContextRef<'_>,
183 cfg: &mut aws_smithy_types::config_bag::ConfigBag,
184 ) -> Result<(), aws_smithy_runtime_api::box_error::BoxError> {
185 cfg.interceptor_state().store_put(MeasurementsContainer {
186 call_start: self.time_source.now(),
187 attempts: 0,
188 attempt_start: SystemTime::UNIX_EPOCH,
189 });
190
191 Ok(())
192 }
193
194 fn read_after_execution(
195 &self,
196 context: &aws_smithy_runtime_api::client::interceptors::context::FinalizerInterceptorContextRef<'_>,
197 _runtime_components: &aws_smithy_runtime_api::client::runtime_components::RuntimeComponents,
198 cfg: &mut aws_smithy_types::config_bag::ConfigBag,
199 ) -> Result<(), aws_smithy_runtime_api::box_error::BoxError> {
200 let (measurements, instruments) = self.get_measurements_and_instruments(cfg);
201
202 let attributes = self.get_attrs_from_cfg(cfg);
203
204 if let Some(mut attrs) = attributes {
205 add_outcome_attrs(&mut attrs, context);
208
209 let call_end = self.time_source.now();
213 let call_duration = call_end.duration_since(measurements.call_start);
214 if let Ok(elapsed) = call_duration {
215 instruments
216 .operation_duration
217 .record(elapsed.as_secs_f64(), Some(&attrs), None);
218 }
219 }
220
221 Ok(())
222 }
223
224 fn read_before_attempt(
225 &self,
226 _context: &aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextRef<'_>,
227 _runtime_components: &aws_smithy_runtime_api::client::runtime_components::RuntimeComponents,
228 cfg: &mut aws_smithy_types::config_bag::ConfigBag,
229 ) -> Result<(), aws_smithy_runtime_api::box_error::BoxError> {
230 let measurements = cfg
231 .get_mut::<MeasurementsContainer>()
232 .expect("set in `read_before_execution`");
233
234 measurements.attempts += 1;
235 measurements.attempt_start = self.time_source.now();
236
237 Ok(())
238 }
239
240 fn read_after_attempt(
241 &self,
242 _context: &aws_smithy_runtime_api::client::interceptors::context::FinalizerInterceptorContextRef<'_>,
243 _runtime_components: &aws_smithy_runtime_api::client::runtime_components::RuntimeComponents,
244 cfg: &mut aws_smithy_types::config_bag::ConfigBag,
245 ) -> Result<(), aws_smithy_runtime_api::box_error::BoxError> {
246 let (measurements, instruments) = self.get_measurements_and_instruments(cfg);
247
248 let attempt_end = self.time_source.now();
249 let attempt_duration = attempt_end.duration_since(measurements.attempt_start);
250 let attributes = self.get_attrs_from_cfg(cfg);
251
252 if let (Ok(elapsed), Some(mut attrs)) = (attempt_duration, attributes) {
253 attrs.set("attempt", AttributeValue::I64(measurements.attempts.into()));
254
255 instruments
256 .attempt_duration
257 .record(elapsed.as_secs_f64(), Some(&attrs), None);
258 }
259 Ok(())
260 }
261}
262
263#[derive(Debug, Default)]
265pub struct MetricsRuntimePlugin {
266 scope: &'static str,
267 time_source: SharedTimeSource,
268 metadata: Option<Metadata>,
269}
270
271impl MetricsRuntimePlugin {
272 pub fn builder() -> MetricsRuntimePluginBuilder {
274 MetricsRuntimePluginBuilder::default()
275 }
276}
277
278impl RuntimePlugin for MetricsRuntimePlugin {
279 fn runtime_components(
280 &self,
281 _current_components: &RuntimeComponentsBuilder,
282 ) -> Cow<'_, RuntimeComponentsBuilder> {
283 let interceptor = MetricsInterceptor::new(self.time_source.clone());
284 if let Ok(interceptor) = interceptor {
285 Cow::Owned(
286 RuntimeComponentsBuilder::new("Metrics")
287 .with_interceptor(SharedInterceptor::permanent(interceptor))
288 .with_interceptor(SharedInterceptor::permanent(
290 crate::client::telemetry_bytes::TelemetryBytesInterceptor,
291 )),
292 )
293 } else {
294 Cow::Owned(RuntimeComponentsBuilder::new("Metrics"))
295 }
296 }
297
298 fn config(&self) -> Option<FrozenLayer> {
299 let instruments = OperationTelemetry::new(self.scope);
300
301 if let Ok(instruments) = instruments {
302 let mut cfg = Layer::new("Metrics");
303 cfg.store_put(instruments);
304
305 if let Some(metadata) = &self.metadata {
306 cfg.store_put(metadata.clone());
307 }
308
309 Some(cfg.freeze())
310 } else {
311 None
312 }
313 }
314}
315
316#[derive(Debug, Default)]
318pub struct MetricsRuntimePluginBuilder {
319 scope: Option<&'static str>,
320 time_source: Option<SharedTimeSource>,
321 metadata: Option<Metadata>,
322}
323
324impl MetricsRuntimePluginBuilder {
325 pub fn with_scope(mut self, scope: &'static str) -> Self {
327 self.scope = Some(scope);
328 self
329 }
330
331 pub fn with_time_source(mut self, time_source: impl TimeSource + 'static) -> Self {
333 self.time_source = Some(SharedTimeSource::new(time_source));
334 self
335 }
336
337 pub fn with_metadata(mut self, metadata: Metadata) -> Self {
342 self.metadata = Some(metadata);
343 self
344 }
345
346 pub fn build(
348 self,
349 ) -> Result<MetricsRuntimePlugin, aws_smithy_runtime_api::box_error::BoxError> {
350 if let Some(scope) = self.scope {
351 Ok(MetricsRuntimePlugin {
352 scope,
353 time_source: self.time_source.unwrap_or_default(),
354 metadata: self.metadata,
355 })
356 } else {
357 Err("Scope is required for MetricsRuntimePlugin.".into())
358 }
359 }
360}
361
362#[cfg(test)]
363mod test {
364 use super::*;
365 use aws_smithy_async::time::SystemTimeSource;
366 use aws_smithy_types::config_bag::ConfigBag;
367
368 fn interceptor() -> MetricsInterceptor {
369 MetricsInterceptor::new(SharedTimeSource::new(SystemTimeSource::new())).unwrap()
370 }
371
372 fn cfg_with(layer: Layer) -> ConfigBag {
373 ConfigBag::of_layers(vec![layer])
374 }
375
376 fn string_attr<'a>(attrs: &'a Attributes, key: &str) -> Option<&'a str> {
377 match attrs.get(key) {
378 Some(AttributeValue::String(s)) => Some(s.as_str()),
379 _ => None,
380 }
381 }
382
383 #[test]
384 fn base_attrs_are_service_and_method() {
385 let mut layer = Layer::new("test");
386 layer.store_put(Metadata::new("GetObject", "S3"));
387
388 let attrs = interceptor()
389 .get_attrs_from_cfg(&cfg_with(layer))
390 .expect("metadata present");
391
392 assert_eq!(Some("S3"), string_attr(&attrs, "rpc.service"));
393 assert_eq!(Some("GetObject"), string_attr(&attrs, "rpc.method"));
394 }
395
396 #[test]
397 fn no_attrs_without_metadata() {
398 assert!(interceptor()
400 .get_attrs_from_cfg(&cfg_with(Layer::new("test")))
401 .is_none());
402 }
403
404 #[test]
405 fn emitted_members_are_merged_onto_attrs() {
406 let mut captured = CapturedTelemetryAttributes::new();
407 captured.insert("Bucket", "my-bucket");
408
409 let mut layer = Layer::new("test");
410 layer.store_put(Metadata::new("GetObject", "S3"));
411 layer.store_put(captured);
412 layer.store_put(RequestedTelemetryAttributes::new(["Bucket"]));
413
414 let attrs = interceptor()
415 .get_attrs_from_cfg(&cfg_with(layer))
416 .expect("metadata present");
417
418 assert_eq!(Some("my-bucket"), string_attr(&attrs, "Bucket"));
420 assert_eq!(Some("S3"), string_attr(&attrs, "rpc.service"));
421 }
422
423 #[test]
424 fn capture_only_members_are_not_emitted() {
425 let mut captured = CapturedTelemetryAttributes::new();
427 captured.insert("Prefix", "logs/");
428
429 let mut requested = RequestedTelemetryAttributes::default();
430 requested.capture_only(["Prefix"]);
431
432 let mut layer = Layer::new("test");
433 layer.store_put(Metadata::new("GetObject", "S3"));
434 layer.store_put(captured);
435 layer.store_put(requested);
436
437 let attrs = interceptor()
438 .get_attrs_from_cfg(&cfg_with(layer))
439 .expect("metadata present");
440
441 assert!(
442 attrs.get("Prefix").is_none(),
443 "capture-only member must not be emitted on the metric"
444 );
445 }
446
447 #[test]
448 fn nothing_captured_leaves_only_base_attrs() {
449 let mut layer = Layer::new("test");
451 layer.store_put(Metadata::new("GetObject", "S3"));
452 layer.store_put(CapturedTelemetryAttributes::new());
453
454 let attrs = interceptor()
455 .get_attrs_from_cfg(&cfg_with(layer))
456 .expect("metadata present");
457
458 assert_eq!(Some("GetObject"), string_attr(&attrs, "rpc.method"));
459 assert!(attrs.get("Bucket").is_none());
460 }
461
462 use aws_smithy_runtime_api::client::interceptors::context::{
465 Error, Input, InterceptorContext, Output,
466 };
467 use aws_smithy_runtime_api::client::orchestrator::OrchestratorError;
468 use aws_smithy_runtime_api::client::result::ConnectorError;
469 use aws_smithy_runtime_api::http::{Response, StatusCode};
470 use aws_smithy_types::body::SdkBody;
471
472 fn i64_attr(attrs: &Attributes, key: &str) -> Option<i64> {
473 match attrs.get(key) {
474 Some(AttributeValue::I64(v)) => Some(*v),
475 _ => None,
476 }
477 }
478
479 #[test]
480 fn outcome_on_success_has_status_code_and_no_error_type() {
481 let mut ctx = InterceptorContext::new(Input::doesnt_matter());
482 ctx.set_output_or_error(Ok(Output::doesnt_matter()));
483 ctx.set_response(Response::new(
484 StatusCode::try_from(200).unwrap(),
485 SdkBody::empty(),
486 ));
487
488 let mut attrs = Attributes::new();
489 add_outcome_attrs(&mut attrs, &(&ctx).into());
490
491 assert!(attrs.get("error.type").is_none());
493 assert_eq!(Some(200), i64_attr(&attrs, "http.status_code"));
494 }
495
496 #[test]
497 fn outcome_on_failure_sets_error_type_category() {
498 let mut ctx = InterceptorContext::new(Input::doesnt_matter());
499 ctx.set_output_or_error(Err(OrchestratorError::connector(ConnectorError::io(
500 "boom".into(),
501 ))));
502
503 let mut attrs = Attributes::new();
504 add_outcome_attrs(&mut attrs, &(&ctx).into());
505
506 assert_eq!(Some("connector"), string_attr(&attrs, "error.type"));
508 }
509
510 #[test]
511 fn outcome_without_response_omits_status_code() {
512 let mut ctx: InterceptorContext<Input, Output, Error> =
513 InterceptorContext::new(Input::doesnt_matter());
514 ctx.set_output_or_error(Err(OrchestratorError::connector(ConnectorError::io(
515 "boom".into(),
516 ))));
517
518 let mut attrs = Attributes::new();
519 add_outcome_attrs(&mut attrs, &(&ctx).into());
520
521 assert!(attrs.get("http.status_code").is_none());
523 assert_eq!(Some("connector"), string_attr(&attrs, "error.type"));
524 }
525}