Skip to main content

aws_smithy_runtime/client/
metrics.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use 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
21/// Sets the outcome attributes (`error.type` and `http.status_code`) on `attrs` from a
22/// finalizer-phase context.
23fn add_outcome_attrs(
24    attrs: &mut Attributes,
25    context: &aws_smithy_runtime_api::client::interceptors::context::FinalizerInterceptorContextRef<
26        '_,
27    >,
28) {
29    // Coarse category only; the error is type-erased here, so the modeled name isn't reachable.
30    // Absent on success, per OTel convention.
31    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    // Raw HTTP status code, whenever a response reached us.
47    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/// Struct to hold metric data in the ConfigBag
56#[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/// Instruments for recording a single operation
68#[derive(Debug, Clone)]
69pub(crate) struct OperationTelemetry {
70    pub(crate) operation_duration: Arc<dyn Histogram>,
71    pub(crate) attempt_duration: Arc<dyn Histogram>,
72    // Body sizes are their own instruments rather than attributes on the duration histogram: body
73    // size is near-unique per call, so attaching it as a label would fragment the duration metric
74    // into one time series per byte count.
75    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    // Holding a TimeSource here isn't ideal, but RuntimeComponents aren't available in
117    // the read_before_execution hook and that is when we need to start the timer for
118    // the operation.
119    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            // Merge captured input members that the customer opted in to *emit*. Capture-only
139            // members are present in the bag for in-process reads but are deliberately excluded
140            // from the metric label set.
141            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            // The outcome is only known at the finalizer, so it is set here rather than in
206            // `get_attrs_from_cfg` (which also serves the per-attempt path).
207            add_outcome_attrs(&mut attrs, context);
208
209            // Transferred byte sizes are recorded on their own instruments by the byte
210            // interceptor (see `telemetry_bytes`), not as attributes on the duration histogram.
211
212            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/// Runtime plugin that adds an interceptor for collecting metrics
264#[derive(Debug, Default)]
265pub struct MetricsRuntimePlugin {
266    scope: &'static str,
267    time_source: SharedTimeSource,
268    metadata: Option<Metadata>,
269}
270
271impl MetricsRuntimePlugin {
272    /// Create a [MetricsRuntimePluginBuilder]
273    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                    // Counts transferred bytes into the bag for the metrics interceptor to read.
289                    .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/// Builder for [MetricsRuntimePlugin]
317#[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    /// Set the scope for the metrics
326    pub fn with_scope(mut self, scope: &'static str) -> Self {
327        self.scope = Some(scope);
328        self
329    }
330
331    /// Set the [TimeSource] for the metrics
332    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    /// Set the [Metadata] for the metrics.
338    ///
339    /// Note: the Metadata is optional, most operations set it themselves, but this is useful
340    /// for operations that do not, like some of the credential providers.
341    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
342        self.metadata = Some(metadata);
343        self
344    }
345
346    /// Build a [MetricsRuntimePlugin]
347    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        // Nothing to key the metric on, so no attributes are produced.
399        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        // The emitted input member rides alongside the built-in rpc.* attributes.
419        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        // A value captured for in-process reads must not land on the metric.
426        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        // Opt-in is off by default: an empty capture set adds nothing.
450        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    // --- add_outcome_attrs (the `status` dimension) ---
463
464    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        // error.type is absent on success (OTel convention); status code is present.
492        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        // A connector error maps to the `connector` category.
507        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        // No response reached us, so there is no HTTP status code to record.
522        assert!(attrs.get("http.status_code").is_none());
523        assert_eq!(Some("connector"), string_attr(&attrs, "error.type"));
524    }
525}