Skip to main content

hydro_lang/telemetry/
emf.rs

1//! AWS CloudWatch embedded metric format (EMF).
2//!
3//! <https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html>
4#[cfg(feature = "runtime_support")]
5use std::marker::Unpin;
6#[cfg(feature = "runtime_support")]
7use std::panic::AssertUnwindSafe;
8use std::time::Duration;
9#[cfg(feature = "runtime_support")]
10use std::time::SystemTime;
11
12#[cfg(feature = "runtime_support")]
13use dfir_rs::Never;
14#[cfg(feature = "runtime_support")]
15use dfir_rs::scheduled::metrics::{DfirMetrics, DfirMetricsIntervals};
16#[cfg(feature = "runtime_support")]
17use futures::FutureExt;
18use quote::quote;
19#[cfg(feature = "runtime_support")]
20use serde_json::json;
21use syn::parse_quote;
22#[cfg(feature = "runtime_support")]
23use tokio::io::{AsyncWrite, AsyncWriteExt};
24#[cfg(feature = "runtime_support")]
25use tokio_metrics::RuntimeMetrics;
26
27use crate::location::{LocationKey, LocationType};
28use crate::staging_util::get_this_crate;
29use crate::telemetry::Sidecar;
30
31/// Default file path for [`RecordMetricsSidecar`].
32pub const DEFAULT_FILE_PATH: &str = "/var/log/hydro/metrics.log";
33/// Default interval for [`RecordMetricsSidecar`].
34pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(30);
35
36/// A sidecar which records metrics to a file via EMF.
37pub struct RecordMetricsSidecar {
38    file_path: String,
39    interval: Duration,
40}
41
42#[buildstructor::buildstructor]
43impl RecordMetricsSidecar {
44    /// Build an instance. Any `None` will be replaced with the default value.
45    #[builder]
46    pub fn new(file_path: Option<String>, interval: Option<Duration>) -> Self {
47        Self {
48            file_path: file_path.unwrap_or_else(|| DEFAULT_FILE_PATH.to_owned()),
49            interval: interval.unwrap_or(DEFAULT_INTERVAL),
50        }
51    }
52}
53
54impl Sidecar for RecordMetricsSidecar {
55    fn to_expr(
56        &self,
57        flow_name: &str,
58        _location_key: LocationKey,
59        _location_type: LocationType,
60        location_name: &str,
61        dfir_ident: &syn::Ident,
62    ) -> syn::Expr {
63        let Self {
64            file_path,
65            interval,
66        } = self;
67
68        let root = get_this_crate();
69        let namespace = flow_name.replace(char::is_whitespace, "_");
70        let interval: proc_macro2::TokenStream = {
71            let secs = interval.as_secs();
72            let nanos = interval.subsec_nanos();
73            quote!(::std::time::Duration::new(#secs, #nanos))
74        };
75
76        parse_quote! {
77            #root::telemetry::emf::record_metrics_sidecar(#dfir_ident.metrics_intervals(), #namespace, #location_name, #file_path, #interval)
78        }
79    }
80
81    /// Opts in to DFIR runtime metrics tracking, so that there are metrics to record.
82    #[cfg(feature = "build")]
83    fn edit_as_code_options(&self, options: &mut dfir_lang::graph::AsCodeOptions) {
84        options.include_metrics_tracking = true;
85    }
86}
87
88/// Record both Dfir and Tokio metrics, at the given interval, forever.
89#[cfg(feature = "runtime_support")]
90#[doc(hidden)]
91pub fn record_metrics_sidecar(
92    mut dfir_intervals: DfirMetricsIntervals,
93    namespace: &'static str,
94    location_name: &'static str,
95    file_path: &'static str,
96    interval: Duration,
97) -> impl 'static + Future<Output = Never> {
98    assert!(!namespace.contains(char::is_whitespace));
99
100    async move {
101        // Attempt to create log file parent dir.
102        if let Some(parent_dir) = std::path::Path::new(file_path).parent()
103            && let Err(e) = tokio::fs::create_dir_all(parent_dir).await
104        {
105            // TODO(minwgei): use `tracing` once deployments set up tracing logging (setup moved out of stdout)
106            eprintln!("Failed to create log file directory for EMF metrics: {}", e);
107        }
108
109        // Only attempt to get Tokio runtime within async to be safe.
110        let rt_monitor = tokio_metrics::RuntimeMonitor::new(&tokio::runtime::Handle::current());
111        let mut rt_intervals = rt_monitor.intervals();
112
113        loop {
114            let _ = tokio::time::sleep(interval).await;
115
116            let dfir_metrics = dfir_intervals.take_interval();
117            let rt_metrics = rt_intervals.next().unwrap();
118
119            let unwind_result = AssertUnwindSafe(async {
120                let timestamp = SystemTime::now();
121
122                let file = tokio::fs::OpenOptions::new()
123                    .write(true)
124                    .create(true)
125                    .truncate(false)
126                    .append(true)
127                    .open(file_path)
128                    .await
129                    .expect("Failed to open log file for EMF metrics.");
130                let mut writer = tokio::io::BufWriter::new(file);
131
132                record_metrics_dfir(
133                    namespace,
134                    location_name,
135                    timestamp,
136                    dfir_metrics,
137                    &mut writer,
138                )
139                .await
140                .unwrap();
141
142                record_metrics_tokio(namespace, location_name, timestamp, rt_metrics, &mut writer)
143                    .await
144                    .unwrap();
145
146                writer.shutdown().await.unwrap();
147            })
148            .catch_unwind()
149            .await;
150
151            if let Err(panic_reason) = unwind_result {
152                // TODO(minwgei): use `tracing` once deployments set up tracing logging (setup coordination moved out of stdout)
153                eprintln!("Panic in metrics sidecar: {panic_reason:?}");
154            }
155        }
156    }
157}
158
159#[cfg(feature = "runtime_support")]
160/// Records DFIR metrics.
161async fn record_metrics_dfir<W>(
162    namespace: &str,
163    location_name: &str,
164    timestamp: SystemTime,
165    metrics: DfirMetrics,
166    writer: &mut W,
167) -> Result<(), std::io::Error>
168where
169    W: AsyncWrite + Unpin,
170{
171    let ts_millis = timestamp
172        .duration_since(SystemTime::UNIX_EPOCH)
173        .unwrap()
174        .as_millis();
175
176    // Handoffs
177    for (hoff_id, hoff_metrics) in metrics.handoffs.iter() {
178        let emf = json!({
179            "_aws": {
180                "Timestamp": ts_millis,
181                "CloudWatchMetrics": [
182                    {
183                        "Namespace": namespace,
184                        "Dimensions": [["LocationName"], ["LocationName", "HandoffId"]],
185                        "Metrics": [
186                            {"Name": "CurrItemsCount", "Unit": Unit::Count},
187                            {"Name": "TotalItemsCount", "Unit": Unit::Count},
188                        ]
189                    }
190                ]
191            },
192            "LocationName": location_name,
193            "HandoffId": format!("{:?}", hoff_id),
194            "CurrItemsCount": hoff_metrics.curr_items_count(),
195            "TotalItemsCount": hoff_metrics.total_items_count(),
196        })
197        .to_string();
198        writer.write_all(emf.as_bytes()).await?;
199        writer.write_u8(b'\n').await?;
200    }
201
202    // Subgraphs
203    for (sg_id, sg_metrics) in metrics.subgraphs.iter() {
204        let emf = json!({
205            "_aws": {
206                "Timestamp": ts_millis,
207                "CloudWatchMetrics": [
208                    {
209                        "Namespace": namespace,
210                        "Dimensions": [["LocationName"], ["LocationName", "SubgraphId"]],
211                        "Metrics": [
212                            {"Name": "TotalRunCount", "Unit": Unit::Count},
213                            {"Name": "TotalPollDuration", "Unit": Unit::Microseconds},
214                            {"Name": "TotalPollCount", "Unit": Unit::Count},
215                            {"Name": "TotalIdleDuration", "Unit": Unit::Microseconds},
216                            {"Name": "TotalIdleCount", "Unit": Unit::Count},
217                        ]
218                    }
219                ]
220            },
221            "LocationName": location_name,
222            "SubgraphId": format!("{:?}", sg_id),
223            "TotalRunCount": sg_metrics.total_run_count(),
224            "TotalPollDuration": sg_metrics.total_poll_duration().as_micros(),
225            "TotalPollCount": sg_metrics.total_poll_count(),
226            "TotalIdleDuration": sg_metrics.total_idle_duration().as_micros(),
227            "TotalIdleCount": sg_metrics.total_idle_count(),
228        })
229        .to_string();
230        writer.write_all(emf.as_bytes()).await?;
231        writer.write_u8(b'\n').await?;
232    }
233
234    Ok(())
235}
236
237#[cfg(feature = "runtime_support")]
238/// Records tokio runtime metrics.
239async fn record_metrics_tokio<W>(
240    namespace: &str,
241    location_name: &str,
242    timestamp: SystemTime,
243    rt_metrics: RuntimeMetrics,
244    writer: &mut W,
245) -> Result<(), std::io::Error>
246where
247    W: AsyncWrite + Unpin,
248{
249    let ts_millis = timestamp
250        .duration_since(SystemTime::UNIX_EPOCH)
251        .unwrap()
252        .as_millis();
253
254    // Tokio RuntimeMetrics
255    let emf = json!({
256        "_aws": {
257            "Timestamp": ts_millis,
258            "CloudWatchMetrics": [
259                {
260                    "Namespace": namespace,
261                    "Dimensions": [["LocationName"]],
262                    "Metrics": [
263                        // {"Name": "LiveTasksCount", "Unit": Unit::Count}, // https://github.com/tokio-rs/tokio-metrics/pull/108
264                        {"Name": "TotalBusyDuration", "Unit": Unit::Microseconds},
265                        {"Name": "GlobalQueueDepth", "Unit": Unit::Count},
266                    ]
267                }
268            ]
269        },
270        "LocationName": location_name,
271        // "LiveTasksCount": rt_metrics.live_tasks_count, // https://github.com/tokio-rs/tokio-metrics/pull/108
272        "TotalBusyDuration": rt_metrics.total_busy_duration.as_micros(),
273        "GlobalQueueDepth": rt_metrics.global_queue_depth,
274        // The rest of the tokio runtime metrics are `cfg(tokio_unstable)`
275    })
276    .to_string();
277    writer.write_all(emf.as_bytes()).await?;
278    writer.write_u8(b'\n').await?;
279
280    Ok(())
281}
282
283/// AWS CloudWatch EMF units.
284///
285/// <https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html#ACW-Type-MetricDatum-Unit>
286#[expect(missing_docs, reason = "self-explanatory")]
287#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
288pub enum Unit {
289    /// None
290    #[default]
291    None,
292    Seconds,
293    Microseconds,
294    Milliseconds,
295    Bytes,
296    Kilobytes,
297    Megabytes,
298    Gigabytes,
299    Terabytes,
300    Bits,
301    Kilobits,
302    Megabits,
303    Gigabits,
304    Terabits,
305    Percent,
306    Count,
307    #[serde(rename = "Bytes/Second")]
308    BytesPerSecond,
309    #[serde(rename = "Kilobytes/Second")]
310    KilobytesPerSecond,
311    #[serde(rename = "Megabytes/Second")]
312    MegabytesPerSecond,
313    #[serde(rename = "Gigabytes/Second")]
314    GigabytesPerSecond,
315    #[serde(rename = "Terabytes/Second")]
316    TerabytesPerSecond,
317    #[serde(rename = "Bits/Second")]
318    BitsPerSecond,
319    #[serde(rename = "Kilobits/Second")]
320    KilobitsPerSecond,
321    #[serde(rename = "Megabits/Second")]
322    MegabitsPerSecond,
323    #[serde(rename = "Gigabits/Second")]
324    GigabitsPerSecond,
325    #[serde(rename = "Terabits/Second")]
326    TerabitsPerSecond,
327    #[serde(rename = "Count/Second")]
328    CountPerSecond,
329}