Skip to main content

o_sfu_telemetry/metrics/
descriptor.rs

1use std::fmt::Write as _;
2
3use o_sfu_model::WebSocketCloseCode;
4
5#[cfg(any(test, feature = "test-support"))]
6use super::snapshot::{RuntimeMetricsSnapshot, SnapshotWriter};
7use super::{
8    catalog::RuntimeMetrics,
9    counter::{
10        CounterFamily, ExportedMetricLabel, Histogram, HistogramBucketLabel, HistogramFamily,
11        MetricBucketLabel, UpDownCounterFamily,
12    },
13    labels::{
14        ControlPlaneDurationBucket, ExportedMetricLabelPair, HttpRoute, RtcRelayEnqueueResult,
15    },
16    rtc::RtcMetricsSnapshot,
17    rtp::{RtpMetricsSnapshot, RtpWorkerMetricsSnapshot},
18};
19
20#[cfg(test)]
21#[path = "TESTS/descriptor.rs"]
22mod tests;
23
24macro_rules! metric_catalog {
25    ($($id:ident {
26        name: $name:literal,
27        help: $help:literal,
28        kind: $kind:ident,
29        samples: |$metrics:ident, $capture:ident, $output:ident| $samples:expr
30    }),+ $(,)?) => {
31        /// Names every exported Prometheus metric family.
32        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33        pub enum MetricName {
34            $(
35                #[doc = concat!("`", $name, "`\n\n", $help)]
36                $id,
37            )+
38        }
39
40        #[cfg(test)]
41        pub(crate) const METRIC_FAMILY_COUNT: usize = [$(MetricName::$id),+].len();
42
43        const PROMETHEUS_METADATA_CAPACITY: usize = 0 $(
44            + "# HELP ".len() + $name.len() + 1 + $help.len() + 1
45            + "# TYPE ".len() + $name.len() + 1 + MetricKind::$kind.name().len() + 1
46        )+;
47
48        fn export(
49            metrics: &RuntimeMetrics,
50            room_gauges: RoomGaugeValues,
51            output: &mut MetricOutput,
52        ) {
53            let capture = MetricCapture {
54                room_gauges,
55                rtp: metrics.rtp_metrics.snapshot(),
56                rtc: metrics.rtc_metrics.snapshot(),
57            };
58            $(
59                output.begin_family(MetricDescriptor {
60                    #[cfg(any(test, feature = "test-support"))]
61                    id: MetricName::$id,
62                    name: $name,
63                    help: $help,
64                    kind: MetricKind::$kind,
65                });
66                {
67                    let $metrics = metrics;
68                    let $capture = &capture;
69                    let $output = &mut *output;
70                    let _ = ($metrics, $capture);
71                    $samples
72                }
73            )+
74        }
75    };
76}
77
78#[derive(Clone, Copy)]
79enum MetricKind {
80    Counter,
81    Gauge,
82    Histogram,
83}
84
85impl MetricKind {
86    const fn name(self) -> &'static str {
87        match self {
88            Self::Counter => "counter",
89            Self::Gauge => "gauge",
90            Self::Histogram => "histogram",
91        }
92    }
93}
94
95#[derive(Clone, Copy)]
96struct MetricDescriptor {
97    #[cfg(any(test, feature = "test-support"))]
98    id: MetricName,
99    name: &'static str,
100    help: &'static str,
101    kind: MetricKind,
102}
103
104struct MetricCapture {
105    room_gauges: RoomGaugeValues,
106    rtp: RtpMetricsSnapshot,
107    rtc: RtcMetricsSnapshot,
108}
109
110/// Room counts supplied to one export and saturated during gauge encoding.
111#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
112pub struct RoomGaugeValues {
113    pub rooms: usize,
114    pub users: usize,
115    pub publications: usize,
116    pub subscriptions: usize,
117    pub recording_rooms: usize,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub(super) enum MetricLabelValue {
122    Text(&'static str),
123    Number(usize),
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub(super) struct MetricLabel {
128    pub(super) name: &'static str,
129    pub(super) value: MetricLabelValue,
130}
131
132impl MetricLabel {
133    const fn text(name: &'static str, value: &'static str) -> Self {
134        Self {
135            name,
136            value: MetricLabelValue::Text(value),
137        }
138    }
139
140    const fn number(name: &'static str, value: usize) -> Self {
141        Self {
142            name,
143            value: MetricLabelValue::Number(value),
144        }
145    }
146}
147
148struct MetricOutput {
149    family: Option<MetricDescriptor>,
150    text: Option<String>,
151    #[cfg(any(test, feature = "test-support"))]
152    snapshot: Option<SnapshotWriter>,
153}
154
155impl MetricOutput {
156    fn prometheus() -> Self {
157        Self {
158            family: None,
159            text: Some(String::with_capacity(PROMETHEUS_METADATA_CAPACITY)),
160            #[cfg(any(test, feature = "test-support"))]
161            snapshot: None,
162        }
163    }
164
165    #[cfg(any(test, feature = "test-support"))]
166    fn snapshot() -> Self {
167        Self {
168            family: None,
169            text: None,
170            snapshot: Some(SnapshotWriter::default()),
171        }
172    }
173
174    fn begin_family(&mut self, descriptor: MetricDescriptor) {
175        self.family = Some(descriptor);
176        let Some(output) = &mut self.text else {
177            return;
178        };
179        output.push_str("# HELP ");
180        output.push_str(descriptor.name);
181        output.push(' ');
182        output.push_str(descriptor.help);
183        output.push('\n');
184        output.push_str("# TYPE ");
185        output.push_str(descriptor.name);
186        output.push(' ');
187        output.push_str(descriptor.kind.name());
188        output.push('\n');
189    }
190
191    fn counter(&mut self, labels: &[MetricLabel], value: u64) {
192        let Some(descriptor) = self.family else {
193            return;
194        };
195        if let Some(output) = &mut self.text {
196            append_sample_name(output, descriptor.name, labels);
197            let _ = writeln!(output, " {value}");
198        }
199        #[cfg(any(test, feature = "test-support"))]
200        if let Some(snapshot) = &mut self.snapshot {
201            snapshot.counter(descriptor.id, Box::from(labels), value);
202        }
203    }
204
205    fn gauge(&mut self, labels: &[MetricLabel], value: i64) {
206        let Some(descriptor) = self.family else {
207            return;
208        };
209        if let Some(output) = &mut self.text {
210            append_sample_name(output, descriptor.name, labels);
211            let _ = writeln!(output, " {value}");
212        }
213        #[cfg(any(test, feature = "test-support"))]
214        if let Some(snapshot) = &mut self.snapshot {
215            snapshot.gauge(descriptor.id, Box::from(labels), value);
216        }
217    }
218
219    fn histogram<B>(
220        &mut self,
221        labels: &[MetricLabel],
222        load_bucket: impl Fn(B) -> u64,
223        load_count: impl Fn() -> u64,
224        load_sum_micros: impl Fn() -> u64,
225    ) where
226        B: MetricBucketLabel,
227    {
228        let Some(descriptor) = self.family else {
229            return;
230        };
231        if let Some(output) = &mut self.text {
232            let mut floor = 0;
233            for bucket in B::VARIANTS {
234                let value = floor.max(load_bucket(*bucket));
235                floor = value;
236                output.push_str(descriptor.name);
237                output.push_str("_bucket");
238                append_labels(
239                    output,
240                    labels,
241                    Some(MetricLabel::text("le", bucket.upper_bound())),
242                );
243                let _ = writeln!(output, " {value}");
244            }
245            let count = floor.max(load_count());
246            let sum_micros = load_sum_micros();
247            output.push_str(descriptor.name);
248            output.push_str("_bucket");
249            append_labels(output, labels, Some(MetricLabel::text("le", "+Inf")));
250            let _ = writeln!(output, " {count}");
251            output.push_str(descriptor.name);
252            output.push_str("_sum");
253            append_labels(output, labels, None);
254            output.push(' ');
255            append_seconds_from_micros(output, sum_micros);
256            output.push('\n');
257            output.push_str(descriptor.name);
258            output.push_str("_count");
259            append_labels(output, labels, None);
260            let _ = writeln!(output, " {count}");
261        }
262        #[cfg(any(test, feature = "test-support"))]
263        if let Some(snapshot) = &mut self.snapshot {
264            let mut floor = 0;
265            let buckets = B::VARIANTS
266                .iter()
267                .map(|bucket| {
268                    let value = floor.max(load_bucket(*bucket));
269                    floor = value;
270                    (bucket.upper_bound(), value)
271                })
272                .collect();
273            snapshot.histogram(
274                descriptor.id,
275                Box::from(labels),
276                buckets,
277                floor.max(load_count()),
278                load_sum_micros(),
279            );
280        }
281    }
282
283    fn finish_prometheus(self) -> String {
284        self.text.unwrap_or_default()
285    }
286
287    #[cfg(any(test, feature = "test-support"))]
288    fn finish_snapshot(self) -> RuntimeMetricsSnapshot {
289        self.snapshot.unwrap_or_default().finish()
290    }
291}
292
293pub(crate) fn render_prometheus_text(metrics: &RuntimeMetrics, gauges: RoomGaugeValues) -> String {
294    let mut output = MetricOutput::prometheus();
295    export(metrics, gauges, &mut output);
296    output.finish_prometheus()
297}
298
299#[cfg(any(test, feature = "test-support"))]
300pub(super) fn build_snapshot(metrics: &RuntimeMetrics) -> RuntimeMetricsSnapshot {
301    let mut output = MetricOutput::snapshot();
302    export(metrics, RoomGaugeValues::default(), &mut output);
303    output.finish_snapshot()
304}
305
306fn gauge_count(value: usize) -> i64 {
307    i64::try_from(value).unwrap_or(i64::MAX)
308}
309
310metric_catalog! {
311    HttpNoopRequestsTotal {
312        name: "osfu_http_noop_requests_total",
313        help: "Total HTTP requests served by /v1/noop.",
314        kind: Counter,
315        samples: |metrics, capture, output| output.counter(&[], metrics.http_requests.load(HttpRoute::Noop))
316    },
317    HttpStatsRequestsTotal {
318        name: "osfu_http_stats_requests_total",
319        help: "Total HTTP requests served by /v1/stats.",
320        kind: Counter,
321        samples: |metrics, capture, output| output.counter(&[], metrics.http_requests.load(HttpRoute::Stats))
322    },
323    HttpRoomRequestsTotal {
324        name: "osfu_http_room_requests_total",
325        help: "Total HTTP requests received by /v1/channel.",
326        kind: Counter,
327        samples: |metrics, capture, output| output.counter(&[], metrics.http_requests.load(HttpRoute::Room))
328    },
329    HttpRoomResponsesTotal {
330        name: "osfu_http_room_responses_total",
331        help: "Total HTTP /v1/channel responses by status.",
332        kind: Counter,
333        samples: |metrics, capture, output| write_counter_family(output, &metrics.http_room_responses, "status")
334    },
335    HttpDisconnectRequestsTotal {
336        name: "osfu_http_disconnect_requests_total",
337        help: "Total HTTP requests received by /v1/disconnect.",
338        kind: Counter,
339        samples: |metrics, capture, output| output.counter(&[], metrics.http_requests.load(HttpRoute::Disconnect))
340    },
341    HttpDisconnectResponsesTotal {
342        name: "osfu_http_disconnect_responses_total",
343        help: "Total HTTP /v1/disconnect responses by status.",
344        kind: Counter,
345        samples: |metrics, capture, output| write_counter_family(output, &metrics.http_disconnect_responses, "status")
346    },
347    HttpMetricsRequestsTotal {
348        name: "osfu_http_metrics_requests_total",
349        help: "Total HTTP requests served by /metrics.",
350        kind: Counter,
351        samples: |metrics, capture, output| output.counter(&[], metrics.http_requests.load(HttpRoute::Metrics))
352    },
353    HttpInflightRequests {
354        name: "osfu_http_inflight_requests",
355        help: "Current in-flight HTTP requests by route.",
356        kind: Gauge,
357        samples: |metrics, capture, output| write_up_down_counter_family(output, &metrics.http_inflight_requests, "route")
358    },
359    HttpRequestDurationSeconds {
360        name: "osfu_http_request_duration_seconds",
361        help: "HTTP request duration by route.",
362        kind: Histogram,
363        samples: |metrics, capture, output| write_histogram_family(output, &metrics.http_request_duration, "route")
364    },
365    WsConnectionsTotal {
366        name: "osfu_ws_connections_total",
367        help: "Total websocket connections observed at each handshake stage.",
368        kind: Counter,
369        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_connections, "stage")
370    },
371    WsHandshakeRejectionsTotal {
372        name: "osfu_ws_handshake_rejections_total",
373        help: "Total websocket handshake rejections by close code bucket.",
374        kind: Counter,
375        samples: |metrics, capture, output| {
376            counter(output,
377                [("close_code", WebSocketCloseCode::AuthTimeout.label_value())],
378                metrics.ws_handshake_rejections.load(WebSocketCloseCode::AuthTimeout),
379            );
380            counter(output,
381                [("close_code", WebSocketCloseCode::AuthFailed.label_value())],
382                metrics.ws_handshake_rejections.load(WebSocketCloseCode::AuthFailed),
383            );
384            counter(output,
385                [("close_code", WebSocketCloseCode::ProtocolError.label_value())],
386                metrics.ws_handshake_rejections.load(WebSocketCloseCode::ProtocolError),
387            );
388            counter(output,
389                [("close_code", WebSocketCloseCode::RoomFull.label_value())],
390                metrics.ws_handshake_rejections.load(WebSocketCloseCode::RoomFull),
391            );
392            counter(output, [("close_code", "error")], metrics.ws_handshake_rejections_other.load());
393        }
394    },
395    WsStartupFailuresTotal {
396        name: "osfu_ws_startup_failures_total",
397        help: "Total websocket startup failures before the steady-state user loop.",
398        kind: Counter,
399        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_startup_failures, "kind")
400    },
401    WsHandshakeDurationSeconds {
402        name: "osfu_ws_handshake_duration_seconds",
403        help: "Websocket handshake duration from upgrade to user readiness or rejection.",
404        kind: Histogram,
405        samples: |metrics, capture, output| control_plane_histogram(output, &metrics.ws_handshake_duration)
406    },
407    WsAuthDurationSeconds {
408        name: "osfu_ws_auth_duration_seconds",
409        help: "Websocket authentication duration from first auth wait through token validation.",
410        kind: Histogram,
411        samples: |metrics, capture, output| control_plane_histogram(output, &metrics.ws_auth_duration)
412    },
413    WsUserInitializeDurationSeconds {
414        name: "osfu_ws_user_initialize_duration_seconds",
415        help: "Websocket user initialization duration after room admission.",
416        kind: Histogram,
417        samples: |metrics, capture, output| control_plane_histogram(output, &metrics.ws_user_initialize_duration)
418    },
419    WsUserLoopsStartedTotal {
420        name: "osfu_ws_user_loops_started_total",
421        help: "Total websocket user loops started after a successful join.",
422        kind: Counter,
423        samples: |metrics, capture, output| output.counter(&[], metrics.ws_user_loops_started.load())
424    },
425    WsUserLoopExitsTotal {
426        name: "osfu_ws_user_loop_exits_total",
427        help: "Total websocket user loop exits by reason.",
428        kind: Counter,
429        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_user_loop_exits, "reason")
430    },
431    WsBusBatchesTotal {
432        name: "osfu_ws_bus_batches_total",
433        help: "Total websocket signaling batches processed by direction.",
434        kind: Counter,
435        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_bus_batches, "direction")
436    },
437    WsBusEnvelopesTotal {
438        name: "osfu_ws_bus_envelopes_total",
439        help: "Total websocket signaling envelopes processed by direction.",
440        kind: Counter,
441        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_bus_envelopes, "direction")
442    },
443    WsBusParseFailuresTotal {
444        name: "osfu_ws_bus_parse_failures_total",
445        help: "Total websocket signaling parse failures.",
446        kind: Counter,
447        samples: |metrics, capture, output| output.counter(&[], metrics.ws_bus_parse_failures.load())
448    },
449    WsBusFailuresTotal {
450        name: "osfu_ws_bus_failures_total",
451        help: "Total websocket signaling failures by kind.",
452        kind: Counter,
453        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_bus_failures, "kind")
454    },
455    WsBusClientFramesTotal {
456        name: "osfu_ws_bus_client_frames_total",
457        help: "Total client websocket signaling frames by kind.",
458        kind: Counter,
459        samples: |metrics, capture, output| write_counter_family(output, &metrics.ws_bus_client_frames, "kind")
460    },
461    WsOutboundQueuedMessages {
462        name: "osfu_ws_outbound_queued_messages",
463        help: "Current websocket outbound room messages waiting in per-user queues.",
464        kind: Gauge,
465        samples: |metrics, capture, output| output.gauge(&[], metrics.ws_outbound_queued_messages.load())
466    },
467    WsOutboundQueueOverflowsTotal {
468        name: "osfu_ws_outbound_queue_overflows_total",
469        help: "Total websocket users marked for slow-consumer shutdown after outbound queue overflow.",
470        kind: Counter,
471        samples: |metrics, capture, output| output.counter(&[], metrics.ws_outbound_queue_overflows.load())
472    },
473    RoomsActive {
474        name: "osfu_rooms_active",
475        help: "Current number of active rooms owned by this runtime.",
476        kind: Gauge,
477        samples: |metrics, capture, output| output.gauge(&[], gauge_count(capture.room_gauges.rooms))
478    },
479    UsersActive {
480        name: "osfu_users_active",
481        help: "Current number of active room users owned by this runtime.",
482        kind: Gauge,
483        samples: |metrics, capture, output| output.gauge(&[], gauge_count(capture.room_gauges.users))
484    },
485    PublicationsActive {
486        name: "osfu_publications_active",
487        help: "Current number of committed or pending published media entries owned by this runtime.",
488        kind: Gauge,
489        samples: |metrics, capture, output| output.gauge(&[], gauge_count(capture.room_gauges.publications))
490    },
491    SubscriptionsActive {
492        name: "osfu_subscriptions_active",
493        help: "Current number of committed or pending consumer subscriptions owned by this runtime.",
494        kind: Gauge,
495        samples: |metrics, capture, output| output.gauge(&[], gauge_count(capture.room_gauges.subscriptions))
496    },
497    TransportUsersActive {
498        name: "osfu_transport_users_active",
499        help: "Current number of active RTC transport users on this runtime.",
500        kind: Gauge,
501        samples: |metrics, capture, output| output.gauge(&[], metrics.active_transport_users.load())
502    },
503    RecordingActionsTotal {
504        name: "osfu_recording_actions_total",
505        help: "Total recording control actions by action and outcome.",
506        kind: Counter,
507        samples: |metrics, capture, output| write_label_pair_counter_family(output, &metrics.recording_actions)
508    },
509    RecordingRoomsActive {
510        name: "osfu_recording_rooms_active",
511        help: "Current number of rooms with an active recording user.",
512        kind: Gauge,
513        samples: |metrics, capture, output| output.gauge(&[], gauge_count(capture.room_gauges.recording_rooms))
514    },
515    RecordingCapturedPacketsTotal {
516        name: "osfu_recording_captured_packets_total",
517        help: "Total packets accepted by the recording capture path.",
518        kind: Counter,
519        samples: |metrics, capture, output| output.counter(&[], metrics.recording_captured_packets.load())
520    },
521    RecordingCapturedStreamsTotal {
522        name: "osfu_recording_captured_streams_total",
523        help: "Total unique media streams first seen by the recording capture path.",
524        kind: Counter,
525        samples: |metrics, capture, output| output.counter(&[], metrics.recording_captured_streams.load())
526    },
527    TransportHealthUsers {
528        name: "osfu_transport_health_users",
529        help: "Current number of transport users by observed health state.",
530        kind: Gauge,
531        samples: |metrics, capture, output| write_up_down_counter_family(output, &metrics.transport_health_users, "state")
532    },
533    TransportHealthTransitionsTotal {
534        name: "osfu_transport_health_transitions_total",
535        help: "Total transport health-state transitions observed from the transport adapter.",
536        kind: Counter,
537        samples: |metrics, capture, output| write_label_pair_counter_family(output, &metrics.transport_health_transitions)
538    },
539    RtpPacketsTotal {
540        name: "osfu_rtp_packets_total",
541        help: "Total RTP packets processed by flow direction.",
542        kind: Counter,
543        samples: |metrics, capture, output| write_snapshot_counters(output,
544            &capture.rtp,
545            "direction",
546            RtpMetricsSnapshot::packets
547        )
548    },
549    RtpPayloadBytesTotal {
550        name: "osfu_rtp_payload_bytes_total",
551        help: "Total RTP payload bytes processed by flow direction.",
552        kind: Counter,
553        samples: |metrics, capture, output| write_snapshot_counters(output,
554            &capture.rtp,
555            "direction",
556            RtpMetricsSnapshot::payload_bytes
557        )
558    },
559    RtpForwardedPacketsTotal {
560        name: "osfu_rtp_forwarded_packets_total",
561        help: "Total RTP packet fan-out operations by forwarding destination.",
562        kind: Counter,
563        samples: |metrics, capture, output| write_snapshot_counters(output,
564            &capture.rtp,
565            "destination",
566            RtpMetricsSnapshot::forwarded_packets
567        )
568    },
569    RtpForwardedPayloadBytesTotal {
570        name: "osfu_rtp_forwarded_payload_bytes_total",
571        help: "Total RTP payload bytes fanned out by forwarding destination.",
572        kind: Counter,
573        samples: |metrics, capture, output| write_snapshot_counters(output,
574            &capture.rtp,
575            "destination",
576            RtpMetricsSnapshot::forwarded_payload_bytes
577        )
578    },
579    WorkerRtpPacketsTotal {
580        name: "osfu_worker_rtp_packets_total",
581        help: "Total RTP packets processed by media worker and flow direction.",
582        kind: Counter,
583        samples: |metrics, capture, output| write_rtp_worker_counters(output,
584            &capture.rtp,
585            "direction",
586            RtpWorkerMetricsSnapshot::packets
587        )
588    },
589    WorkerRtpPayloadBytesTotal {
590        name: "osfu_worker_rtp_payload_bytes_total",
591        help: "Total RTP payload bytes processed by media worker and flow direction.",
592        kind: Counter,
593        samples: |metrics, capture, output| write_rtp_worker_counters(output,
594            &capture.rtp,
595            "direction",
596            RtpWorkerMetricsSnapshot::payload_bytes
597        )
598    },
599    WorkerRtpForwardedPacketsTotal {
600        name: "osfu_worker_rtp_forwarded_packets_total",
601        help: "Total RTP packet fan-out operations by media worker and forwarding destination.",
602        kind: Counter,
603        samples: |metrics, capture, output| write_rtp_worker_counters(output,
604            &capture.rtp,
605            "destination",
606            RtpWorkerMetricsSnapshot::forwarded_packets
607        )
608    },
609    WorkerRtpForwardedPayloadBytesTotal {
610        name: "osfu_worker_rtp_forwarded_payload_bytes_total",
611        help: "Total RTP payload bytes fanned out by media worker and forwarding destination.",
612        kind: Counter,
613        samples: |metrics, capture, output| write_rtp_worker_counters(output,
614            &capture.rtp,
615            "destination",
616            RtpWorkerMetricsSnapshot::forwarded_payload_bytes
617        )
618    },
619    RtpRelayOverloadDropsTotal {
620        name: "osfu_rtp_relay_overload_drops_total",
621        help: "Total RTP relay packets dropped because the bounded relay mailbox was full.",
622        kind: Counter,
623        samples: |metrics, capture, output| write_counter_family(output, &metrics.rtp_relay_overload_drops, "destination")
624    },
625    RtpDecoderRefreshesTotal {
626        name: "osfu_rtp_decoder_refreshes_total",
627        help: "Total decoder-refresh RTP packets observed by source scope.",
628        kind: Counter,
629        samples: |metrics, capture, output| write_snapshot_counters(output,
630            &capture.rtp,
631            "scope",
632            RtpMetricsSnapshot::decoder_refreshes
633        )
634    },
635    TransportIceStateChangesTotal {
636        name: "osfu_transport_ice_state_changes_total",
637        help: "Total RTC ICE state-change events observed from the transport adapter.",
638        kind: Counter,
639        samples: |metrics, capture, output| write_counter_family(output, &metrics.transport_ice_state_changes, "state")
640    },
641    TransportDtlsConnectedTotal {
642        name: "osfu_transport_dtls_connected_total",
643        help: "Total RTC DTLS-connected events observed from the transport adapter.",
644        kind: Counter,
645        samples: |metrics, capture, output| output.counter(&[], metrics.transport_dtls_connected.load())
646    },
647    TransportUserLifetimeSeconds {
648        name: "osfu_transport_user_lifetime_seconds",
649        help: "Lifetime of closed RTC transport users observed at cold-path teardown.",
650        kind: Histogram,
651        samples: |metrics, capture, output| output.histogram(
652            &[],
653            |bucket| metrics.transport_user_lifetime_buckets.load(bucket),
654            || metrics.transport_user_lifetime_count.load(),
655            || metrics.transport_user_lifetime_sum_micros.load(),
656        )
657    },
658    MediaQualitySamplesTotal {
659        name: "osfu_media_quality_samples_total",
660        help: "Total sampled transport-quality events by str0m stats source.",
661        kind: Counter,
662        samples: |metrics, capture, output| write_counter_family(output, &metrics.media_quality_samples, "sample")
663    },
664    MediaQualityRttSeconds {
665        name: "osfu_media_quality_rtt_seconds",
666        help: "Sampled RTC round-trip time by str0m stats source.",
667        kind: Histogram,
668        samples: |metrics, capture, output| write_histogram_family(output, &metrics.media_quality_rtt, "sample")
669    },
670    MediaQualityLossPpmObservedTotal {
671        name: "osfu_media_quality_loss_ppm_observed_total",
672        help: "Sum of sampled packet loss observations in parts per million by direction.",
673        kind: Counter,
674        samples: |metrics, capture, output| write_counter_family(output, &metrics.media_quality_loss_ppm_observed, "direction")
675    },
676    MediaQualityLossObservationsTotal {
677        name: "osfu_media_quality_loss_observations_total",
678        help: "Total packet loss observations by direction.",
679        kind: Counter,
680        samples: |metrics, capture, output| write_counter_family(output, &metrics.media_quality_loss_observations, "direction")
681    },
682    MediaQualityBweBpsObservedTotal {
683        name: "osfu_media_quality_bwe_bps_observed_total",
684        help: "Sum of sampled peer egress bandwidth estimates in bits per second.",
685        kind: Counter,
686        samples: |metrics, capture, output| output.counter(&[], metrics.media_quality_bwe_bps_observed.load())
687    },
688    MediaQualityBweObservationsTotal {
689        name: "osfu_media_quality_bwe_observations_total",
690        help: "Total peer egress bandwidth estimate observations.",
691        kind: Counter,
692        samples: |metrics, capture, output| output.counter(&[], metrics.media_quality_bwe_observations.load())
693    },
694    MediaQualityJitterRtpTimestampUnitsObservedTotal {
695        name: "osfu_media_quality_jitter_rtp_timestamp_units_observed_total",
696        help: "Sum of sampled remote egress jitter observations in RTP timestamp units.",
697        kind: Counter,
698        samples: |metrics, capture, output| output.counter(
699            &[],
700            metrics.media_quality_jitter_rtp_timestamp_units_observed.load(),
701        )
702    },
703    MediaQualityJitterObservationsTotal {
704        name: "osfu_media_quality_jitter_observations_total",
705        help: "Total remote egress jitter observations.",
706        kind: Counter,
707        samples: |metrics, capture, output| output.counter(&[], metrics.media_quality_jitter_observations.load())
708    },
709    TransportCleanupFailuresTotal {
710        name: "osfu_transport_cleanup_failures_total",
711        help: "Total terminal transport cleanup failures.",
712        kind: Counter,
713        samples: |metrics, capture, output| counter(output,
714            [("kind", "terminal")],
715            metrics.transport_cleanup_failures.load()
716        )
717    },
718    RtcDatagramRoutesTotal {
719        name: "osfu_rtc_datagram_routes_total",
720        help: "Total RTC UDP datagrams accepted by routing path.",
721        kind: Counter,
722        samples: |metrics, capture, output| write_snapshot_counters(output,
723            &capture.rtc,
724            "path",
725            RtcMetricsSnapshot::datagram_routes
726        )
727    },
728    RtcDatagramDropsTotal {
729        name: "osfu_rtc_datagram_drops_total",
730        help: "Total RTC UDP datagrams dropped by ingress routing before session delivery.",
731        kind: Counter,
732        samples: |metrics, capture, output| write_snapshot_counters(output,
733            &capture.rtc,
734            "reason",
735            RtcMetricsSnapshot::datagram_drops
736        )
737    },
738    RtcDatagramFallbackScansTotal {
739        name: "osfu_rtc_datagram_fallback_scans_total",
740        help: "Total fallback scans across RTC users for UDP datagram routing.",
741        kind: Counter,
742        samples: |metrics, capture, output| output.counter(&[], capture.rtc.datagram_fallback_scans())
743    },
744    RtcDatagramScanUsersTotal {
745        name: "osfu_rtc_datagram_scan_users_total",
746        help: "Total RTC users examined by UDP fallback scans.",
747        kind: Counter,
748        samples: |metrics, capture, output| output.counter(&[], capture.rtc.datagram_scan_users())
749    },
750    RtcNacksTotal {
751        name: "osfu_rtc_nacks_total",
752        help: "Total Generic NACK feedback events by WebRTC direction.",
753        kind: Counter,
754        samples: |metrics, capture, output| write_snapshot_counters(output,
755            &capture.rtc,
756            "direction",
757            RtcMetricsSnapshot::nacks
758        )
759    },
760    RtcRtxPacketsTotal {
761        name: "osfu_rtc_rtx_packets_total",
762        help: "Total authenticated RTX packets accepted from publishers.",
763        kind: Counter,
764        samples: |metrics, capture, output| counter(output,
765            [("direction", "received_from_publisher")],
766            capture.rtc.rtx_packets_received_from_publisher()
767        )
768    },
769    RtcRtxPayloadBytesTotal {
770        name: "osfu_rtc_rtx_payload_bytes_total",
771        help: "Total de-RTX media payload bytes accepted from publishers.",
772        kind: Counter,
773        samples: |metrics, capture, output| counter(output,
774            [("direction", "received_from_publisher")],
775            capture.rtc.rtx_payload_bytes_received_from_publisher()
776        )
777    },
778    RtcRtcpIngressBudgetDropsTotal {
779        name: "osfu_rtc_rtcp_ingress_budget_drops_total",
780        help: "Total candidate RTCP datagrams dropped by the per-session ingress byte budget.",
781        kind: Counter,
782        samples: |metrics, capture, output| output.counter(&[], capture.rtc.rtcp_ingress_budget_drops())
783    },
784    RtcOutputBudgetExhaustionsTotal {
785        name: "osfu_rtc_output_budget_exhaustions_total",
786        help: "Total RTC session drains that exhausted the output budget by limit.",
787        kind: Counter,
788        samples: |metrics, capture, output| write_snapshot_counters(output,
789            &capture.rtc,
790            "limit",
791            RtcMetricsSnapshot::output_budget_exhaustions
792        )
793    },
794    RtcOutputBudgetSessionClosesTotal {
795        name: "osfu_rtc_output_budget_session_closes_total",
796        help: "Total RTC sessions closed after output-budget exhaustion.",
797        kind: Counter,
798        samples: |metrics, capture, output| output.counter(&[], capture.rtc.output_budget_session_closes())
799    },
800    RtcRouteControlTotal {
801        name: "osfu_rtc_route_control_total",
802        help: "Total RTC route-control decisions observed at the transport boundary.",
803        kind: Counter,
804        samples: |metrics, capture, output| write_snapshot_counters(output,
805            &capture.rtc,
806            "outcome",
807            RtcMetricsSnapshot::route_control
808        )
809    },
810    RtcKeyframeRequestsTotal {
811        name: "osfu_rtc_keyframe_requests_total",
812        help: "Total RTC keyframe request tracker outcomes.",
813        kind: Counter,
814        samples: |metrics, capture, output| write_snapshot_counters(output,
815            &capture.rtc,
816            "outcome",
817            RtcMetricsSnapshot::keyframe_requests
818        )
819    },
820    RtcRelayEnqueuesTotal {
821        name: "osfu_rtc_relay_enqueues_total",
822        help: "Total relay enqueue attempts by target kind and outcome.",
823        kind: Counter,
824        samples: |metrics, capture, output| write_label_pair_counters(output, |result: RtcRelayEnqueueResult| {
825            capture.rtc.relay_enqueues(result)
826        })
827    },
828    RtcRelayMailboxDepthSamplesTotal {
829        name: "osfu_rtc_relay_mailbox_depth_samples_total",
830        help: "Total sampled intra-node relay mailbox depth observations.",
831        kind: Counter,
832        samples: |metrics, capture, output| output.counter(&[], capture.rtc.relay_mailbox_depth_samples())
833    },
834    RtcRelayMailboxDepthObservedTotal {
835        name: "osfu_rtc_relay_mailbox_depth_observed_total",
836        help: "Sum of sampled intra-node relay mailbox depths.",
837        kind: Counter,
838        samples: |metrics, capture, output| output.counter(&[], capture.rtc.relay_mailbox_depth_total())
839    },
840    RtcRelayDrainBatchesTotal {
841        name: "osfu_rtc_relay_drain_batches_total",
842        help: "Total non-empty packet-loop relay drain batches.",
843        kind: Counter,
844        samples: |metrics, capture, output| output.counter(&[], capture.rtc.relay_drain_batches())
845    },
846    RtcRelayDrainedPacketsTotal {
847        name: "osfu_rtc_relay_drained_packets_total",
848        help: "Total relay packets drained into packet-loop batches.",
849        kind: Counter,
850        samples: |metrics, capture, output| output.counter(&[], capture.rtc.relay_drained_packets())
851    },
852    RtcRelayDrainCapHitsTotal {
853        name: "osfu_rtc_relay_drain_cap_hits_total",
854        help: "Total relay drain batches that left queued relay packets behind after hitting the per-turn cap.",
855        kind: Counter,
856        samples: |metrics, capture, output| output.counter(&[], capture.rtc.relay_drain_cap_hits())
857    },
858    RtcRemoteControlDropsTotal {
859        name: "osfu_rtc_remote_control_drops_total",
860        help: "Total remote-source control commands dropped before enqueue by command kind.",
861        kind: Counter,
862        samples: |metrics, capture, output| write_snapshot_counters(output,
863            &capture.rtc,
864            "kind",
865            RtcMetricsSnapshot::remote_control_drops
866        )
867    },
868    RtcRemotePacketGateConvergenceTotal {
869        name: "osfu_rtc_remote_packet_gate_convergence_total",
870        help: "Total remote packet-gate convergence retry attempts and successful pending flushes.",
871        kind: Counter,
872        samples: |metrics, capture, output| write_snapshot_counters(output,
873            &capture.rtc,
874            "outcome",
875            RtcMetricsSnapshot::remote_packet_gate_convergence
876        )
877    },
878    SourceSelectionUpdatesTotal {
879        name: "osfu_source_selection_updates_total",
880        help: "Total room-scoped source selector updates accepted by source policy.",
881        kind: Counter,
882        samples: |metrics, capture, output| write_counter_family(output, &metrics.source_selection_updates, "selector")
883    },
884    BudgetSolverOutcomesTotal {
885        name: "osfu_budget_solver_outcomes_total",
886        help: "Total receiver video budget solver outcomes accepted by room policy.",
887        kind: Counter,
888        samples: |metrics, capture, output| write_counter_family(output, &metrics.budget_solver_outcomes, "outcome")
889    },
890}
891
892fn write_counter_family<L>(
893    output: &mut MetricOutput,
894    family: &CounterFamily<L>,
895    label_name: &'static str,
896) where
897    L: ExportedMetricLabel,
898{
899    for label in L::VARIANTS {
900        counter(
901            output,
902            [(label_name, label.label_value())],
903            family.load(*label),
904        );
905    }
906}
907
908fn write_label_pair_counter_family<L>(output: &mut MetricOutput, family: &CounterFamily<L>)
909where
910    L: ExportedMetricLabelPair,
911{
912    write_label_pair_counters(output, |label| family.load(label));
913}
914
915fn write_label_pair_counters<L>(output: &mut MetricOutput, load: impl Fn(L) -> u64)
916where
917    L: ExportedMetricLabelPair,
918{
919    for label in L::VARIANTS {
920        counter(output, label.label_pair(), load(*label));
921    }
922}
923
924fn write_snapshot_counters<S, L>(
925    output: &mut MetricOutput,
926    snapshot: &S,
927    label_name: &'static str,
928    read: fn(&S, L) -> u64,
929) where
930    L: ExportedMetricLabel,
931{
932    for label in L::VARIANTS {
933        counter(
934            output,
935            [(label_name, label.label_value())],
936            read(snapshot, *label),
937        );
938    }
939}
940
941fn write_rtp_worker_counters<L>(
942    output: &mut MetricOutput,
943    snapshot: &RtpMetricsSnapshot,
944    label_name: &'static str,
945    read: fn(&RtpWorkerMetricsSnapshot, L) -> u64,
946) where
947    L: ExportedMetricLabel,
948{
949    for worker in snapshot.worker_snapshots() {
950        for label in L::VARIANTS {
951            output.counter(
952                &[
953                    MetricLabel::number("media_worker_id", worker.media_worker_id()),
954                    MetricLabel::text(label_name, label.label_value()),
955                ],
956                read(worker, *label),
957            );
958        }
959    }
960}
961
962fn write_up_down_counter_family<L>(
963    output: &mut MetricOutput,
964    family: &UpDownCounterFamily<L>,
965    label_name: &'static str,
966) where
967    L: ExportedMetricLabel,
968{
969    for label in L::VARIANTS {
970        output.gauge(
971            &[MetricLabel::text(label_name, label.label_value())],
972            family.load(*label),
973        );
974    }
975}
976
977fn write_histogram_family<L, B>(
978    output: &mut MetricOutput,
979    family: &HistogramFamily<L, B>,
980    label_name: &'static str,
981) where
982    L: ExportedMetricLabel,
983    B: HistogramBucketLabel,
984{
985    for label in L::VARIANTS {
986        output.histogram(
987            &[MetricLabel::text(label_name, label.label_value())],
988            |bucket| family.load_bucket(*label, bucket),
989            || family.load_count(*label),
990            || family.load_sum_micros(*label),
991        );
992    }
993}
994
995fn counter<const N: usize>(
996    output: &mut MetricOutput,
997    labels: [(&'static str, &'static str); N],
998    value: u64,
999) {
1000    output.counter(
1001        &labels.map(|(name, value)| MetricLabel::text(name, value)),
1002        value,
1003    );
1004}
1005
1006fn control_plane_histogram(
1007    output: &mut MetricOutput,
1008    histogram: &Histogram<ControlPlaneDurationBucket>,
1009) {
1010    output.histogram(
1011        &[],
1012        |bucket| histogram.load_bucket(bucket),
1013        || histogram.load_count(),
1014        || histogram.load_sum_micros(),
1015    );
1016}
1017
1018fn append_sample_name(output: &mut String, name: &str, labels: &[MetricLabel]) {
1019    output.push_str(name);
1020    append_labels(output, labels, None);
1021}
1022
1023fn append_labels(output: &mut String, labels: &[MetricLabel], extra_label: Option<MetricLabel>) {
1024    if labels.is_empty() && extra_label.is_none() {
1025        return;
1026    }
1027    output.push('{');
1028    for (index, label) in labels.iter().chain(extra_label.iter()).enumerate() {
1029        if index != 0 {
1030            output.push(',');
1031        }
1032        output.push_str(label.name);
1033        output.push_str("=\"");
1034        match label.value {
1035            MetricLabelValue::Text(value) => output.push_str(value),
1036            MetricLabelValue::Number(value) => {
1037                let _ = write!(output, "{value}");
1038            }
1039        }
1040        output.push('"');
1041    }
1042    output.push('}');
1043}
1044
1045fn append_seconds_from_micros(output: &mut String, micros: u64) {
1046    let whole_seconds = micros / 1_000_000;
1047    let fractional_micros = micros % 1_000_000;
1048    let _ = write!(output, "{whole_seconds}");
1049    if fractional_micros == 0 {
1050        output.push_str(".0");
1051        return;
1052    }
1053    let _ = write!(output, ".{fractional_micros:06}");
1054    while output.ends_with('0') {
1055        output.pop();
1056    }
1057}