refactor(metrics): centralize registry and lifecycle tracking (#446)

## Summary

- split Prometheus definitions into domain-owned modules backed by one
registry
- eagerly initialize every metric family and fail startup on invalid or
duplicate definitions
- add reusable RAII guards for gauges and relay durations across
cancellation, panic, and retry paths
- centralize HTTP, WebSocket, gRPC, and relay recording behind
bounded-label helpers
- enforce the 65-metric bilingual catalog from registered descriptors
- persist completed explicit-message deletion and user-ban moderation
steps
- update the pinned Rust toolchain and CI jobs to `nightly-2026-08-25`
- make the CI Clippy job fail on every Rust warning with `-D warnings`
- keep Helm configuration-validation builds visible to avoid silent-run
termination
- separate Rust caches by artifact type and let Helm reuse codegen
artifacts from the Build job
- work around the nightly global next-solver memory regression and
remove obsolete rustc recursion-limit overrides
- preserve workspace Rust flags in Docker builds while applying linker
flags to the final binary

## Compatibility

- preserve all existing metric names, HELP text, label order, and
histogram buckets
- keep route-template and bounded status/error labels
- return HTTP 500 when Prometheus exposition encoding fails
- keep the latest nightly while restoring the previous coherence-only
trait-solver behavior

## Nightly memory regression

The failed CI jobs were terminated by the runner while compiling
`synctv-api-http` (SIGTERM, exit 143). Local peak-RSS measurements for
that crate were:

| Configuration | Peak RSS |
| --- | ---: |
| `nightly-2026-08-21` | 6.62 GB |
| `nightly-2026-08-25`, global next solver | 10.76 GB |
| `nightly-2026-08-25`, `-Znext-solver=coherence` | 6.64 GB |

Rust enabled `-Znext-solver=globally` by default on nightly in
[rust-lang/rust#160619](https://github.com/rust-lang/rust/pull/160619).
The same memory blow-up is tracked in
[rust-lang/rust#161748](https://github.com/rust-lang/rust/issues/161748),
and the official tracking issue documents `-Znext-solver=coherence` as
the temporary opt-out. The workspace now applies that option in
`.cargo/config.toml`.

The Dockerfile previously set `RUSTFLAGS` for linker options, which
overrode the workspace configuration and re-enabled the global solver
inside image builds. It now uses `cargo rustc` to pass linker flags only
to the final binary, preserving the workspace solver setting for every
crate.

All rustc `#![recursion_limit = "256"]` attributes were removed. The
default limit passes with the coherence-only solver, confirming that a
higher recursion limit was unrelated to the CI termination.

## Validation

- `make build-workspace`
- `make clippy-check` (`--workspace --all-targets -- -D warnings`)
- `cargo check --locked -p synctv-api-http`
- `cargo check --locked -p synctv-api-common -p synctv-api-grpc`
- `cargo test -p synctv-api-common --lib` (655 passed, 165 Docker tests
ignored)
- `cargo test -p synctv-core metrics --lib` (9 passed)
- `make fmt-check`
- `docker build --check .`
- `make validate-helm`
- `actionlint .github/workflows/ci.yml .github/workflows/helm-ci.yml`
- `npm run validate` in `docs` (124 pages, 353 links, Astro 0
errors/warnings)
dependabot/github_actions/actions/cache-6
zijiren 4 weeks ago committed by GitHub
parent 7fb7f6c64f
commit 2445da8630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,2 +1,3 @@
[build] [build]
rustflags = ["-Zthreads=8", "-Zshare-generics=y"] # The nightly global next solver exceeds CI memory on synctv-api-http; see rust-lang/rust#161748.
rustflags = ["-Zthreads=8", "-Zshare-generics=y", "-Znext-solver=coherence"]

@ -39,7 +39,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
components: rustfmt components: rustfmt
- name: Check formatting - name: Check formatting
@ -55,7 +55,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
components: clippy components: clippy
- name: Install build dependencies - name: Install build dependencies
@ -63,6 +63,9 @@ jobs:
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
cache-workspace-crates: true
- name: Run Clippy - name: Run Clippy
timeout-minutes: 60 timeout-minutes: 60
@ -79,13 +82,16 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
cache-workspace-crates: true
- name: Build - name: Build
run: make build-workspace run: make build-workspace
@ -100,7 +106,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
@ -122,13 +128,16 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
cache-workspace-crates: true
- name: Check SQLx offline metadata - name: Check SQLx offline metadata
run: make check-all-targets run: make check-all-targets
@ -150,7 +159,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install cargo-nextest - name: Install cargo-nextest
uses: taiki-e/install-action@nextest uses: taiki-e/install-action@nextest
@ -173,6 +182,9 @@ jobs:
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
cache-workspace-crates: true
- name: Run non-ignored tests with nextest - name: Run non-ignored tests with nextest
timeout-minutes: 60 timeout-minutes: 60
@ -199,7 +211,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install cargo-nextest - name: Install cargo-nextest
uses: taiki-e/install-action@nextest uses: taiki-e/install-action@nextest
@ -210,6 +222,9 @@ jobs:
- name: Cache Rust build artifacts - name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
cache-workspace-crates: true
- name: Run ignored tests with nextest - name: Run ignored tests with nextest
timeout-minutes: 60 timeout-minutes: 60
@ -225,7 +240,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install cargo-audit - name: Install cargo-audit
run: make install-cargo-audit run: make install-cargo-audit
@ -248,7 +263,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install cargo-deny - name: Install cargo-deny
run: make install-cargo-deny run: make install-cargo-deny
@ -282,11 +297,17 @@ jobs:
- name: Install Rust nightly - name: Install Rust nightly
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
cache-workspace-crates: true
- name: Install cargo-udeps - name: Install cargo-udeps
run: make install-cargo-udeps run: make install-cargo-udeps

@ -82,7 +82,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install Flutter - name: Install Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2

@ -47,11 +47,18 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
shared-key: build
cache-on-failure: true
cache-workspace-crates: true
- name: Validate chart - name: Validate chart
run: make validate-helm run: make validate-helm

@ -41,7 +41,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev

@ -30,7 +30,7 @@ jobs:
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21 toolchain: nightly-2026-08-25
- name: Normalize release version - name: Normalize release version
id: version id: version

@ -96,10 +96,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
if [ -n "$SYNCTV_BUILD_FEATURES" ]; then \ if [ -n "$SYNCTV_BUILD_FEATURES" ]; then \
build_flags="$build_flags --features $SYNCTV_BUILD_FEATURES"; \ build_flags="$build_flags --features $SYNCTV_BUILD_FEATURES"; \
fi; \ fi; \
RUSTFLAGS="-Clink-arg=-fuse-ld=lld -Clink-arg=-Wl,-z,pack-relative-relocs" \
cargo \ cargo \
build $build_flags \ rustc $build_flags \
--bin synctv && \ -p synctv \
--bin synctv \
-- \
-Clink-arg=-fuse-ld=lld \
-Clink-arg=-Wl,-z,pack-relative-relocs && \
cp "target/$target_profile_dir/synctv" /synctv cp "target/$target_profile_dir/synctv" /synctv
# Stage 2: Runtime image # Stage 2: Runtime image

@ -373,7 +373,7 @@ clippy: ## Apply Clippy fixes, then require a clean workspace lint pass.
SQLX_OFFLINE=true $(CARGO) clippy $(CARGO_WORKSPACE_ALL_TARGETS_BUILD_ARGS) --fix --allow-dirty SQLX_OFFLINE=true $(CARGO) clippy $(CARGO_WORKSPACE_ALL_TARGETS_BUILD_ARGS) --fix --allow-dirty
clippy-check: ## Run locked workspace Clippy checks without modifying files. clippy-check: ## Run locked workspace Clippy checks without modifying files.
SQLX_OFFLINE=true $(CARGO) clippy $(CARGO_WORKSPACE_ALL_TARGETS_BUILD_ARGS) SQLX_OFFLINE=true $(CARGO) clippy $(CARGO_WORKSPACE_ALL_TARGETS_BUILD_ARGS) -- -D warnings
install-cargo-audit: ## Install cargo-audit for CI security checks. install-cargo-audit: ## Install cargo-audit for CI security checks.
$(CARGO) install cargo-audit $(CARGO_LOCKED) $(CARGO) install cargo-audit $(CARGO_LOCKED)

@ -14,6 +14,8 @@ metrics:
生产环境可以开启但不要直接暴露公网。Linux 构建会把进程级指标和业务指标放在同一个 registry 中暴露。 生产环境可以开启但不要直接暴露公网。Linux 构建会把进程级指标和业务指标放在同一个 registry 中暴露。
metrics listener 启动时会校验并注册全部指标定义。重复或无效定义会中止启动,避免端点静默暴露不完整的 registry。抓取时发生编码错误会返回 HTTP `500`;应在 Prometheus 中配置抓取失败告警。
## 常用示例 ## 常用示例
Bearer token 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. 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 ## Common Examples
Bearer token: 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. Disabled or unused features may not emit their metrics. Do not expose the metrics listener directly to the public internet.
</Aside> </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 ## HTTP And WebSocket
| Metric | Type | Labels | Meaning | | 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_active` | gauge | none | Active DB connections |
| `db_connections_idle` | gauge | none | Idle 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 | | `db_pool_utilization_ratio` | gauge | `pool` | Pool utilization, from 0 to 1 |
| `cache_hits_total` | counter | `cache_type`, `level` | Cache hits | | `cache_hits_total` | counter | `cache_type`, `level` | Cache hits |
| `cache_misses_total` | counter | `cache_type`, `level` | Cache misses | | `cache_misses_total` | counter | `cache_type`, `level` | Cache misses |
| `cache_evictions_total` | counter | `cache_type` | Cache evictions | | `cache_evictions_total` | counter | `cache_type` | Cache evictions |
| `cache_errors_total` | counter | `cache_type`, `operation` | Cache operation errors | | `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 ## 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 | | `webrtc_peers_active` | gauge | none | Active WebRTC peers |
| `active_connections` | gauge | none | Active connections | | `active_connections` | gauge | none | Active connections |
| `spawned_task_panics_total` | counter | `task_name` | Background task panics caught by `spawn_monitored` | | `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_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_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` | | `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_epoch` | gauge | none | Current leader epoch |
| `synctv_cluster_leader_election_consecutive_failures` | gauge | none | Consecutive leader election failures | | `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_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 ## 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 | | `active_relay_streams` | gauge | none | Active relay streams |
| `stream_errors_total` | counter | `stream_type`, `error_type` | Stream errors | | `stream_errors_total` | counter | `stream_type`, `error_type` | Stream errors |
| `streamhub_restarts_total` | counter | `reason` | StreamHub event loop restarts | | `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_publishers` | gauge | none | Active livestream publishers |
| `livestream_active_viewers` | gauge | none | Active livestream viewers | | `livestream_active_viewers` | gauge | none | Active livestream viewers |
| `livestream_relay_frame_drops_total` | counter | none | Relay frame drops caused by backpressure | | `livestream_relay_frame_drops_total` | counter | none | Relay frame drops caused by backpressure |

@ -19,6 +19,13 @@ curl -fsS \
没有启用的功能可能不会产生对应指标。不要把 metrics listener 直接暴露公网。 没有启用的功能可能不会产生对应指标。不要把 metrics listener 直接暴露公网。
</Aside> </Aside>
## 埋点契约
- metrics listener 启动时会注册全部指标定义。无效或重复定义会中止启动,避免暴露不完整的 registry。
- Label 只能使用有限集合。HTTP `path` 使用 Axum 路由模板,例如 `/api/rooms/{room_id}`;不得包含资源 ID、query string 或原始错误消息。
- 现有指标名和 label 集合属于兼容接口。修改前必须检查 dashboard 和 alert。
- vector 指标可能在对应功能首次记录带 label 的样本前保持缺失。
## HTTP 和 WebSocket ## HTTP 和 WebSocket
| 指标 | 类型 | Labels | 含义 | | 指标 | 类型 | Labels | 含义 |
@ -38,11 +45,28 @@ curl -fsS \
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `db_connections_active` | gauge | none | 活跃数据库连接 | | `db_connections_active` | gauge | none | 活跃数据库连接 |
| `db_connections_idle` | gauge | none | 空闲数据库连接 | | `db_connections_idle` | gauge | none | 空闲数据库连接 |
| `db_pool_size_max` | gauge | none | 所有数据库连接池配置的最大连接数 |
| `db_pool_utilization_ratio` | gauge | `pool` | 连接池利用率,取值 0 到 1 | | `db_pool_utilization_ratio` | gauge | `pool` | 连接池利用率,取值 0 到 1 |
| `cache_hits_total` | counter | `cache_type`, `level` | 缓存命中数 | | `cache_hits_total` | counter | `cache_type`, `level` | 缓存命中数 |
| `cache_misses_total` | counter | `cache_type`, `level` | 缓存未命中数 | | `cache_misses_total` | counter | `cache_type`, `level` | 缓存未命中数 |
| `cache_evictions_total` | counter | `cache_type` | 缓存淘汰数 | | `cache_evictions_total` | counter | `cache_type` | 缓存淘汰数 |
| `cache_errors_total` | counter | `cache_type`, `operation` | 缓存操作错误数 | | `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 | | `webrtc_peers_active` | gauge | none | 活跃 WebRTC peer |
| `active_connections` | gauge | none | 活跃连接 | | `active_connections` | gauge | none | 活跃连接 |
| `spawned_task_panics_total` | counter | `task_name` | `spawn_monitored` 捕获的后台任务 panic | | `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_queue_depth` | gauge | none | PostgreSQL outbox 中等待投递的邮件任务数 |
| `email_delivery_in_flight` | gauge | none | 当前实例正在投递的邮件任务数 | | `email_delivery_in_flight` | gauge | none | 当前实例正在投递的邮件任务数 |
| `email_delivery_jobs_total` | counter | `kind`, `status` | 邮件任务结果数;状态包括 `sent`、`retry`、`dead`、`superseded`、`fenced`、`persist_failed` 和 `ack_failed` | | `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_epoch` | gauge | none | 当前 leader epoch |
| `synctv_cluster_leader_election_consecutive_failures` | gauge | none | 连续选主失败数 | | `synctv_cluster_leader_election_consecutive_failures` | gauge | none | 连续选主失败数 |
| `synctv_cluster_epoch_mismatch_quarantine` | gauge | none | epoch mismatch 隔离状态 | | `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 | | `active_relay_streams` | gauge | none | 活跃 relay stream |
| `stream_errors_total` | counter | `stream_type`, `error_type` | stream 错误数 | | `stream_errors_total` | counter | `stream_type`, `error_type` | stream 错误数 |
| `streamhub_restarts_total` | counter | `reason` | StreamHub event loop 重启次数 | | `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_publishers` | gauge | none | 活跃直播 publisher |
| `livestream_active_viewers` | gauge | none | 活跃直播 viewer | | `livestream_active_viewers` | gauge | none | 活跃直播 viewer |
| `livestream_relay_frame_drops_total` | counter | none | backpressure 导致的 relay 丢帧 | | `livestream_relay_frame_drops_total` | counter | none | backpressure 导致的 relay 丢帧 |

@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "nightly-2026-08-21" channel = "nightly-2026-08-25"
profile = "minimal" profile = "minimal"
components = ["clippy", "rustfmt"] components = ["clippy", "rustfmt"]

@ -348,7 +348,7 @@ run_rendered_synctv_config_validation() {
[ -z "${RUSTUP_HOME:-}" ] || validation_env+=("RUSTUP_HOME=$RUSTUP_HOME") [ -z "${RUSTUP_HOME:-}" ] || validation_env+=("RUSTUP_HOME=$RUSTUP_HOME")
[ -z "${CARGO_TARGET_DIR:-}" ] || validation_env+=("CARGO_TARGET_DIR=$CARGO_TARGET_DIR") [ -z "${CARGO_TARGET_DIR:-}" ] || validation_env+=("CARGO_TARGET_DIR=$CARGO_TARGET_DIR")
env -i "${validation_env[@]}" \ env -i "${validation_env[@]}" \
cargo run -q -p synctv --bin synctv -- --no-dotenv --config "$rendered_config" config validate --strict cargo run -p synctv --bin synctv -- --no-dotenv --config "$rendered_config" config validate --strict
} }
validate_rendered_synctv_config() { validate_rendered_synctv_config() {

@ -291,8 +291,6 @@ impl AdminApiImpl {
let mut phase = job.phase; let mut phase = job.phase;
let mut deleted_messages = job.deleted_messages; let mut deleted_messages = job.deleted_messages;
let mut deleted_reactions = job.deleted_reactions; let mut deleted_reactions = job.deleted_reactions;
let mut explicit_message_done = job.explicit_message_done;
let mut ban_done = job.ban_done;
let mut snapshot_at = job.snapshot_at; let mut snapshot_at = job.snapshot_at;
let (message_cursor, reaction_cursor, hidden_reaction_cursor) = ( let (message_cursor, reaction_cursor, hidden_reaction_cursor) = (
job.message_cursor, job.message_cursor,
@ -308,7 +306,7 @@ impl AdminApiImpl {
lock_version: job.lock_version, lock_version: job.lock_version,
}; };
if let Some(message_id) = job.message_id.filter(|_| !explicit_message_done) { if let Some(message_id) = job.message_id.filter(|_| !job.explicit_message_done) {
let outcome = chat_service let outcome = chat_service
.delete_moderation_message_event_outcome_as_admin_with_progress( .delete_moderation_message_event_outcome_as_admin_with_progress(
&job.room_id, &job.room_id,
@ -336,10 +334,10 @@ impl AdminApiImpl {
dispatcher.dispatch_pin(pin_event); dispatcher.dispatch_pin(pin_event);
} }
} }
explicit_message_done = true; next.explicit_message_done = true;
} }
if job.ban_user && !ban_done { if job.ban_user && !job.ban_done {
let newly_banned = self let newly_banned = self
.ensure_persisted_user_banned_with_cleanup( .ensure_persisted_user_banned_with_cleanup(
&job.target_user_id, &job.target_user_id,
@ -372,7 +370,7 @@ impl AdminApiImpl {
tracing::error!(error = %error, job_id = %job.id, "Failed to write async chat moderation ban audit log"); tracing::error!(error = %error, job_id = %job.id, "Failed to write async chat moderation ban audit log");
} }
} }
ban_done = true; next.ban_done = true;
snapshot_at = self.clock.now(); snapshot_at = self.clock.now();
} }

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

@ -1,5 +1,3 @@
#![recursion_limit = "256"]
#[cfg(all(feature = "tls-aws-lc", feature = "tls-ring"))] #[cfg(all(feature = "tls-aws-lc", feature = "tls-ring"))]
compile_error!("features \"tls-aws-lc\" and \"tls-ring\" are mutually exclusive - use only one"); compile_error!("features \"tls-aws-lc\" and \"tls-ring\" are mutually exclusive - use only one");

@ -4,12 +4,14 @@
//! unified registry. //! unified registry.
pub use synctv_core::metrics::http::{ 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::{ 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::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) { fn record_grpc_metrics(route: &str, grpc_code: i32, grpc_status: &str, elapsed: Duration) {
let (service, method) = grpc_metric_labels(route, grpc_code); let (service, method) = grpc_metric_labels(route, grpc_code);
metrics::REMOTE_TRANSPORT_REQUESTS_TOTAL metrics::record_remote_transport_request(service, method, grpc_status, elapsed);
.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());
} }
fn grpc_metric_labels(route: &str, grpc_code: i32) -> (&str, &str) { fn grpc_metric_labels(route: &str, grpc_code: i32) -> (&str, &str) {

@ -1,5 +1,3 @@
#![recursion_limit = "256"]
pub mod grpc; pub mod grpc;
pub mod grpc_support; pub mod grpc_support;
pub(crate) mod providers; pub(crate) mod providers;

@ -37,6 +37,7 @@ pub fn create_health_router() -> Router<AppState> {
/// Dedicated metrics router. /// Dedicated metrics router.
pub fn create_metrics_router() -> Router<AppState> { pub fn create_metrics_router() -> Router<AppState> {
metrics::initialize();
Router::new().route("/metrics", get(prometheus_metrics)) Router::new().route("/metrics", get(prometheus_metrics))
} }
@ -595,14 +596,28 @@ pub async fn prometheus_metrics(
} }
} }
( match metrics::gather_metrics() {
[( Ok(body) => (
axum::http::header::CONTENT_TYPE, [(
"text/plain; version=0.0.4; charset=utf-8", axum::http::header::CONTENT_TYPE,
)], "text/plain; version=0.0.4; charset=utf-8",
metrics::gather_metrics(), )],
) body,
.into_response() )
.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)] #[cfg(test)]

@ -9,21 +9,6 @@ use std::time::Instant;
use synctv_api_common::observability::metrics; 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. /// Middleware that records HTTP request count, duration, and in-flight gauge.
pub async fn metrics_layer(request: Request, next: Next) -> Response { pub async fn metrics_layer(request: Request, next: Next) -> Response {
let method = request.method().to_string(); 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(), |path| path.as_str().to_string(),
); );
let _in_flight = InFlightRequestGuard::new(); let _in_flight = metrics::start_request();
let start = Instant::now(); let start = Instant::now();
let response = next.run(request).await; let response = next.run(request).await;
let duration = start.elapsed().as_secs_f64(); metrics::record_request(&method, &path, response.status().as_u16(), start.elapsed());
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);
response response
} }
@ -77,7 +54,8 @@ mod tests {
.expect("request should complete"); .expect("request should complete");
assert_eq!(response.status(), StatusCode::OK); 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( assert!(output.contains(
"http_requests_total{method=\"GET\",path=\"/items/{item_id}\",status=\"200\"}" "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 SLOW_CLIENT_DROP_THRESHOLD: u32 = 10;
const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeTransportFormat { pub(crate) enum RealtimeTransportFormat {
Json, Json,
@ -958,9 +908,7 @@ impl synctv_api_common::impls::messaging::MessageSender for WebSocketMessageSend
message_type = msg_type, message_type = msg_type,
"Critical WebSocket message rejected: critical queue full (slow client)" "Critical WebSocket message rejected: critical queue full (slow client)"
); );
synctv_core::metrics::http::WEBSOCKET_ERRORS_TOTAL synctv_core::metrics::http::record_websocket_error("message_dropped_critical");
.with_label_values(&["message_dropped_critical"])
.inc();
return Err(format!( return Err(format!(
"Critical message (type={msg_type}) rejected: critical queue full after {drops} consecutive drops (slow client)" "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, message_type = msg_type,
"WebSocket message dropped: channel full (slow client)" "WebSocket message dropped: channel full (slow client)"
); );
synctv_core::metrics::http::WEBSOCKET_ERRORS_TOTAL synctv_core::metrics::http::record_websocket_error("message_dropped");
.with_label_values(&["message_dropped"])
.inc();
if requires_resync || drops >= SLOW_CLIENT_DROP_THRESHOLD { if requires_resync || drops >= SLOW_CLIENT_DROP_THRESHOLD {
// Too many consecutive drops: disconnect the slow client gracefully // Too many consecutive drops: disconnect the slow client gracefully
Err(format!( Err(format!(
@ -1326,7 +1272,7 @@ async fn handle_socket(
let event_service = state.event_service.clone(); 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 // Use the shared rate limiter from app state
let rate_limiter = state.rate_limiter.clone(); let rate_limiter = state.rate_limiter.clone();

@ -1,5 +1,3 @@
#![recursion_limit = "256"]
pub mod http; pub mod http;
#[cfg(feature = "openapi")] #[cfg(feature = "openapi")]
pub mod openapi; pub mod openapi;

@ -70,25 +70,23 @@ impl ClusterService for ClusterServer {
match result { match result {
Ok(nodes) => { Ok(nodes) => {
let elapsed = start.elapsed().as_secs_f64(); synctv_core::metrics::remote_transport::record(
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUEST_DURATION "cluster",
.with_label_values(&["cluster", "get_nodes", "ok"]) "get_nodes",
.observe(elapsed); "ok",
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUESTS_TOTAL start.elapsed(),
.with_label_values(&["cluster", "get_nodes", "ok"]) );
.inc();
let proto_nodes = nodes.iter().map(Self::discovery_to_proto_node).collect(); let proto_nodes = nodes.iter().map(Self::discovery_to_proto_node).collect();
Ok(Response::new(GetNodesResponse { nodes: proto_nodes })) Ok(Response::new(GetNodesResponse { nodes: proto_nodes }))
} }
Err(error) => { Err(error) => {
let elapsed = start.elapsed().as_secs_f64(); synctv_core::metrics::remote_transport::record(
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUEST_DURATION "cluster",
.with_label_values(&["cluster", "get_nodes", "error"]) "get_nodes",
.observe(elapsed); "error",
synctv_core::metrics::remote_transport::REMOTE_TRANSPORT_REQUESTS_TOTAL start.elapsed(),
.with_label_values(&["cluster", "get_nodes", "error"]) );
.inc();
tracing::error!("Failed to get nodes from cluster registry: {error}"); tracing::error!("Failed to get nodes from cluster registry: {error}");
Err(Status::unavailable(error.to_string())) 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_max_message_size(grpc_max_message_size_bytes)
.with_grpc_compression(grpc_compression_enabled); .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 // Race the puller against cancellation and periodic lease_epoch re-validation
let mut epoch_interval = let mut epoch_interval =
tokio::time::interval(retry_policy.epoch_revalidation_interval); tokio::time::interval(retry_policy.epoch_revalidation_interval);
// Skip the first immediate tick // Skip the first immediate tick
epoch_interval.tick().await; epoch_interval.tick().await;
let run_result = tokio::select! { let run_result = {
r = grpc_puller.run(&data_sender) => r, let _relay_metrics = synctv_core::metrics::stream::track_relay(
() = child_token.cancelled() => { synctv_core::metrics::stream::RelayProtocol::Rtmp,
info!("gRPC puller task cancelled for {} / {}", room_id, media_id); );
timer.observe_duration();
synctv_core::metrics::stream::ACTIVE_RELAY_STREAMS.dec(); tokio::select! {
break Ok(()); r = grpc_puller.run(&data_sender) => r,
} () = child_token.cancelled() => {
() = async { info!("gRPC puller task cancelled for {} / {}", room_id, media_id);
loop { break Ok(());
epoch_interval.tick().await; }
match registry () = async {
.validate_lease( loop {
&room_id, epoch_interval.tick().await;
&media_id, match registry
&source_generation_id, .validate_lease(
lease_epoch, &room_id,
) &media_id,
.await &source_generation_id,
{ lease_epoch,
Ok(true) => { )
// Reset failure counter on success. .await
consecutive_epoch_failures = 0; {
debug!( Ok(true) => {
"Periodic lease_epoch {} still valid for {}/{}", // Reset failure counter on success.
lease_epoch, room_id, media_id consecutive_epoch_failures = 0;
); debug!(
} "Periodic lease_epoch {} still valid for {}/{}",
Ok(false) => { lease_epoch, room_id, media_id
warn!( );
"Periodic lease_epoch re-validation: lease_epoch {} is stale for {}/{}, publisher changed", }
lease_epoch, room_id, media_id Ok(false) => {
); warn!(
return; "Periodic lease_epoch re-validation: lease_epoch {} is stale for {}/{}, publisher changed",
} lease_epoch, room_id, media_id
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; return;
} }
warn!( Err(e) => {
"Periodic lease_epoch re-validation failed for {}/{}: {} ({}/{} consecutive failures). Continuing.", // Track consecutive failures instead of unconditional fail-open.
room_id, media_id, e, consecutive_epoch_failures, retry_policy.max_consecutive_epoch_failures 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 { match run_result {
Ok(()) => break Ok(()), Ok(()) => break Ok(()),
Err(e) => { Err(e) => {
let err_str = e.to_string(); let err_str = e.to_string();
let error_type = if err_str.contains("timeout") { synctv_core::metrics::stream::record_error(
"timeout" synctv_core::metrics::stream::RelayProtocol::Rtmp,
} else if err_str.contains("connection") { &err_str,
"connection" );
} else {
"other"
};
synctv_core::metrics::stream::STREAM_ERRORS
.with_label_values(&["rtmp", error_type])
.inc();
rebuild_count += 1; rebuild_count += 1;
if rebuild_count > retry_policy.max_rebuilds { if rebuild_count > retry_policy.max_rebuilds {

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

Loading…
Cancel
Save