aws_runtime/user_agent/
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_runtime::client::sdk_feature::SmithySdkFeature;
7use once_cell::sync::Lazy;
8use std::borrow::Cow;
9use std::collections::HashMap;
10use std::fmt;
11
12const MAX_COMMA_SEPARATED_METRICS_VALUES_LENGTH: usize = 1024;
13#[allow(dead_code)]
14const MAX_METRICS_ID_NUMBER: usize = 350;
15
16macro_rules! iterable_enum {
17    ($docs:tt, $enum_name:ident, $( $variant:ident ),*) => {
18        #[derive(Clone, Debug, Eq, Hash, PartialEq)]
19        #[non_exhaustive]
20        #[doc = $docs]
21        #[allow(missing_docs)] // for variants, not for the Enum itself
22        pub enum $enum_name {
23            $( $variant ),*
24        }
25
26        #[allow(dead_code)]
27        impl $enum_name {
28            pub(crate) fn iter() -> impl Iterator<Item = &'static $enum_name> {
29                const VARIANTS: &[$enum_name] = &[
30                    $( $enum_name::$variant ),*
31                ];
32                VARIANTS.iter()
33            }
34        }
35    };
36}
37
38struct Base64Iterator {
39    current: Vec<usize>,
40    base64_chars: Vec<char>,
41}
42
43impl Base64Iterator {
44    #[allow(dead_code)]
45    fn new() -> Self {
46        Base64Iterator {
47            current: vec![0], // Start with the first character
48            base64_chars: (b'A'..=b'Z') // 'A'-'Z'
49                .chain(b'a'..=b'z') // 'a'-'z'
50                .chain(b'0'..=b'9') // '0'-'9'
51                .chain([b'+', b'-']) // '+' and '-'
52                .map(|c| c as char)
53                .collect(),
54        }
55    }
56
57    fn increment(&mut self) {
58        let mut i = 0;
59        while i < self.current.len() {
60            self.current[i] += 1;
61            if self.current[i] < self.base64_chars.len() {
62                // The value at current position hasn't reached 64
63                return;
64            }
65            self.current[i] = 0;
66            i += 1;
67        }
68        self.current.push(0); // Add new digit if all positions overflowed
69    }
70}
71
72impl Iterator for Base64Iterator {
73    type Item = String;
74
75    fn next(&mut self) -> Option<Self::Item> {
76        if self.current.is_empty() {
77            return None; // No more items
78        }
79
80        // Convert the current indices to characters
81        let result: String = self
82            .current
83            .iter()
84            .rev()
85            .map(|&idx| self.base64_chars[idx])
86            .collect();
87
88        // Increment to the next value
89        self.increment();
90        Some(result)
91    }
92}
93
94pub(super) static FEATURE_ID_TO_METRIC_VALUE: Lazy<HashMap<BusinessMetric, Cow<'static, str>>> =
95    Lazy::new(|| {
96        let mut m = HashMap::new();
97        for (metric, value) in BusinessMetric::iter()
98            .cloned()
99            .zip(Base64Iterator::new())
100            .take(MAX_METRICS_ID_NUMBER)
101        {
102            m.insert(metric, Cow::Owned(value));
103        }
104        m
105    });
106
107iterable_enum!(
108    "Enumerates human readable identifiers for the features tracked by metrics",
109    BusinessMetric,
110    ResourceModel,
111    Waiter,
112    Paginator,
113    RetryModeLegacy,
114    RetryModeStandard,
115    RetryModeAdaptive,
116    S3Transfer,
117    S3CryptoV1n,
118    S3CryptoV2,
119    S3ExpressBucket,
120    S3AccessGrants,
121    GzipRequestCompression,
122    ProtocolRpcV2Cbor,
123    EndpointOverride,
124    AccountIdEndpoint,
125    AccountIdModePreferred,
126    AccountIdModeDisabled,
127    AccountIdModeRequired,
128    Sigv4aSigning,
129    ResolvedAccountId
130);
131
132pub(crate) trait ProvideBusinessMetric {
133    fn provide_business_metric(&self) -> Option<BusinessMetric>;
134}
135
136impl ProvideBusinessMetric for SmithySdkFeature {
137    fn provide_business_metric(&self) -> Option<BusinessMetric> {
138        use SmithySdkFeature::*;
139        match self {
140            Waiter => Some(BusinessMetric::Waiter),
141            Paginator => Some(BusinessMetric::Paginator),
142            GzipRequestCompression => Some(BusinessMetric::GzipRequestCompression),
143            ProtocolRpcV2Cbor => Some(BusinessMetric::ProtocolRpcV2Cbor),
144            otherwise => {
145                // This may occur if a customer upgrades only the `aws-smithy-runtime-api` crate
146                // while continuing to use an outdated version of an SDK crate or the `aws-runtime`
147                // crate.
148                tracing::warn!(
149                    "Attempted to provide `BusinessMetric` for `{otherwise:?}`, which is not recognized in the current version of the `aws-runtime` crate. \
150                    Consider upgrading to the latest version to ensure that all tracked features are properly reported in your metrics."
151                );
152                None
153            }
154        }
155    }
156}
157
158#[derive(Clone, Debug, Default)]
159pub(super) struct BusinessMetrics(Vec<BusinessMetric>);
160
161impl BusinessMetrics {
162    pub(super) fn push(&mut self, metric: BusinessMetric) {
163        self.0.push(metric);
164    }
165
166    pub(super) fn is_empty(&self) -> bool {
167        self.0.is_empty()
168    }
169}
170
171fn drop_unfinished_metrics_to_fit(csv: &str, max_len: usize) -> Cow<'_, str> {
172    if csv.len() <= max_len {
173        Cow::Borrowed(csv)
174    } else {
175        let truncated = &csv[..max_len];
176        if let Some(pos) = truncated.rfind(',') {
177            Cow::Owned(truncated[..pos].to_owned())
178        } else {
179            Cow::Owned(truncated.to_owned())
180        }
181    }
182}
183
184impl fmt::Display for BusinessMetrics {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        // business-metrics = "m/" metric_id *(comma metric_id)
187        let metrics_values = self
188            .0
189            .iter()
190            .map(|feature_id| {
191                FEATURE_ID_TO_METRIC_VALUE
192                    .get(feature_id)
193                    .expect("{feature_id:?} should be found in `FEATURE_ID_TO_METRIC_VALUE`")
194                    .clone()
195            })
196            .collect::<Vec<_>>()
197            .join(",");
198
199        let metrics_values = drop_unfinished_metrics_to_fit(
200            &metrics_values,
201            MAX_COMMA_SEPARATED_METRICS_VALUES_LENGTH,
202        );
203
204        write!(f, "m/{}", metrics_values)
205    }
206}
207#[cfg(test)]
208mod tests {
209    use crate::user_agent::metrics::{
210        drop_unfinished_metrics_to_fit, Base64Iterator, FEATURE_ID_TO_METRIC_VALUE,
211        MAX_METRICS_ID_NUMBER,
212    };
213    use crate::user_agent::BusinessMetric;
214    use convert_case::{Boundary, Case, Casing};
215    use std::collections::HashMap;
216    use std::fmt::{Display, Formatter};
217
218    impl Display for BusinessMetric {
219        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
220            f.write_str(
221                &format!("{:?}", self)
222                    .as_str()
223                    .from_case(Case::Pascal)
224                    .with_boundaries(&[Boundary::DigitUpper, Boundary::LowerUpper])
225                    .to_case(Case::ScreamingSnake),
226            )
227        }
228    }
229
230    #[test]
231    fn feature_id_to_metric_value() {
232        const EXPECTED: &str = r#"
233{
234  "RESOURCE_MODEL": "A",
235  "WAITER": "B",
236  "PAGINATOR": "C",
237  "RETRY_MODE_LEGACY": "D",
238  "RETRY_MODE_STANDARD": "E",
239  "RETRY_MODE_ADAPTIVE": "F",
240  "S3_TRANSFER": "G",
241  "S3_CRYPTO_V1N": "H",
242  "S3_CRYPTO_V2": "I",
243  "S3_EXPRESS_BUCKET": "J",
244  "S3_ACCESS_GRANTS": "K",
245  "GZIP_REQUEST_COMPRESSION": "L",
246  "PROTOCOL_RPC_V2_CBOR": "M",
247  "ENDPOINT_OVERRIDE": "N",
248  "ACCOUNT_ID_ENDPOINT": "O",
249  "ACCOUNT_ID_MODE_PREFERRED": "P",
250  "ACCOUNT_ID_MODE_DISABLED": "Q",
251  "ACCOUNT_ID_MODE_REQUIRED": "R",
252  "SIGV4A_SIGNING": "S",
253  "RESOLVED_ACCOUNT_ID": "T"
254}
255        "#;
256
257        let expected: HashMap<&str, &str> = serde_json::from_str(EXPECTED).unwrap();
258        assert_eq!(expected.len(), FEATURE_ID_TO_METRIC_VALUE.len());
259
260        for (feature_id, metric_value) in &*FEATURE_ID_TO_METRIC_VALUE {
261            assert_eq!(
262                expected.get(format!("{feature_id}").as_str()).unwrap(),
263                metric_value,
264            );
265        }
266    }
267
268    #[test]
269    fn test_base64_iter() {
270        // 350 is the max number of metric IDs we support for now
271        let ids: Vec<String> = Base64Iterator::new()
272            .into_iter()
273            .take(MAX_METRICS_ID_NUMBER)
274            .collect();
275        assert_eq!("A", ids[0]);
276        assert_eq!("Z", ids[25]);
277        assert_eq!("a", ids[26]);
278        assert_eq!("z", ids[51]);
279        assert_eq!("0", ids[52]);
280        assert_eq!("9", ids[61]);
281        assert_eq!("+", ids[62]);
282        assert_eq!("-", ids[63]);
283        assert_eq!("AA", ids[64]);
284        assert_eq!("AB", ids[65]);
285        assert_eq!("A-", ids[127]);
286        assert_eq!("BA", ids[128]);
287        assert_eq!("Ed", ids[349]);
288    }
289
290    #[test]
291    fn test_drop_unfinished_metrics_to_fit() {
292        let csv = "A,10BC,E";
293        assert_eq!("A", drop_unfinished_metrics_to_fit(csv, 5));
294
295        let csv = "A10B,CE";
296        assert_eq!("A10B", drop_unfinished_metrics_to_fit(csv, 5));
297
298        let csv = "A10BC,E";
299        assert_eq!("A10BC", drop_unfinished_metrics_to_fit(csv, 5));
300
301        let csv = "A10BCE";
302        assert_eq!("A10BC", drop_unfinished_metrics_to_fit(csv, 5));
303
304        let csv = "A";
305        assert_eq!("A", drop_unfinished_metrics_to_fit(csv, 5));
306
307        let csv = "A,B";
308        assert_eq!("A,B", drop_unfinished_metrics_to_fit(csv, 5));
309    }
310}