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 #[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#[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}