refactor(metrics): centralize registry and lifecycle tracking

pull/446/head
zijiren233 4 weeks ago
parent 7fb7f6c64f
commit 537b946e36
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -14,6 +14,8 @@ metrics:
生产环境可以开启但不要直接暴露公网。Linux 构建会把进程级指标和业务指标放在同一个 registry 中暴露。
metrics listener 启动时会校验并注册全部指标定义。重复或无效定义会中止启动,避免端点静默暴露不完整的 registry。抓取时发生编码错误会返回 HTTP `500`;应在 Prometheus 中配置抓取失败告警。
## 常用示例
Bearer token

@ -18,6 +18,8 @@ Production deployments should enable metrics but avoid exposing them publicly.
Linux builds also register process-level metrics in the same Prometheus registry, including CPU, memory, file descriptors, and process start time. They are exposed through `/metrics` together with application metrics and need no extra configuration.
All metric definitions are validated and registered when the metrics listener starts. A duplicate or invalid definition stops startup so the endpoint cannot silently expose a partial registry. A scrape-time encoding failure returns HTTP `500`; configure scrape-failure alerts in Prometheus.
## Common Examples
Bearer token:

@ -19,6 +19,13 @@ curl -fsS \
Disabled or unused features may not emit their metrics. Do not expose the metrics listener directly to the public internet.
</Aside>
## Instrumentation Contract
- SyncTV registers every metric definition when the metrics listener starts. Invalid or duplicate definitions stop startup instead of producing a partial registry.
- Labels use bounded values. HTTP `path` is an Axum route template such as `/api/rooms/{room_id}`; resource IDs, query strings, and raw error messages are excluded.
- Existing metric names and label sets are compatibility-sensitive. Review dashboards and alerts before changing them.
- A vector metric may remain absent until its feature records the first labeled sample.
## HTTP And WebSocket
| Metric | Type | Labels | Meaning |
@ -38,11 +45,28 @@ Disabled or unused features may not emit their metrics. Do not expose the metric
| --- | --- | --- | --- |
| `db_connections_active` | gauge | none | Active DB connections |
| `db_connections_idle` | gauge | none | Idle DB connections |
| `db_pool_size_max` | gauge | none | Configured maximum DB pool size across pools |
| `db_pool_utilization_ratio` | gauge | `pool` | Pool utilization, from 0 to 1 |
| `cache_hits_total` | counter | `cache_type`, `level` | Cache hits |
| `cache_misses_total` | counter | `cache_type`, `level` | Cache misses |
| `cache_evictions_total` | counter | `cache_type` | Cache evictions |
| `cache_errors_total` | counter | `cache_type`, `operation` | Cache operation errors |
| `cache_invalidations_total` | counter | `cache_type` | Cache invalidations |
| `cache_operation_duration_seconds` | histogram | `operation` | Cache operation duration |
| `cache_lag_flush_total` | counter | `component` | Full L1 flushes after invalidation-channel lag |
| `cache_fence_operations_total` | counter | `domain`, `operation`, `result` | Version-fence operations |
| `cache_db_fallback_total` | counter | `domain`, `reason` | Strong reads that fell back to PostgreSQL |
| `cache_stale_write_reject_total` | counter | `cache_type`, `level` | Rejected stale cache writes |
| `cache_fence_pending` | gauge | `domain` | Domains with a pending version fence |
| `cache_fence_repair_total` | counter | `domain`, `result` | Read-time fence repair outcomes |
| `cache_fence_db_compare` | gauge | `domain`, `relation` | Latest DB-to-fence patrol comparison |
## Remote Transport
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `grpc_requests_total` | counter | `service`, `method`, `status` | Completed gRPC requests |
| `grpc_request_duration_seconds` | histogram | `service`, `method`, `status` | gRPC request duration |
## Business And Rate Limits
@ -56,6 +80,7 @@ Disabled or unused features may not emit their metrics. Do not expose the metric
| `webrtc_peers_active` | gauge | none | Active WebRTC peers |
| `active_connections` | gauge | none | Active connections |
| `spawned_task_panics_total` | counter | `task_name` | Background task panics caught by `spawn_monitored` |
| `logging_dropped_lines_total` | counter | `component` | Log lines dropped by full non-blocking queues |
| `email_delivery_queue_depth` | gauge | none | Email jobs awaiting delivery in the PostgreSQL outbox |
| `email_delivery_in_flight` | gauge | none | Email jobs being delivered by this instance |
| `email_delivery_jobs_total` | counter | `kind`, `status` | Email job outcomes: `sent`, `retry`, `dead`, `superseded`, `fenced`, `persist_failed`, or `ack_failed` |
@ -76,6 +101,10 @@ Disabled or unused features may not emit their metrics. Do not expose the metric
| `synctv_cluster_leader_election_epoch` | gauge | none | Current leader epoch |
| `synctv_cluster_leader_election_consecutive_failures` | gauge | none | Consecutive leader election failures |
| `synctv_cluster_epoch_mismatch_quarantine` | gauge | none | Epoch mismatch quarantine state |
| `synctv_cluster_leader_election_mode` | gauge | none | Election mode: 0 standalone, 1 Redis, 2 Kubernetes Lease |
| `synctv_cluster_distributed_counter_ttl_refreshes_total` | counter | `result` | Distributed counter TTL refresh outcomes |
| `synctv_cluster_distributed_counter_ttl_keys_refreshed` | gauge | none | Keys refreshed in the latest TTL cycle |
| `synctv_cluster_distributed_counter_ttl_consecutive_failures` | gauge | none | Consecutive TTL refresh failures |
## Media And Livestream
@ -85,6 +114,8 @@ Disabled or unused features may not emit their metrics. Do not expose the metric
| `active_relay_streams` | gauge | none | Active relay streams |
| `stream_errors_total` | counter | `stream_type`, `error_type` | Stream errors |
| `streamhub_restarts_total` | counter | `reason` | StreamHub event loop restarts |
| `streams_active` | gauge | none | Active tracked streams |
| `synctv_publisher_heartbeat_failures_total` | counter | none | Publisher cleanups after heartbeat failure |
| `livestream_active_publishers` | gauge | none | Active livestream publishers |
| `livestream_active_viewers` | gauge | none | Active livestream viewers |
| `livestream_relay_frame_drops_total` | counter | none | Relay frame drops caused by backpressure |

@ -19,6 +19,13 @@ curl -fsS \
没有启用的功能可能不会产生对应指标。不要把 metrics listener 直接暴露公网。
</Aside>
## 埋点契约
- metrics listener 启动时会注册全部指标定义。无效或重复定义会中止启动,避免暴露不完整的 registry。
- Label 只能使用有限集合。HTTP `path` 使用 Axum 路由模板,例如 `/api/rooms/{room_id}`;不得包含资源 ID、query string 或原始错误消息。
- 现有指标名和 label 集合属于兼容接口。修改前必须检查 dashboard 和 alert。
- vector 指标可能在对应功能首次记录带 label 的样本前保持缺失。
## HTTP 和 WebSocket
| 指标 | 类型 | Labels | 含义 |
@ -38,11 +45,28 @@ curl -fsS \
| --- | --- | --- | --- |
| `db_connections_active` | gauge | none | 活跃数据库连接 |
| `db_connections_idle` | gauge | none | 空闲数据库连接 |
| `db_pool_size_max` | gauge | none | 所有数据库连接池配置的最大连接数 |
| `db_pool_utilization_ratio` | gauge | `pool` | 连接池利用率,取值 0 到 1 |
| `cache_hits_total` | counter | `cache_type`, `level` | 缓存命中数 |
| `cache_misses_total` | counter | `cache_type`, `level` | 缓存未命中数 |
| `cache_evictions_total` | counter | `cache_type` | 缓存淘汰数 |
| `cache_errors_total` | counter | `cache_type`, `operation` | 缓存操作错误数 |
| `cache_invalidations_total` | counter | `cache_type` | 缓存失效次数 |
| `cache_operation_duration_seconds` | histogram | `operation` | 缓存操作耗时 |
| `cache_lag_flush_total` | counter | `component` | 失效 channel 延迟触发的 L1 全量清理次数 |
| `cache_fence_operations_total` | counter | `domain`, `operation`, `result` | version fence 操作次数 |
| `cache_db_fallback_total` | counter | `domain`, `reason` | 强一致读取回退 PostgreSQL 的次数 |
| `cache_stale_write_reject_total` | counter | `cache_type`, `level` | 被拒绝的过期缓存写入次数 |
| `cache_fence_pending` | gauge | `domain` | 存在待处理 version fence 的 domain |
| `cache_fence_repair_total` | counter | `domain`, `result` | 读取时 fence 修复结果 |
| `cache_fence_db_compare` | gauge | `domain`, `relation` | 最近一次 DB 与 fence 巡检比较结果 |
## 远程传输
| 指标 | 类型 | Labels | 含义 |
| --- | --- | --- | --- |
| `grpc_requests_total` | counter | `service`, `method`, `status` | 已完成 gRPC 请求数 |
| `grpc_request_duration_seconds` | histogram | `service`, `method`, `status` | gRPC 请求耗时 |
## 业务和限流
@ -56,6 +80,7 @@ curl -fsS \
| `webrtc_peers_active` | gauge | none | 活跃 WebRTC peer |
| `active_connections` | gauge | none | 活跃连接 |
| `spawned_task_panics_total` | counter | `task_name` | `spawn_monitored` 捕获的后台任务 panic |
| `logging_dropped_lines_total` | counter | `component` | 非阻塞日志队列满时丢弃的日志行数 |
| `email_delivery_queue_depth` | gauge | none | PostgreSQL outbox 中等待投递的邮件任务数 |
| `email_delivery_in_flight` | gauge | none | 当前实例正在投递的邮件任务数 |
| `email_delivery_jobs_total` | counter | `kind`, `status` | 邮件任务结果数;状态包括 `sent`、`retry`、`dead`、`superseded`、`fenced`、`persist_failed` 和 `ack_failed` |
@ -76,6 +101,10 @@ curl -fsS \
| `synctv_cluster_leader_election_epoch` | gauge | none | 当前 leader epoch |
| `synctv_cluster_leader_election_consecutive_failures` | gauge | none | 连续选主失败数 |
| `synctv_cluster_epoch_mismatch_quarantine` | gauge | none | epoch mismatch 隔离状态 |
| `synctv_cluster_leader_election_mode` | gauge | none | 选主模式0 standalone、1 Redis、2 Kubernetes Lease |
| `synctv_cluster_distributed_counter_ttl_refreshes_total` | counter | `result` | 分布式计数器 TTL 刷新结果 |
| `synctv_cluster_distributed_counter_ttl_keys_refreshed` | gauge | none | 最近一轮 TTL 刷新的 key 数 |
| `synctv_cluster_distributed_counter_ttl_consecutive_failures` | gauge | none | 连续 TTL 刷新失败数 |
## 媒体和直播
@ -85,6 +114,8 @@ curl -fsS \
| `active_relay_streams` | gauge | none | 活跃 relay stream |
| `stream_errors_total` | counter | `stream_type`, `error_type` | stream 错误数 |
| `streamhub_restarts_total` | counter | `reason` | StreamHub event loop 重启次数 |
| `streams_active` | gauge | none | 当前被跟踪的活跃 stream |
| `synctv_publisher_heartbeat_failures_total` | counter | none | heartbeat 失败后清理 publisher 的次数 |
| `livestream_active_publishers` | gauge | none | 活跃直播 publisher |
| `livestream_active_viewers` | gauge | none | 活跃直播 viewer |
| `livestream_relay_frame_drops_total` | counter | none | backpressure 导致的 relay 丢帧 |

@ -2936,9 +2936,7 @@ impl StreamMessageHandler {
self.room_service.touch_room_activity(self.room_id).await;
// Track chat message metric
synctv_core::metrics::application::CHAT_MESSAGES_TOTAL
.with_label_values(&[] as &[&str])
.inc();
synctv_core::metrics::application::CHAT_MESSAGES_TOTAL.inc();
if outcome.inserted {
self.chat_event_dispatcher.dispatch(&outcome.event);

@ -4,12 +4,14 @@
//! unified registry.
pub use synctv_core::metrics::http::{
HTTP_REQUESTS_IN_FLIGHT, HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION_SECONDS,
record_request, start_request, HTTP_REQUESTS_IN_FLIGHT, HTTP_REQUESTS_TOTAL,
HTTP_REQUEST_DURATION_SECONDS,
};
pub use synctv_core::metrics::remote_transport::{
REMOTE_TRANSPORT_REQUESTS_TOTAL, REMOTE_TRANSPORT_REQUEST_DURATION,
record as record_remote_transport_request, REMOTE_TRANSPORT_REQUESTS_TOTAL,
REMOTE_TRANSPORT_REQUEST_DURATION,
};
pub use synctv_core::metrics::livestream::LIVESTREAM_FLV_SLOW_CLIENT_TERMINATIONS_TOTAL;
pub use synctv_core::metrics::gather_metrics;
pub use synctv_core::metrics::{gather_metrics, initialize};

@ -550,12 +550,7 @@ fn grpc_access_log_level(
fn record_grpc_metrics(route: &str, grpc_code: i32, grpc_status: &str, elapsed: Duration) {
let (service, method) = grpc_metric_labels(route, grpc_code);
metrics::REMOTE_TRANSPORT_REQUESTS_TOTAL
.with_label_values(&[service, method, grpc_status])
.inc();
metrics::REMOTE_TRANSPORT_REQUEST_DURATION
.with_label_values(&[service, method, grpc_status])
.observe(elapsed.as_secs_f64());
metrics::record_remote_transport_request(service, method, grpc_status, elapsed);
}
fn grpc_metric_labels(route: &str, grpc_code: i32) -> (&str, &str) {

@ -37,6 +37,7 @@ pub fn create_health_router() -> Router<AppState> {
/// Dedicated metrics router.
pub fn create_metrics_router() -> Router<AppState> {
metrics::initialize();
Router::new().route("/metrics", get(prometheus_metrics))
}
@ -595,14 +596,28 @@ pub async fn prometheus_metrics(
}
}
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
metrics::gather_metrics(),
)
.into_response()
match metrics::gather_metrics() {
Ok(body) => (
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body,
)
.into_response(),
Err(error) => {
tracing::error!(%error, "Failed to gather Prometheus metrics");
(
StatusCode::INTERNAL_SERVER_ERROR,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"Internal Server Error".to_string(),
)
.into_response()
}
}
}
#[cfg(test)]

@ -9,21 +9,6 @@ use std::time::Instant;
use synctv_api_common::observability::metrics;
struct InFlightRequestGuard;
impl InFlightRequestGuard {
fn new() -> Self {
metrics::HTTP_REQUESTS_IN_FLIGHT.inc();
Self
}
}
impl Drop for InFlightRequestGuard {
fn drop(&mut self) {
metrics::HTTP_REQUESTS_IN_FLIGHT.dec();
}
}
/// Middleware that records HTTP request count, duration, and in-flight gauge.
pub async fn metrics_layer(request: Request, next: Next) -> Response {
let method = request.method().to_string();
@ -32,20 +17,12 @@ pub async fn metrics_layer(request: Request, next: Next) -> Response {
|path| path.as_str().to_string(),
);
let _in_flight = InFlightRequestGuard::new();
let _in_flight = metrics::start_request();
let start = Instant::now();
let response = next.run(request).await;
let duration = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
metrics::HTTP_REQUESTS_TOTAL
.with_label_values(&[&method, &path, &status])
.inc();
metrics::HTTP_REQUEST_DURATION_SECONDS
.with_label_values(&[&method, &path])
.observe(duration);
metrics::record_request(&method, &path, response.status().as_u16(), start.elapsed());
response
}
@ -77,7 +54,8 @@ mod tests {
.expect("request should complete");
assert_eq!(response.status(), StatusCode::OK);
let output = synctv_api_common::observability::metrics::gather_metrics();
let output = synctv_api_common::observability::metrics::gather_metrics()
.expect("metrics should encode");
assert!(output.contains(
"http_requests_total{method=\"GET\",path=\"/items/{item_id}\",status=\"200\"}"
));

@ -55,56 +55,6 @@ use synctv_realtime::sync::ConnectionRuntime;
const SLOW_CLIENT_DROP_THRESHOLD: u32 = 10;
const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
// MetricsGuard - RAII guard for WebSocket metrics
/// RAII guard that increments WebSocket metrics on creation and decrements on drop.
///
/// This ensures metrics are correctly maintained even if the connection handling
/// panics or returns early. Without this guard, metrics would leak in error paths.
///
/// # Example
///
/// ```text
/// async fn handle_socket() {
/// let _guard = MetricsGuard::new();
///
/// // Even if this panics, metrics will be decremented
/// // when _guard is dropped
/// do_work().await;
/// }
/// ```
pub struct MetricsGuard {
/// Track if we've already decremented (to prevent double-decrement)
decremented: bool,
}
impl MetricsGuard {
/// Create a new guard, incrementing WebSocket connection metrics.
#[must_use = "MetricsGuard must be held for metrics to be tracked correctly"]
pub fn new() -> Self {
synctv_core::metrics::http::WEBSOCKET_CONNECTIONS_ACTIVE.inc();
synctv_core::metrics::http::WEBSOCKET_CONNECTIONS_TOTAL
.with_label_values(&["success"])
.inc();
Self { decremented: false }
}
}
impl Default for MetricsGuard {
fn default() -> Self {
Self::new()
}
}
impl Drop for MetricsGuard {
fn drop(&mut self) {
if !self.decremented {
synctv_core::metrics::http::WEBSOCKET_CONNECTIONS_ACTIVE.dec();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeTransportFormat {
Json,
@ -958,9 +908,7 @@ impl synctv_api_common::impls::messaging::MessageSender for WebSocketMessageSend
message_type = msg_type,
"Critical WebSocket message rejected: critical queue full (slow client)"
);
synctv_core::metrics::http::WEBSOCKET_ERRORS_TOTAL
.with_label_values(&["message_dropped_critical"])
.inc();
synctv_core::metrics::http::record_websocket_error("message_dropped_critical");
return Err(format!(
"Critical message (type={msg_type}) rejected: critical queue full after {drops} consecutive drops (slow client)"
));
@ -984,9 +932,7 @@ impl synctv_api_common::impls::messaging::MessageSender for WebSocketMessageSend
message_type = msg_type,
"WebSocket message dropped: channel full (slow client)"
);
synctv_core::metrics::http::WEBSOCKET_ERRORS_TOTAL
.with_label_values(&["message_dropped"])
.inc();
synctv_core::metrics::http::record_websocket_error("message_dropped");
if requires_resync || drops >= SLOW_CLIENT_DROP_THRESHOLD {
// Too many consecutive drops: disconnect the slow client gracefully
Err(format!(
@ -1326,7 +1272,7 @@ async fn handle_socket(
let event_service = state.event_service.clone();
let _metrics_guard = MetricsGuard::new();
let _metrics_guard = synctv_core::metrics::http::track_websocket_connection();
// Use the shared rate limiter from app state
let rate_limiter = state.rate_limiter.clone();

@ -70,25 +70,23 @@ impl ClusterService for ClusterServer {
match result {
Ok(nodes) => {
let elapsed = start.elapsed().as_secs_f64();
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUEST_DURATION
.with_label_values(&["cluster", "get_nodes", "ok"])
.observe(elapsed);
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUESTS_TOTAL
.with_label_values(&["cluster", "get_nodes", "ok"])
.inc();
synctv_core::metrics::remote_transport::record(
"cluster",
"get_nodes",
"ok",
start.elapsed(),
);
let proto_nodes = nodes.iter().map(Self::discovery_to_proto_node).collect();
Ok(Response::new(GetNodesResponse { nodes: proto_nodes }))
}
Err(error) => {
let elapsed = start.elapsed().as_secs_f64();
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUEST_DURATION
.with_label_values(&["cluster", "get_nodes", "error"])
.observe(elapsed);
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUESTS_TOTAL
.with_label_values(&["cluster", "get_nodes", "error"])
.inc();
synctv_core::metrics::remote_transport::record(
"cluster",
"get_nodes",
"error",
start.elapsed(),
);
tracing::error!("Failed to get nodes from cluster registry: {error}");
Err(Status::unavailable(error.to_string()))
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,21 @@
use super::*;
pub static ROOMS_ACTIVE: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| int_gauge("rooms_active", "Number of currently active rooms"));
pub static USERS_ONLINE: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| int_gauge("users_online", "Number of currently online users"));
pub static STREAMS_ACTIVE: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| int_gauge("streams_active", "Number of active live streams"));
pub static WEBRTC_PEERS_ACTIVE: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"webrtc_peers_active",
"Number of active WebRTC peer connections",
)
});
pub static CHAT_MESSAGES_TOTAL: std::sync::LazyLock<IntCounter> = std::sync::LazyLock::new(|| {
int_counter("chat_messages_total", "Total number of chat messages sent")
});

@ -0,0 +1,114 @@
use super::*;
pub static CACHE_HITS: std::sync::LazyLock<IntCounterVec> = std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_hits_total",
"Total number of cache hits",
&["cache_type", "level"],
)
});
pub static CACHE_MISSES: std::sync::LazyLock<IntCounterVec> = std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_misses_total",
"Total number of cache misses",
&["cache_type", "level"],
)
});
pub static CACHE_EVICTIONS: std::sync::LazyLock<IntCounterVec> = std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_evictions_total",
"Total number of cache evictions",
&["cache_type"],
)
});
pub static CACHE_ERRORS: std::sync::LazyLock<IntCounterVec> = std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_errors_total",
"Total number of cache operation errors",
&["cache_type", "operation"],
)
});
pub static CACHE_INVALIDATIONS: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_invalidations_total",
"Total number of cache invalidations",
&["cache_type"],
)
});
pub static CACHE_OPERATION_DURATION: std::sync::LazyLock<HistogramVec> =
std::sync::LazyLock::new(|| {
histogram_vec(
HistogramOpts::new(
"cache_operation_duration_seconds",
"Duration of cache operations in seconds",
),
&["operation"],
)
});
pub static CACHE_LAG_FLUSH_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_lag_flush_total",
"Total L1 cache flushes triggered by broadcast channel lag",
&["component"],
)
});
pub static CACHE_FENCE_OPERATIONS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_fence_operations_total",
"Total number of cache version-fence operations",
&["domain", "operation", "result"],
)
});
pub static CACHE_DB_FALLBACK_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_db_fallback_total",
"Total number of strong cache reads that fell back to PostgreSQL",
&["domain", "reason"],
)
});
pub static CACHE_STALE_WRITE_REJECT_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_stale_write_reject_total",
"Total number of stale version-aware cache writes rejected",
&["cache_type", "level"],
)
});
pub static CACHE_FENCE_PENDING: std::sync::LazyLock<GaugeVec> = std::sync::LazyLock::new(|| {
gauge_vec(
"cache_fence_pending",
"Whether a cache version fence domain currently has a pending write",
&["domain"],
)
});
pub static CACHE_FENCE_REPAIR_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"cache_fence_repair_total",
"Total number of read-time cache fence repair outcomes",
&["domain", "result"],
)
});
pub static CACHE_FENCE_DB_COMPARE: std::sync::LazyLock<GaugeVec> = std::sync::LazyLock::new(|| {
gauge_vec(
"cache_fence_db_compare",
"Latest cache fence patrol comparison with PostgreSQL version (1 when observed)",
&["domain", "relation"],
)
});

@ -0,0 +1,112 @@
use super::*;
pub static CLUSTER_CONNECTIONS: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_connections_total",
"Current number of active connections on this cluster node",
)
});
pub static NODE_ACTIVE_ROOMS: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"synctv_node_active_rooms",
"Current number of active rooms on this node",
)
});
pub static REALTIME_EVENTS_PUBLISHED: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_realtime_events_published_total",
"Total realtime events published",
&["event_type"],
)
});
pub static REALTIME_EVENTS_RECEIVED: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_realtime_events_received_total",
"Total realtime events received from other nodes",
&["event_type"],
)
});
pub static REALTIME_EVENTS_DROPPED: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_realtime_events_dropped_total",
"Total realtime events dropped",
&["reason"],
)
});
pub static CLUSTER_HEARTBEAT_FAILURES: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_heartbeat_failures",
"Consecutive Redis heartbeat failures for network partition detection",
)
});
pub static LEADER_ELECTION_STATE: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_leader_election_state",
"Leader election state (1 = leader, 0 = follower)",
)
});
pub static LEADER_ELECTION_EPOCH: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_leader_election_epoch",
"Leader election epoch (fencing token), incremented on each leadership acquisition",
)
});
pub static LEADER_ELECTION_CONSECUTIVE_FAILURES: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_leader_election_consecutive_failures",
"Consecutive leader election failures (network partition or backend outage detection)",
)
});
pub static CLUSTER_EPOCH_MISMATCH_QUARANTINE: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_epoch_mismatch_quarantine",
"Epoch mismatch quarantine state (1 = quarantined due to split-brain, 0 = normal)",
)
});
pub static LEADER_ELECTION_MODE: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_leader_election_mode",
"Leader election mode (0=standalone, 1=redis, 2=k8s_lease)",
)
});
pub static DISTRIBUTED_COUNTER_TTL_REFRESHES: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_cluster_distributed_counter_ttl_refreshes_total",
"Total distributed counter TTL refresh operations",
&["result"],
)
});
pub static DISTRIBUTED_COUNTER_TTL_KEYS_REFRESHED: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_distributed_counter_ttl_keys_refreshed",
"Number of keys refreshed in the last TTL refresh cycle",
)
});
pub static DISTRIBUTED_COUNTER_TTL_CONSECUTIVE_FAILURES: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"synctv_cluster_distributed_counter_ttl_consecutive_failures",
"Consecutive TTL refresh failures (alert when >= 3)",
)
});

@ -0,0 +1,30 @@
use super::*;
pub static DB_CONNECTIONS_ACTIVE: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"db_connections_active",
"Current number of active database connections",
)
});
pub static DB_POOL_UTILIZATION: std::sync::LazyLock<GaugeVec> = std::sync::LazyLock::new(|| {
gauge_vec(
"db_pool_utilization_ratio",
"Database connection pool utilization ratio (active/max)",
&["pool"],
)
});
pub static DB_POOL_SIZE_MAX: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"db_pool_size_max",
"Maximum number of connections in the pool",
)
});
pub static DB_CONNECTIONS_IDLE: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"db_connections_idle",
"Number of idle connections in the pool",
)
});

@ -0,0 +1,38 @@
use super::*;
pub static EMAIL_DELIVERY_QUEUE_DEPTH: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"email_delivery_queue_depth",
"Number of queued email delivery jobs",
)
});
pub static EMAIL_DELIVERY_IN_FLIGHT: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"email_delivery_in_flight",
"Number of email delivery jobs currently being processed",
)
});
pub static EMAIL_DELIVERY_JOBS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"email_delivery_jobs_total",
"Total email delivery job transitions",
&["kind", "status"],
)
});
pub static EMAIL_DELIVERY_DURATION_SECONDS: std::sync::LazyLock<HistogramVec> =
std::sync::LazyLock::new(|| {
histogram_vec(
HistogramOpts::new(
"email_delivery_duration_seconds",
"Email delivery processing duration in seconds",
)
.buckets(vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0]),
&["kind", "status"],
)
});

@ -0,0 +1,35 @@
use super::*;
pub static FILE_OBJECT_DELETE_ATTEMPTS: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_file_object_delete_attempts_total",
"Total file object delete attempts",
&["origin", "backend"],
)
});
pub static FILE_OBJECT_DELETE_FAILURES: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_file_object_delete_failures_total",
"Total file object delete failures",
&["origin", "backend"],
)
});
pub static FILE_CLEANUP_JOBS_DUE: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"synctv_file_cleanup_jobs_due",
"File cleanup jobs due for retry",
)
});
pub static FILE_CLEANUP_JOBS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"synctv_file_cleanup_jobs_total",
"Total file cleanup retry job actions",
&["action", "origin", "backend"],
)
});

@ -0,0 +1,73 @@
use prometheus::{Histogram, HistogramTimer, IntGauge};
/// Keeps an integer gauge balanced across early returns, cancellation, and panics.
#[derive(Debug)]
#[must_use = "the guard must be held for as long as the measured operation is active"]
pub struct GaugeGuard {
gauge: IntGauge,
}
impl GaugeGuard {
pub fn increment(gauge: &IntGauge) -> Self {
gauge.inc();
Self {
gauge: gauge.clone(),
}
}
}
impl Drop for GaugeGuard {
fn drop(&mut self) {
self.gauge.dec();
}
}
/// Measures an operation's active count and duration through one lexical lifetime.
#[derive(Debug)]
#[must_use = "the guard must be held until the measured operation completes"]
pub struct InFlightTimer {
_active: GaugeGuard,
_duration: HistogramTimer,
}
impl InFlightTimer {
pub fn start(active: &IntGauge, duration: &Histogram) -> Self {
Self {
_active: GaugeGuard::increment(active),
_duration: duration.start_timer(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gauge_guard_balances_the_gauge_when_dropped() {
let gauge = IntGauge::new("guard_test_gauge", "test gauge").expect("valid gauge");
{
let _guard = GaugeGuard::increment(&gauge);
assert_eq!(gauge.get(), 1);
}
assert_eq!(gauge.get(), 0);
}
#[test]
fn in_flight_timer_records_duration_and_balances_the_gauge() {
let gauge = IntGauge::new("timer_test_gauge", "test gauge").expect("valid gauge");
let histogram =
Histogram::with_opts(prometheus::HistogramOpts::new("timer_test", "test timer"))
.expect("valid histogram");
{
let _guard = InFlightTimer::start(&gauge, &histogram);
assert_eq!(gauge.get(), 1);
}
assert_eq!(gauge.get(), 0);
assert_eq!(histogram.get_sample_count(), 1);
}
}

@ -0,0 +1,128 @@
use super::*;
const WEBSOCKET_SUCCESS: &str = "success";
/// Total HTTP requests, labeled by method, path, and status code.
pub static HTTP_REQUESTS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"http_requests_total",
"Total number of HTTP requests",
&["method", "path", "status"],
)
});
/// HTTP request duration in seconds, labeled by method and path.
pub static HTTP_REQUEST_DURATION_SECONDS: std::sync::LazyLock<HistogramVec> =
std::sync::LazyLock::new(|| {
histogram_vec(
HistogramOpts::new(
"http_request_duration_seconds",
"HTTP request duration in seconds (P50/P95/P99)",
)
.buckets(vec![
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
]),
&["method", "path"],
)
});
/// Number of in-flight HTTP requests.
pub static HTTP_REQUESTS_IN_FLIGHT: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"http_requests_in_flight",
"Number of HTTP requests currently being processed",
)
});
/// Active WebSocket connections.
pub static WEBSOCKET_CONNECTIONS_ACTIVE: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"websocket_connections_active",
"Number of active WebSocket connections",
)
});
/// Total WebSocket connections opened, labeled by connection outcome.
pub static WEBSOCKET_CONNECTIONS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"websocket_connections_total",
"Total number of WebSocket connections opened",
&["status"],
)
});
/// Total WebSocket errors, labeled by error type.
pub static WEBSOCKET_ERRORS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"websocket_errors_total",
"Total number of WebSocket errors",
&["error_type"],
)
});
pub fn start_request() -> GaugeGuard {
GaugeGuard::increment(&HTTP_REQUESTS_IN_FLIGHT)
}
pub fn record_request(method: &str, path: &str, status: u16, elapsed: std::time::Duration) {
let status = status.to_string();
HTTP_REQUESTS_TOTAL
.with_label_values(&[method, path, &status])
.inc();
HTTP_REQUEST_DURATION_SECONDS
.with_label_values(&[method, path])
.observe(elapsed.as_secs_f64());
}
pub fn track_websocket_connection() -> GaugeGuard {
WEBSOCKET_CONNECTIONS_TOTAL
.with_label_values(&[WEBSOCKET_SUCCESS])
.inc();
GaugeGuard::increment(&WEBSOCKET_CONNECTIONS_ACTIVE)
}
pub fn record_websocket_error(error_type: &'static str) {
WEBSOCKET_ERRORS_TOTAL
.with_label_values(&[error_type])
.inc();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_facade_uses_bounded_route_labels() {
let requests = HTTP_REQUESTS_TOTAL
.with_label_values(&["GET", "/items/{item_id}", "200"])
.get();
let observations = HTTP_REQUEST_DURATION_SECONDS
.with_label_values(&["GET", "/items/{item_id}"])
.get_sample_count();
record_request(
"GET",
"/items/{item_id}",
200,
std::time::Duration::from_millis(5),
);
assert_eq!(
HTTP_REQUESTS_TOTAL
.with_label_values(&["GET", "/items/{item_id}", "200"])
.get(),
requests + 1
);
assert_eq!(
HTTP_REQUEST_DURATION_SECONDS
.with_label_values(&["GET", "/items/{item_id}"])
.get_sample_count(),
observations + 1
);
}
}

@ -0,0 +1,41 @@
use super::*;
pub static PUBLISHER_HEARTBEAT_FAILURES: std::sync::LazyLock<IntCounter> =
std::sync::LazyLock::new(|| {
int_counter(
"synctv_publisher_heartbeat_failures_total",
"Total publisher cleanups due to heartbeat failure",
)
});
pub static LIVESTREAM_ACTIVE_PUBLISHERS: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"livestream_active_publishers",
"Number of active livestream publishers",
)
});
pub static LIVESTREAM_ACTIVE_VIEWERS: std::sync::LazyLock<IntGauge> =
std::sync::LazyLock::new(|| {
int_gauge(
"livestream_active_viewers",
"Number of active livestream viewers",
)
});
pub static LIVESTREAM_RELAY_FRAME_DROPS: std::sync::LazyLock<IntCounter> =
std::sync::LazyLock::new(|| {
int_counter(
"livestream_relay_frame_drops_total",
"Total relay frames dropped due to backpressure",
)
});
pub static LIVESTREAM_FLV_SLOW_CLIENT_TERMINATIONS_TOTAL: std::sync::LazyLock<IntCounter> =
std::sync::LazyLock::new(|| {
int_counter(
"livestream_flv_slow_client_terminations_total",
"Total FLV stream terminations due to slow client",
)
});

@ -0,0 +1,30 @@
use std::{collections::HashMap, sync::Mutex};
use super::*;
pub static LOGGING_DROPPED_LINES_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"logging_dropped_lines_total",
"Total log lines dropped by a full non-blocking queue",
&["component"],
)
});
static LAST_OBSERVED: std::sync::LazyLock<Mutex<HashMap<String, usize>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) fn sync_dropped_lines(samples: &[(String, usize)]) {
let mut observed = LAST_OBSERVED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (component, current) in samples {
let previous = observed.entry(component.clone()).or_default();
let delta = current.saturating_sub(*previous);
let counter = LOGGING_DROPPED_LINES_TOTAL.with_label_values(&[component]);
if delta > 0 {
counter.inc_by(u64::try_from(delta).unwrap_or(u64::MAX));
}
*previous = *current;
}
}

@ -0,0 +1,10 @@
use super::*;
pub static RATE_LIMIT_REDIS_FALLBACKS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"rate_limit_redis_fallbacks_total",
"Total Redis errors that triggered in-memory rate limit fallback",
&["category"],
)
});

@ -0,0 +1,166 @@
use std::{
collections::BTreeMap,
sync::{LazyLock, Mutex},
};
use prometheus::{
core::Collector, Encoder, GaugeVec, HistogramOpts, HistogramVec, IntCounter, IntCounterVec,
IntGauge, Opts, Registry, TextEncoder,
};
static REGISTRY: LazyLock<MetricsRegistry> = LazyLock::new(MetricsRegistry::new);
#[derive(Debug)]
struct MetricsRegistry {
inner: Registry,
descriptors: Mutex<BTreeMap<String, MetricDescriptor>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MetricKind {
Counter,
Gauge,
Histogram,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MetricDescriptor {
pub kind: MetricKind,
pub labels: Vec<String>,
}
impl MetricsRegistry {
fn new() -> Self {
let registry = Self {
inner: Registry::new(),
descriptors: Mutex::new(BTreeMap::new()),
};
#[cfg(target_os = "linux")]
registry.register_collector(
prometheus::process_collector::ProcessCollector::for_self(),
"process",
);
registry
}
#[cfg(test)]
fn empty() -> Self {
Self {
inner: Registry::new(),
descriptors: Mutex::new(BTreeMap::new()),
}
}
fn register<T>(&self, metric: T, name: &str, kind: MetricKind) -> T
where
T: Collector + Clone + 'static,
{
let descriptors = metric.desc();
self.register_collector(metric.clone(), name);
let mut registered = self
.descriptors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for descriptor in descriptors {
registered.insert(
descriptor.fq_name.clone(),
MetricDescriptor {
kind,
labels: descriptor.variable_labels.clone(),
},
);
}
metric
}
fn register_collector<T>(&self, collector: T, name: &str)
where
T: Collector + 'static,
{
self.inner
.register(Box::new(collector))
.unwrap_or_else(|error| panic!("registering Prometheus metric `{name}`: {error}"));
}
fn gather(&self) -> Result<String, MetricsError> {
let mut buffer = Vec::new();
TextEncoder::new().encode(&self.inner.gather(), &mut buffer)?;
Ok(String::from_utf8(buffer)?)
}
}
#[derive(Debug, thiserror::Error)]
pub enum MetricsError {
#[error("failed to encode Prometheus metrics: {0}")]
Encode(#[from] prometheus::Error),
#[error("Prometheus text encoder produced invalid UTF-8: {0}")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
}
fn register<T>(metric: T, name: &str, kind: MetricKind) -> T
where
T: Collector + Clone + 'static,
{
REGISTRY.register(metric, name, kind)
}
pub(super) fn int_counter(name: &str, help: &str) -> IntCounter {
let metric = IntCounter::new(name, help)
.unwrap_or_else(|error| panic!("defining Prometheus metric `{name}`: {error}"));
register(metric, name, MetricKind::Counter)
}
pub(super) fn int_gauge(name: &str, help: &str) -> IntGauge {
let metric = IntGauge::new(name, help)
.unwrap_or_else(|error| panic!("defining Prometheus metric `{name}`: {error}"));
register(metric, name, MetricKind::Gauge)
}
pub(super) fn int_counter_vec(name: &str, help: &str, labels: &[&str]) -> IntCounterVec {
let metric = IntCounterVec::new(Opts::new(name, help), labels)
.unwrap_or_else(|error| panic!("defining Prometheus metric `{name}`: {error}"));
register(metric, name, MetricKind::Counter)
}
pub(super) fn gauge_vec(name: &str, help: &str, labels: &[&str]) -> GaugeVec {
let metric = GaugeVec::new(Opts::new(name, help), labels)
.unwrap_or_else(|error| panic!("defining Prometheus metric `{name}`: {error}"));
register(metric, name, MetricKind::Gauge)
}
pub(super) fn histogram_vec(opts: HistogramOpts, labels: &[&str]) -> HistogramVec {
let name = opts.common_opts.fq_name();
let metric = HistogramVec::new(opts, labels)
.unwrap_or_else(|error| panic!("defining Prometheus metric `{name}`: {error}"));
register(metric, &name, MetricKind::Histogram)
}
pub(super) fn gather() -> Result<String, MetricsError> {
REGISTRY.gather()
}
#[cfg(test)]
pub(super) fn descriptors() -> BTreeMap<String, MetricDescriptor> {
REGISTRY
.descriptors
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "registering Prometheus metric `duplicate_metric`")]
fn registry_fails_fast_on_duplicate_metric_names() {
let registry = MetricsRegistry::empty();
let first = IntGauge::new("duplicate_metric", "first definition").expect("valid metric");
let duplicate =
IntGauge::new("duplicate_metric", "second definition").expect("valid metric");
registry.register(first, "duplicate_metric", MetricKind::Gauge);
registry.register(duplicate, "duplicate_metric", MetricKind::Gauge);
}
}

@ -0,0 +1,31 @@
use super::*;
pub static REMOTE_TRANSPORT_REQUESTS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"grpc_requests_total",
"Total number of remote transport requests",
&["service", "method", "status"],
)
});
pub static REMOTE_TRANSPORT_REQUEST_DURATION: std::sync::LazyLock<HistogramVec> =
std::sync::LazyLock::new(|| {
histogram_vec(
HistogramOpts::new(
"grpc_request_duration_seconds",
"Remote transport request duration in seconds",
),
&["service", "method", "status"],
)
});
pub fn record(service: &str, method: &str, status: &str, elapsed: std::time::Duration) {
let labels = &[service, method, status];
REMOTE_TRANSPORT_REQUESTS_TOTAL
.with_label_values(labels)
.inc();
REMOTE_TRANSPORT_REQUEST_DURATION
.with_label_values(labels)
.observe(elapsed.as_secs_f64());
}

@ -0,0 +1,83 @@
use super::*;
#[derive(Debug, Clone, Copy)]
pub enum RelayProtocol {
Hls,
Rtmp,
}
impl RelayProtocol {
const fn as_str(self) -> &'static str {
match self {
Self::Hls => "hls",
Self::Rtmp => "rtmp",
}
}
}
pub static STREAM_RELAY_DURATION: std::sync::LazyLock<HistogramVec> =
std::sync::LazyLock::new(|| {
histogram_vec(
HistogramOpts::new(
"stream_relay_duration_seconds",
"Stream relay operation duration in seconds",
),
&["stream_type"],
)
});
pub static ACTIVE_RELAY_STREAMS: std::sync::LazyLock<IntGauge> = std::sync::LazyLock::new(|| {
int_gauge(
"active_relay_streams",
"Current number of active relay streams",
)
});
pub static STREAM_ERRORS: std::sync::LazyLock<IntCounterVec> = std::sync::LazyLock::new(|| {
int_counter_vec(
"stream_errors_total",
"Total number of stream errors",
&["stream_type", "error_type"],
)
});
pub fn track_relay(protocol: RelayProtocol) -> InFlightTimer {
InFlightTimer::start(
&ACTIVE_RELAY_STREAMS,
&STREAM_RELAY_DURATION.with_label_values(&[protocol.as_str()]),
)
}
pub fn record_error(protocol: RelayProtocol, error: &str) {
let error_type = if error.contains("timeout") {
"timeout"
} else if error.contains("connection") {
"connection"
} else {
"other"
};
STREAM_ERRORS
.with_label_values(&[protocol.as_str(), error_type])
.inc();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relay_error_classification_has_a_bounded_value_set() {
let timeout = STREAM_ERRORS.with_label_values(&["rtmp", "timeout"]);
let connection = STREAM_ERRORS.with_label_values(&["rtmp", "connection"]);
let other = STREAM_ERRORS.with_label_values(&["rtmp", "other"]);
let before = (timeout.get(), connection.get(), other.get());
record_error(RelayProtocol::Rtmp, "request timeout");
record_error(RelayProtocol::Rtmp, "connection reset");
record_error(RelayProtocol::Rtmp, "codec failure with id 123");
assert_eq!(timeout.get(), before.0 + 1);
assert_eq!(connection.get(), before.1 + 1);
assert_eq!(other.get(), before.2 + 1);
}
}

@ -0,0 +1,10 @@
use super::*;
pub static STREAMHUB_RESTARTS_TOTAL: std::sync::LazyLock<IntCounterVec> =
std::sync::LazyLock::new(|| {
int_counter_vec(
"streamhub_restarts_total",
"Total number of StreamHub event loop restarts",
&["reason"],
)
});

@ -0,0 +1,9 @@
use super::*;
pub static TASK_PANICS_TOTAL: std::sync::LazyLock<IntCounterVec> = std::sync::LazyLock::new(|| {
int_counter_vec(
"spawned_task_panics_total",
"Total number of spawned task panics caught by spawn_monitored",
&["task_name"],
)
});

@ -370,102 +370,89 @@ impl PullStream {
.with_grpc_max_message_size(grpc_max_message_size_bytes)
.with_grpc_compression(grpc_compression_enabled);
// Track relay duration via histogram (stream_type = "rtmp" for gRPC RTMP relay)
let timer = synctv_core::metrics::stream::STREAM_RELAY_DURATION
.with_label_values(&["rtmp"])
.start_timer();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.inc();
// Race the puller against cancellation and periodic lease_epoch re-validation
let mut epoch_interval =
tokio::time::interval(retry_policy.epoch_revalidation_interval);
// Skip the first immediate tick
epoch_interval.tick().await;
let run_result = tokio::select! {
r = grpc_puller.run(&data_sender) => r,
() = child_token.cancelled() => {
info!("gRPC puller task cancelled for {} / {}", room_id, media_id);
timer.observe_duration();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.dec();
break Ok(());
}
() = async {
loop {
epoch_interval.tick().await;
match registry
.validate_lease(
&room_id,
&media_id,
&source_generation_id,
lease_epoch,
)
.await
{
Ok(true) => {
// Reset failure counter on success.
consecutive_epoch_failures = 0;
debug!(
"Periodic lease_epoch {} still valid for {}/{}",
lease_epoch, room_id, media_id
);
}
Ok(false) => {
warn!(
"Periodic lease_epoch re-validation: lease_epoch {} is stale for {}/{}, publisher changed",
lease_epoch, room_id, media_id
);
return;
}
Err(e) => {
// Track consecutive failures instead of unconditional fail-open.
consecutive_epoch_failures += 1;
if consecutive_epoch_failures >= retry_policy.max_consecutive_epoch_failures {
error!(
"Epoch validation failed {} consecutive times for {}/{}: {}. \
Terminating pull stream (publisher may be stale). \
Stream will reconnect when Redis is available.",
consecutive_epoch_failures, room_id, media_id, e
let run_result = {
let _relay_metrics = synctv_core::metrics::stream::track_relay(
synctv_core::metrics::stream::RelayProtocol::Rtmp,
);
tokio::select! {
r = grpc_puller.run(&data_sender) => r,
() = child_token.cancelled() => {
info!("gRPC puller task cancelled for {} / {}", room_id, media_id);
break Ok(());
}
() = async {
loop {
epoch_interval.tick().await;
match registry
.validate_lease(
&room_id,
&media_id,
&source_generation_id,
lease_epoch,
)
.await
{
Ok(true) => {
// Reset failure counter on success.
consecutive_epoch_failures = 0;
debug!(
"Periodic lease_epoch {} still valid for {}/{}",
lease_epoch, room_id, media_id
);
}
Ok(false) => {
warn!(
"Periodic lease_epoch re-validation: lease_epoch {} is stale for {}/{}, publisher changed",
lease_epoch, room_id, media_id
);
return;
}
warn!(
"Periodic lease_epoch re-validation failed for {}/{}: {} ({}/{} consecutive failures). Continuing.",
room_id, media_id, e, consecutive_epoch_failures, retry_policy.max_consecutive_epoch_failures
);
Err(e) => {
// Track consecutive failures instead of unconditional fail-open.
consecutive_epoch_failures += 1;
if consecutive_epoch_failures >= retry_policy.max_consecutive_epoch_failures {
error!(
"Epoch validation failed {} consecutive times for {}/{}: {}. \
Terminating pull stream (publisher may be stale). \
Stream will reconnect when Redis is available.",
consecutive_epoch_failures, room_id, media_id, e
);
return;
}
warn!(
"Periodic lease_epoch re-validation failed for {}/{}: {} ({}/{} consecutive failures). Continuing.",
room_id, media_id, e, consecutive_epoch_failures, retry_policy.max_consecutive_epoch_failures
);
}
}
}
} => {
warn!(
"Stale lease_epoch detected during streaming for {}/{}; stopping pull stream",
room_id, media_id
);
break Err(anyhow::anyhow!(
"Stale lease_epoch detected during streaming: publisher changed for {room_id} / {media_id}"
));
}
} => {
warn!(
"Stale lease_epoch detected during streaming for {}/{}; stopping pull stream",
room_id, media_id
);
timer.observe_duration();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.dec();
break Err(anyhow::anyhow!(
"Stale lease_epoch detected during streaming: publisher changed for {room_id} / {media_id}"
));
}
};
timer.observe_duration();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.dec();
match run_result {
Ok(()) => break Ok(()),
Err(e) => {
let err_str = e.to_string();
let error_type = if err_str.contains("timeout") {
"timeout"
} else if err_str.contains("connection") {
"connection"
} else {
"other"
};
synctv_core::metrics::stream::STREAM_ERRORS
.with_label_values(&["rtmp", error_type])
.inc();
synctv_core::metrics::stream::record_error(
synctv_core::metrics::stream::RelayProtocol::Rtmp,
&err_str,
);
rebuild_count += 1;
if rebuild_count > retry_policy.max_rebuilds {

@ -1201,29 +1201,18 @@ impl LivestreamServer {
)
.with_active_publishers_source(active_publishers_source);
let timer = synctv_core::metrics::stream::STREAM_RELAY_DURATION
.with_label_values(&["hls"])
.start_timer();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.inc();
let _relay_metrics = synctv_core::metrics::stream::track_relay(
synctv_core::metrics::stream::RelayProtocol::Hls,
);
if let Err(e) = remuxer.run().await {
error!("HLS remuxer error: {}", e);
let err_str = e.to_string();
let error_type = if err_str.contains("timeout") {
"timeout"
} else if err_str.contains("connection") {
"connection"
} else {
"other"
};
synctv_core::metrics::stream::STREAM_ERRORS
.with_label_values(&["hls", error_type])
.inc();
synctv_core::metrics::stream::record_error(
synctv_core::metrics::stream::RelayProtocol::Hls,
&e.to_string(),
);
}
timer.observe_duration();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.dec();
});
info!("HLS remuxer started (in-process, no standalone HTTP server)");

Loading…
Cancel
Save