feat: global logging

pull/370/head
zijiren233 2 months ago
parent e7910ada54
commit fe820521ae
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -160,30 +160,71 @@ SYNCTV_JWT_CLOCK_SKEW_LEEWAY_SECS=60
# LOGGING CONFIGURATION
# ============================================================================
# File paths are resolved from SYNCTV_DATA_DIR. Setting OUTPUT_PATH selects
# file output; rotation values apply to file output only.
SYNCTV_LOGGING_LEVEL=info
SYNCTV_LOGGING_FORMAT=text
SYNCTV_LOGGING_OUTPUT=stdout
SYNCTV_LOGGING_COLOR=auto
# SYNCTV_LOGGING_OUTPUT_PATH=logs/global.log
# SYNCTV_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_SERVER_LOGGING_LEVEL=info
SYNCTV_SERVER_LOGGING_FORMAT=text
SYNCTV_SERVER_LOGGING_OUTPUT=stdout
SYNCTV_SERVER_LOGGING_COLOR=auto
# SYNCTV_SERVER_LOGGING_OUTPUT_PATH=logs/server.log
# SYNCTV_SERVER_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_SERVER_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_HEALTH_LOGGING_LEVEL=info
SYNCTV_HEALTH_LOGGING_FORMAT=text
SYNCTV_HEALTH_LOGGING_OUTPUT=stdout
SYNCTV_HEALTH_LOGGING_COLOR=auto
# SYNCTV_HEALTH_LOGGING_OUTPUT_PATH=logs/health.log
# SYNCTV_HEALTH_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_HEALTH_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_METRICS_LOGGING_LEVEL=warn
SYNCTV_METRICS_LOGGING_FORMAT=text
SYNCTV_METRICS_LOGGING_OUTPUT=stdout
SYNCTV_METRICS_LOGGING_COLOR=auto
# SYNCTV_METRICS_LOGGING_OUTPUT_PATH=logs/metrics.log
# SYNCTV_METRICS_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_METRICS_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_CLUSTER_LOGGING_LEVEL=warn
SYNCTV_CLUSTER_LOGGING_FORMAT=text
SYNCTV_CLUSTER_LOGGING_OUTPUT=stdout
SYNCTV_CLUSTER_LOGGING_COLOR=auto
# SYNCTV_CLUSTER_LOGGING_OUTPUT_PATH=logs/cluster.log
# SYNCTV_CLUSTER_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_CLUSTER_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_MANAGEMENT_LOGGING_LEVEL=info
SYNCTV_MANAGEMENT_LOGGING_FORMAT=text
SYNCTV_MANAGEMENT_LOGGING_OUTPUT=stdout
SYNCTV_MANAGEMENT_LOGGING_COLOR=auto
# SYNCTV_MANAGEMENT_LOGGING_OUTPUT_PATH=logs/management.log
# SYNCTV_MANAGEMENT_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_MANAGEMENT_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_LIVESTREAM_LOGGING_LEVEL=info
SYNCTV_LIVESTREAM_LOGGING_FORMAT=text
SYNCTV_LIVESTREAM_LOGGING_OUTPUT=stdout
SYNCTV_LIVESTREAM_LOGGING_COLOR=auto
# SYNCTV_LIVESTREAM_LOGGING_OUTPUT_PATH=logs/livestream.log
# SYNCTV_LIVESTREAM_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_LIVESTREAM_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
SYNCTV_WEBRTC_LOGGING_LEVEL=info
SYNCTV_WEBRTC_LOGGING_FORMAT=text
SYNCTV_WEBRTC_LOGGING_OUTPUT=stdout
SYNCTV_WEBRTC_LOGGING_COLOR=auto
# SYNCTV_WEBRTC_LOGGING_OUTPUT_PATH=logs/webrtc.log
# SYNCTV_WEBRTC_LOGGING_OUTPUT_ROTATION_STRATEGY=daily
# SYNCTV_WEBRTC_LOGGING_OUTPUT_ROTATION_MAX_FILES=30
# ============================================================================
# WEBRTC CONFIGURATION

@ -82,7 +82,10 @@ set +a; \
export SYNCTV_DATA_DIR="$(DEV_DATA_DIR)"; \
export SYNCTV_DATABASE_URL="$(DEV_DATABASE_URL)"; \
export SYNCTV_REDIS_URL="$(DEV_REDIS_URL)"; \
export SYNCTV_LOGGING_LEVEL="$${SYNCTV_LOGGING_LEVEL:-debug}"; \
export SYNCTV_SERVER_LOGGING_LEVEL="$${SYNCTV_SERVER_LOGGING_LEVEL:-debug}"; \
export SYNCTV_LIVESTREAM_LOGGING_LEVEL="$${SYNCTV_LIVESTREAM_LOGGING_LEVEL:-debug}"; \
export SYNCTV_WEBRTC_LOGGING_LEVEL="$${SYNCTV_WEBRTC_LOGGING_LEVEL:-debug}"; \
export SYNCTV_SERVER_HOST=0.0.0.0; \
export SYNCTV_SERVER_PORT=8080; \
export SYNCTV_SERVER_CORS_ALLOWED_ORIGINS='$(DEV_CORS_ORIGINS)'; \

@ -64,6 +64,12 @@ curl -fsS http://localhost:8081/health/ready
</Aside>
```yaml
logging:
level: "info"
format: "text"
output: stdout
color: auto
server:
host: "0.0.0.0"
port: 8080
@ -175,6 +181,12 @@ metrics:
模板同时列出 secret 直填字段和 `*_file` 字段,用于展示层级。生产配置使用 `*_file` 或环境变量注入。
```yaml
logging:
level: "info"
format: "text"
output: stdout
color: auto
server:
host: "0.0.0.0"
port: 8080
@ -318,6 +330,11 @@ jwt:
livestream:
rtmp_port: 1935
public_rtmp_host: "stream.example.com"
logging:
level: "info"
format: "text"
output: stdout
color: auto
gop_cache_size: 2
stream_timeout_seconds: 300
cleanup_check_interval_seconds: 60
@ -383,6 +400,11 @@ webrtc:
stun_host: "0.0.0.0"
stun_external_addr: ""
filter_private_ice_candidates: false
logging:
level: "info"
format: "text"
output: stdout
color: auto
connection_limits:
max_per_user: 20

@ -115,7 +115,8 @@ export SYNCTV_JWT_SECRET_FILE=/run/secrets/jwt_secret
会受 `data_dir` 影响的路径:
- `management.unix_socket_path`
- `<component>.logging.output.path` for `server`, `health`, `metrics`, `cluster`, and `management`
- `logging.output.path`
- `<component>.logging.output.path`,其中 `<component>` 为 `server`、`health`、`metrics`、`management`、`cluster`、`livestream` 或 `webrtc`
- `livestream.hls_storage.path`
- `proxy_slice_cache.file_cache_dir`

@ -143,8 +143,14 @@ data_dir: "/var/lib/synctv"
## `logging`
顶层 `logging` 是全局兜底输出覆盖数据库、Redis、缓存、代理、realtime、outbox、迁移、启动流程、第三方依赖和其余 target。`server`、`health`、`metrics`、`management`、`cluster`、`livestream`、`webrtc` 使用各自的专属输出。每条事件只进入一个输出:专属 target 进入对应组件,其余 target 进入全局输出。
| 字段 | 默认值 | 用途 |
| --- | --- | --- |
| `logging.level` | `info` | 全局日志级别 |
| `logging.format` | `text` | 全局日志格式(`text` 或 `json` |
| `logging.output` | `stdout` | 全局 `stdout`、`stderr` 或文件对象 |
| `logging.color` | `auto` | 全局颜色策略(`auto`、`always` 或 `never` |
| `<component>.logging.level` | `info`metrics/cluster 为 `warn` | 组件日志级别 |
| `<component>.logging.format` | `text` | 组件日志格式(`text` 或 `json` |
| `<component>.logging.output` | `stdout` | 组件独立的 `stdout`、`stderr` 或文件对象 |
@ -153,37 +159,40 @@ data_dir: "/var/lib/synctv"
Kubernetes 或日志采集系统使用 JSON
```yaml
server:
logging:
level: "info"
format: "text"
output: stdout
color: auto
logging:
level: "info"
format: "json"
output: stdout
color: never
health:
server:
logging:
level: "info"
format: "json"
output: stdout
color: never
metrics:
livestream:
logging:
level: "warn"
level: "info"
format: "json"
output:
type: file
path: "logs/metrics.log"
path: "logs/livestream.log"
rotation:
strategy: daily
max_files: 30
color: never
```
`server`、`health`、`metrics`、`cluster` 和 `management` 分别构建独立日志 layer
各自的级别、格式、输出和文件轮转配置单独生效。
webrtc:
logging:
level: "info"
format: "json"
output: stdout
color: never
```
各组件的相对日志文件路径按 `data_dir` 解析。`rotation.max_files` 限制组件保留的日志文件数量,默认保留 30 个。容器中通常输出到 stdout。
全局和组件日志的相对文件路径都按 `data_dir` 解析。文件输出支持 `daily`、`hourly` 和 `never` 三种轮转策略;`rotation.max_files` 默认保留 30 个文件。容器环境通常把所有目标输出到 stdout由日志采集系统按 JSON 字段处理
## `management`

@ -64,6 +64,12 @@ Production Compose users should maintain `.env.postgres`, `.env.redis`, and `.en
</Aside>
```yaml
logging:
level: "info"
format: "text"
output: stdout
color: auto
server:
host: "0.0.0.0"
port: 8080
@ -175,6 +181,12 @@ This template covers all static configuration fields in the current version. Pro
The template lists both inline secret fields and `*_file` fields to show hierarchy. Production configuration should use `*_file` or environment injection.
```yaml
logging:
level: "info"
format: "text"
output: stdout
color: auto
server:
host: "0.0.0.0"
port: 8080
@ -318,6 +330,11 @@ jwt:
livestream:
rtmp_port: 1935
public_rtmp_host: "stream.example.com"
logging:
level: "info"
format: "text"
output: stdout
color: auto
gop_cache_size: 2
stream_timeout_seconds: 300
cleanup_check_interval_seconds: 60
@ -383,6 +400,11 @@ webrtc:
stun_host: "0.0.0.0"
stun_external_addr: ""
filter_private_ice_candidates: false
logging:
level: "info"
format: "text"
output: stdout
color: auto
connection_limits:
max_per_user: 20

@ -119,7 +119,8 @@ Relative `*_file` paths are resolved relative to the configuration file director
Paths affected by `data_dir`:
- `management.unix_socket_path`
- `<component>.logging.output.path` for `server`, `health`, `metrics`, `cluster`, and `management`
- `logging.output.path`
- `<component>.logging.output.path`, where `<component>` is `server`, `health`, `metrics`, `management`, `cluster`, `livestream`, or `webrtc`
- `livestream.hls_storage.path`
- `proxy_slice_cache.file_cache_dir`

@ -171,8 +171,14 @@ Container deployments usually use `/data` and mount it as a volume.
## Logging
Top-level `logging` is the global catch-all output for the database, Redis, caches, proxies, realtime, outbox, migrations, startup, third-party dependencies, and all remaining targets. `server`, `health`, `metrics`, `management`, `cluster`, `livestream`, and `webrtc` use dedicated outputs. Each event enters exactly one output: dedicated targets enter their component output, and every remaining target enters the global output.
| Field | Default | Purpose |
| --- | --- | --- |
| `logging.level` | `info` | Global log level |
| `logging.format` | `text` | Global log format (`text` or `json`) |
| `logging.output` | `stdout` | Global `stdout`, `stderr`, or file output |
| `logging.color` | `auto` | Global color policy (`auto`, `always`, or `never`) |
| `<component>.logging.level` | `info` (`warn` for metrics/cluster) | Component log level |
| `<component>.logging.format` | `text` | Component log format (`text` or `json`) |
| `<component>.logging.output` | `stdout` | Independent `stdout`, `stderr`, or file output |
@ -181,38 +187,40 @@ Container deployments usually use `/data` and mount it as a volume.
Use `json` in Kubernetes or log aggregation systems:
```yaml
server:
logging:
level: "info"
format: "text"
output: stdout
color: auto
logging:
level: "info"
format: "json"
output: stdout
color: never
health:
server:
logging:
level: "info"
format: "json"
output: stdout
color: never
metrics:
livestream:
logging:
level: "warn"
level: "info"
format: "json"
output:
type: file
path: "logs/metrics.log"
path: "logs/livestream.log"
rotation:
strategy: daily
max_files: 30
color: never
webrtc:
logging:
level: "info"
format: "json"
output: stdout
color: never
```
`server`, `health`, `metrics`, `cluster`, and `management` each build an independent
logging layer. Their levels, formats, outputs, and file rotation settings apply
independently. Relative component log paths resolve under `data_dir`.
`rotation.max_files` limits retained files per component and defaults to 30.
Containers usually write component logs to stdout.
Relative file paths for global and component logs resolve under `data_dir`. File outputs support `daily`, `hourly`, and `never` rotation strategies; `rotation.max_files` retains 30 files by default. Containers commonly send every output to stdout for JSON-aware log collection.
## Management Endpoint

@ -198,6 +198,8 @@ Use the field index below for bare-metal, Kubernetes, secret files, or custom YA
## `jwt` and `logging`
Top-level `logging` receives logs from the database, Redis, caches, proxies, realtime, outbox, migrations, startup, third-party dependencies, and all remaining targets. `<component>` is one of `server`, `health`, `metrics`, `management`, `cluster`, `livestream`, or `webrtc`; each component has an independent output.
| Field | Type / values | Default | Meaning |
| --- | --- | --- | --- |
| `jwt.secret` | secret string | `change-me-in-production` | Token signing secret |
@ -205,9 +207,17 @@ Use the field index below for bare-metal, Kubernetes, secret files, or custom YA
| `jwt.refresh_token_duration_days` | days | `30` | Refresh-token lifetime |
| `jwt.guest_token_duration_hours` | hours | `4` | Guest-token lifetime |
| `jwt.clock_skew_leeway_secs` | seconds | `60` | Token timestamp skew allowance |
| `logging.level` | `error`, `warn`, `info`, `debug`, or `trace` | `info` | Global log level |
| `logging.format` | `text` or `json` | `text` | Global log format |
| `logging.output` | `stdout`, `stderr`, or a file object | `stdout` | Global log output |
| `logging.output.path` | file path | `""` | Global log file path; relative paths resolve under `data_dir` |
| `logging.output.rotation.strategy` | `daily`, `hourly`, or `never` | `daily` | Global log file rotation period |
| `logging.output.rotation.max_files` | positive integer | `30` | Global log files retained |
| `logging.color` | `auto`, `always`, or `never` | `auto` | Global color output |
| `<component>.logging.level` | `error`, `warn`, `info`, `debug`, or `trace` | `info` (`warn` for metrics/cluster) | Component log level |
| `<component>.logging.format` | `text` or `json` | `text` | Component log format |
| `<component>.logging.output` | `stdout`, `stderr`, or a file object | `stdout` | Independent component output |
| `<component>.logging.output.path` | file path | `""` | Component log file path; relative paths resolve under `data_dir` |
| `<component>.logging.output.rotation.strategy` | `daily`, `hourly`, or `never` | `daily` | File rotation period |
| `<component>.logging.output.rotation.max_files` | positive integer | `30` | Files retained per component |
| `<component>.logging.color` | `auto`, `always`, or `never` | `auto` | Component color output |

@ -146,6 +146,13 @@ SYNCTV_FILE_STORAGE_BACKENDS='{"s3_public":{"type":"s3","endpoint":"https://s3.e
| `SYNCTV_HEALTH_ENABLED` | `health.enabled` |
| `SYNCTV_HEALTH_HOST` | `health.host` |
| `SYNCTV_HEALTH_PORT` | `health.port` |
| `SYNCTV_LOGGING_LEVEL` | `logging.level` |
| `SYNCTV_LOGGING_FORMAT` | `logging.format` |
| `SYNCTV_LOGGING_OUTPUT` | `logging.output` |
| `SYNCTV_LOGGING_COLOR` | `logging.color` |
| `SYNCTV_LOGGING_OUTPUT_PATH` | `logging.output.path` |
| `SYNCTV_LOGGING_OUTPUT_ROTATION_STRATEGY` | `logging.output.rotation.strategy` |
| `SYNCTV_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `logging.output.rotation.max_files` |
| `SYNCTV_SERVER_LOGGING_LEVEL` | `server.logging.level` |
| `SYNCTV_SERVER_LOGGING_FORMAT` | `server.logging.format` |
| `SYNCTV_SERVER_LOGGING_OUTPUT` | `server.logging.output` |
@ -185,6 +192,20 @@ SYNCTV_FILE_STORAGE_BACKENDS='{"s3_public":{"type":"s3","endpoint":"https://s3.e
| `SYNCTV_MANAGEMENT_LOGGING_OUTPUT_PATH` | `management.logging.output.path` |
| `SYNCTV_MANAGEMENT_LOGGING_OUTPUT_ROTATION_STRATEGY` | `management.logging.output.rotation.strategy` |
| `SYNCTV_MANAGEMENT_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `management.logging.output.rotation.max_files` |
| `SYNCTV_LIVESTREAM_LOGGING_LEVEL` | `livestream.logging.level` |
| `SYNCTV_LIVESTREAM_LOGGING_FORMAT` | `livestream.logging.format` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT` | `livestream.logging.output` |
| `SYNCTV_LIVESTREAM_LOGGING_COLOR` | `livestream.logging.color` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT_PATH` | `livestream.logging.output.path` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT_ROTATION_STRATEGY` | `livestream.logging.output.rotation.strategy` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `livestream.logging.output.rotation.max_files` |
| `SYNCTV_WEBRTC_LOGGING_LEVEL` | `webrtc.logging.level` |
| `SYNCTV_WEBRTC_LOGGING_FORMAT` | `webrtc.logging.format` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT` | `webrtc.logging.output` |
| `SYNCTV_WEBRTC_LOGGING_COLOR` | `webrtc.logging.color` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT_PATH` | `webrtc.logging.output.path` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT_ROTATION_STRATEGY` | `webrtc.logging.output.rotation.strategy` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `webrtc.logging.output.rotation.max_files` |
| `SYNCTV_MANAGEMENT_AUTH_TOKEN` | `management.auth_token` |
| `SYNCTV_MANAGEMENT_AUTH_TOKEN_FILE` | `management.auth_token` file |
| `SYNCTV_MANAGEMENT_ENABLED` | `management.enabled` |

@ -198,6 +198,8 @@ curl -fsS http://localhost:8081/health/ready
## `jwt` 和 `logging`
顶层 `logging` 接收数据库、Redis、缓存、代理、realtime、outbox、迁移、启动流程、第三方依赖和其余 target 的日志。`<component>` 可取 `server`、`health`、`metrics`、`management`、`cluster`、`livestream` 或 `webrtc`,每个组件拥有独立输出目标。
| 字段 | 类型/可选值 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `jwt.secret` | secret 字符串 | `change-me-in-production` | token 签名密钥 |
@ -205,9 +207,17 @@ curl -fsS http://localhost:8081/health/ready
| `jwt.refresh_token_duration_days` | 天数 | `30` | refresh token 有效期 |
| `jwt.guest_token_duration_hours` | 小时数 | `4` | guest token 有效期 |
| `jwt.clock_skew_leeway_secs` | 秒数 | `60` | token 时间误差容忍 |
| `logging.level` | `error`、`warn`、`info`、`debug`、`trace` | `info` | 全局日志级别 |
| `logging.format` | `text` 或 `json` | `text` | 全局日志格式 |
| `logging.output` | `stdout`、`stderr` 或文件对象 | `stdout` | 全局日志输出目标 |
| `logging.output.path` | 文件路径 | `""` | 全局日志文件路径;相对路径按 `data_dir` 解析 |
| `logging.output.rotation.strategy` | `daily`、`hourly`、`never` | `daily` | 全局日志文件轮转周期 |
| `logging.output.rotation.max_files` | 正整数 | `30` | 全局日志文件保留数量 |
| `logging.color` | `auto`、`always`、`never` | `auto` | 全局日志颜色输出 |
| `<component>.logging.level` | `error`、`warn`、`info`、`debug`、`trace` | `info`metrics/cluster 为 `warn` | 组件日志级别 |
| `<component>.logging.format` | `text` 或 `json` | `text` | 组件日志格式 |
| `<component>.logging.output` | `stdout`、`stderr` 或文件对象 | `stdout` | 组件独立输出目标 |
| `<component>.logging.output.path` | 文件路径 | `""` | 组件日志文件路径;相对路径按 `data_dir` 解析 |
| `<component>.logging.output.rotation.strategy` | `daily`、`hourly`、`never` | `daily` | 文件轮转周期 |
| `<component>.logging.output.rotation.max_files` | 正整数 | `30` | 每个组件保留的文件数量 |
| `<component>.logging.color` | `auto`、`always`、`never` | `auto` | 组件颜色输出 |

@ -132,6 +132,13 @@ description: 常用 SYNCTV_ 环境变量速查。
| 环境变量 | 对应配置 |
| --- | --- |
| `SYNCTV_LOGGING_LEVEL` | `logging.level` |
| `SYNCTV_LOGGING_FORMAT` | `logging.format` |
| `SYNCTV_LOGGING_OUTPUT` | `logging.output` |
| `SYNCTV_LOGGING_COLOR` | `logging.color` |
| `SYNCTV_LOGGING_OUTPUT_PATH` | `logging.output.path` |
| `SYNCTV_LOGGING_OUTPUT_ROTATION_STRATEGY` | `logging.output.rotation.strategy` |
| `SYNCTV_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `logging.output.rotation.max_files` |
| `SYNCTV_SERVER_LOGGING_LEVEL` | `server.logging.level` |
| `SYNCTV_SERVER_LOGGING_FORMAT` | `server.logging.format` |
| `SYNCTV_SERVER_LOGGING_OUTPUT` | `server.logging.output` |
@ -167,6 +174,20 @@ description: 常用 SYNCTV_ 环境变量速查。
| `SYNCTV_MANAGEMENT_LOGGING_OUTPUT_PATH` | `management.logging.output.path` |
| `SYNCTV_MANAGEMENT_LOGGING_OUTPUT_ROTATION_STRATEGY` | `management.logging.output.rotation.strategy` |
| `SYNCTV_MANAGEMENT_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `management.logging.output.rotation.max_files` |
| `SYNCTV_LIVESTREAM_LOGGING_LEVEL` | `livestream.logging.level` |
| `SYNCTV_LIVESTREAM_LOGGING_FORMAT` | `livestream.logging.format` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT` | `livestream.logging.output` |
| `SYNCTV_LIVESTREAM_LOGGING_COLOR` | `livestream.logging.color` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT_PATH` | `livestream.logging.output.path` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT_ROTATION_STRATEGY` | `livestream.logging.output.rotation.strategy` |
| `SYNCTV_LIVESTREAM_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `livestream.logging.output.rotation.max_files` |
| `SYNCTV_WEBRTC_LOGGING_LEVEL` | `webrtc.logging.level` |
| `SYNCTV_WEBRTC_LOGGING_FORMAT` | `webrtc.logging.format` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT` | `webrtc.logging.output` |
| `SYNCTV_WEBRTC_LOGGING_COLOR` | `webrtc.logging.color` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT_PATH` | `webrtc.logging.output.path` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT_ROTATION_STRATEGY` | `webrtc.logging.output.rotation.strategy` |
| `SYNCTV_WEBRTC_LOGGING_OUTPUT_ROTATION_MAX_FILES` | `webrtc.logging.output.rotation.max_files` |
## 管理端点

@ -226,7 +226,8 @@ The application uses split database/Redis configuration so credentials can stay
| Section | Description |
|---------|-------------|
| `config.server` | API bind address, CORS, proxy settings, and gRPC transport settings |
| `config.logging` | Process-wide output for shared infrastructure, background workers, and unmatched tracing targets |
| `config.server` | API bind address, CORS, proxy settings, gRPC transport settings, and API logging |
| `config.publicIds` | Optional sqids settings for public API IDs |
| `config.management` | Management endpoint settings |
| `config.database` | Pool settings; actual host/port/user/password and optional read URL come from env vars |
@ -234,13 +235,13 @@ The application uses split database/Redis configuration so credentials can stay
| `config.cluster` | Cluster coordination and discovery settings |
| `config.jwt` | Token durations; signing secret comes from a secret |
| `config.bootstrap` | Bootstrap root-user settings |
| `config.livestream` | RTMP/HLS/pull timeout and cache settings |
| `config.livestream` | RTMP/HLS/pull timeout, cache, and logging settings |
| `config.fileStorage` | Uploaded file storage backends and product-level backend routing |
| `config.cache` | Business L1/L2 cache settings |
| `config.proxySliceCache` | Startup-only media proxy Range-slice cache settings |
| `config.mediaProviders` | Local built-in provider adapter request and connect timeouts |
| `config.webauthn` | Passkey relying-party settings |
| `config.webrtc` | Built-in STUN and WebRTC settings; external ICE servers are runtime settings |
| `config.webrtc` | Built-in STUN, WebRTC, and logging settings; external ICE servers are runtime settings |
| `config.requestRateLimits` | Shared HTTP and gRPC API category rate limits |
| `config.passwordComplexity` | Password policy for account credentials |
| `config.bufferSizes` | Internal queue sizes |

@ -45,6 +45,21 @@ data:
interval_seconds: {{ $clockSyncProvider.intervalSeconds | default 300 }}
timeout_millis: {{ $clockSyncProvider.timeoutMillis | default 1000 }}
logging:
level: {{ .Values.config.logging.level | default "info" | quote }}
format: {{ .Values.config.logging.format | default "text" | quote }}
{{- if kindIs "map" .Values.config.logging.output }}
output:
type: {{ .Values.config.logging.output.type | default "file" | quote }}
path: {{ .Values.config.logging.output.path | quote }}
rotation:
strategy: {{ .Values.config.logging.output.rotation.strategy | default "daily" | quote }}
max_files: {{ .Values.config.logging.output.rotation.maxFiles | default 30 }}
{{- else }}
output: {{ .Values.config.logging.output | default "stdout" | quote }}
{{- end }}
color: {{ .Values.config.logging.color | default "auto" | quote }}
server:
host: {{ .Values.config.server.host | quote }}
port: {{ .Values.config.server.port }}
@ -236,6 +251,20 @@ data:
livestream:
rtmp_port: {{ .Values.config.livestream.rtmpPort }}
public_rtmp_host: {{ .Values.config.livestream.publicRtmpHost | default "" | quote }}
logging:
level: {{ $livestream.logging.level | default "info" | quote }}
format: {{ $livestream.logging.format | default "text" | quote }}
{{- if kindIs "map" $livestream.logging.output }}
output:
type: {{ $livestream.logging.output.type | default "file" | quote }}
path: {{ $livestream.logging.output.path | quote }}
rotation:
strategy: {{ $livestream.logging.output.rotation.strategy | default "daily" | quote }}
max_files: {{ $livestream.logging.output.rotation.maxFiles | default 30 }}
{{- else }}
output: {{ $livestream.logging.output | default "stdout" | quote }}
{{- end }}
color: {{ $livestream.logging.color | default "auto" | quote }}
gop_cache_size: {{ $livestream.gopCacheSize }}
stream_timeout_seconds: {{ $livestream.streamTimeoutSeconds }}
cleanup_check_interval_seconds: {{ $livestream.cleanupCheckIntervalSeconds }}
@ -351,6 +380,20 @@ data:
stun_external_addr: {{ .Values.config.webrtc.stunExternalAddr | quote }}
{{- end }}
filter_private_ice_candidates: {{ .Values.config.webrtc.filterPrivateIceCandidates }}
logging:
level: {{ .Values.config.webrtc.logging.level | default "info" | quote }}
format: {{ .Values.config.webrtc.logging.format | default "text" | quote }}
{{- if kindIs "map" .Values.config.webrtc.logging.output }}
output:
type: {{ .Values.config.webrtc.logging.output.type | default "file" | quote }}
path: {{ .Values.config.webrtc.logging.output.path | quote }}
rotation:
strategy: {{ .Values.config.webrtc.logging.output.rotation.strategy | default "daily" | quote }}
max_files: {{ .Values.config.webrtc.logging.output.rotation.maxFiles | default 30 }}
{{- else }}
output: {{ .Values.config.webrtc.logging.output | default "stdout" | quote }}
{{- end }}
color: {{ .Values.config.webrtc.logging.color | default "auto" | quote }}
connection_limits:
max_per_user: {{ $connectionLimits.maxPerUser | default 20 }}

@ -335,6 +335,15 @@ config:
intervalSeconds: 300
timeoutMillis: 1000
## Process-wide logging for shared infrastructure and unmatched targets.
logging:
level: info
format: text
## File output example:
## output: {type: file, path: /data/logs/global.log, rotation: {strategy: daily, maxFiles: 30}}
output: stdout
color: auto
## ServerConfig
server:
host: "0.0.0.0"
@ -463,6 +472,11 @@ config:
## If empty, SyncTV uses only the local bind host fallback and does not
## reuse internal Pod IPs or server.advertise_host.
publicRtmpHost: ""
logging:
level: info
format: text
output: stdout
color: auto
gopCacheSize: 2
streamTimeoutSeconds: 300
cleanupCheckIntervalSeconds: 60
@ -562,6 +576,11 @@ config:
stunExternalAddr: ""
## External ICE servers are configured dynamically via the settings API.
filterPrivateIceCandidates: false
logging:
level: info
format: text
output: stdout
color: auto
## ConnectionLimitsConfig
connectionLimits:

@ -133,6 +133,7 @@ impl MetricsAccessController {
.map(Arc::new)
.map_err(|error| {
tracing::error!(
target: "synctv::metrics",
error = %error,
"failed to initialize Kubernetes metrics authorizer"
);
@ -145,7 +146,13 @@ impl MetricsAccessController {
.authorize(&token, path, method, metrics)
.await
.inspect_err(|&error| {
tracing::warn!(error = ?error, path, method, "metrics request denied");
tracing::warn!(
target: "synctv::metrics",
error = ?error,
path,
method,
"metrics request denied"
);
});
}
}
@ -395,7 +402,11 @@ fn map_kubernetes_metrics_error(error: &kube::Error) -> MetricsAccessError {
return MetricsAccessError::Forbidden;
}
}
tracing::error!(error = %error, "kubernetes metrics auth request failed");
tracing::error!(
target: "synctv::metrics",
error = %error,
"kubernetes metrics auth request failed"
);
MetricsAccessError::Internal
}

@ -62,20 +62,22 @@ pub struct ComponentLoggingOptions {
#[derive(Debug, Clone)]
pub struct LoggingOptions {
pub global: ComponentLoggingOptions,
pub components: Vec<ComponentLoggingOptions>,
}
impl Default for LoggingOptions {
fn default() -> Self {
Self {
components: vec![ComponentLoggingOptions {
name: "server".to_string(),
global: ComponentLoggingOptions {
name: "global".to_string(),
targets: Vec::new(),
level: "info".to_string(),
format: "text".to_string(),
output: LogOutput::Stdout,
color: LogColor::Auto,
}],
},
components: Vec::new(),
}
}
}
@ -118,9 +120,9 @@ pub(crate) fn dropped_lines_by_component() -> Vec<(String, usize)> {
.map_or_else(Vec::new, |counters| dropped_lines(counters))
}
/// Build one filtered fmt layer per configured component and install them on a
/// single registry. Routing is exclusive: a specialized target is handled by
/// its component, while the server component receives all remaining targets.
/// Build one filtered fmt layer per configured output and install them on a
/// single registry. Routing is exclusive: configured targets are handled by
/// their components, while the global output receives every remaining target.
pub fn init_logging(config: &LoggingOptions) -> anyhow::Result<LoggingGuards> {
let (subscriber, guards) = build_subscriber(config)?;
subscriber
@ -135,26 +137,26 @@ type LoggingSubscriber = Layered<Vec<Box<dyn Layer<Registry> + Send + Sync>>, Re
fn build_subscriber(config: &LoggingOptions) -> anyhow::Result<(LoggingSubscriber, LoggingGuards)> {
validate_component_routes(config)?;
let specialized_targets: Vec<String> = config
let component_targets: Vec<String> = config
.components
.iter()
.filter(|component| component.name != "server")
.flat_map(|component| component.targets.iter().cloned())
.collect();
let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
let mut writers = WriterPool::new();
for component in &config.components {
for (component, is_global) in std::iter::once((&config.global, true))
.chain(config.components.iter().map(|component| (component, false)))
{
let level = parse_log_level(&component.level)?;
let targets = component.targets.clone();
let is_server = component.name == "server";
let specialized_targets_for_server = specialized_targets.clone();
let component_targets_for_global = component_targets.clone();
let filter = FilterFn::new(move |metadata| {
if *metadata.level() > level {
return false;
}
if is_server && metadata.target() == SQLX_POSTGRES_NOTICE_TARGET {
if is_global && metadata.target() == SQLX_POSTGRES_NOTICE_TARGET {
let notice_level = if matches!(level, Level::TRACE | Level::DEBUG) {
Level::INFO
} else {
@ -166,9 +168,9 @@ fn build_subscriber(config: &LoggingOptions) -> anyhow::Result<(LoggingSubscribe
}
target_is_owned_by_component(
metadata.target(),
is_server,
is_global,
&targets,
&specialized_targets_for_server,
&component_targets_for_global,
)
});
@ -217,68 +219,31 @@ fn build_subscriber(config: &LoggingOptions) -> anyhow::Result<(LoggingSubscribe
}
fn validate_component_routes(config: &LoggingOptions) -> anyhow::Result<()> {
let server_count = config
.components
.iter()
.filter(|component| component.name == "server")
.count();
if server_count != 1 {
if config.global.name.trim().is_empty() {
return Err(anyhow::anyhow!(
"logging requires exactly one server component, found {server_count}"
"global logging component name must be non-empty"
));
}
if !config.global.targets.is_empty() {
return Err(anyhow::anyhow!(
"global logging component must not define targets"
));
}
let mut names = HashSet::new();
validate_component_options(&config.global, &mut names)?;
for component in &config.components {
if component.name.trim().is_empty() || !names.insert(component.name.as_str()) {
return Err(anyhow::anyhow!(
"logging component names must be non-empty and unique"
));
}
if component.name != "server" && component.targets.is_empty() {
if component.targets.is_empty() {
return Err(anyhow::anyhow!(
"logging component '{}' requires at least one target",
component.name
));
}
parse_log_level(&component.level).map_err(|error| {
anyhow::anyhow!(
"invalid log level for component '{}': {error}",
component.name
)
})?;
if !matches!(
component.format.to_ascii_lowercase().as_str(),
"text" | "json"
) {
return Err(anyhow::anyhow!(
"invalid log format '{}' for component '{}'",
component.format,
component.name
));
}
if matches!(&component.output, LogOutput::File { path, .. } if path.as_os_str().is_empty())
{
return Err(anyhow::anyhow!(
"file output path for component '{}' must not be empty",
component.name
));
}
if matches!(&component.output, LogOutput::File { max_files: 0, .. }) {
return Err(anyhow::anyhow!(
"file retention for component '{}' must keep at least one file",
component.name
));
}
validate_component_options(component, &mut names)?;
}
let specialized: Vec<_> = config
.components
.iter()
.filter(|component| component.name != "server")
.collect();
for (index, left) in specialized.iter().enumerate() {
for right in specialized.iter().skip(index + 1) {
for (index, left) in config.components.iter().enumerate() {
for right in config.components.iter().skip(index + 1) {
for left_target in &left.targets {
for right_target in &right.targets {
if target_prefixes_overlap(left_target, right_target) {
@ -297,6 +262,46 @@ fn validate_component_routes(config: &LoggingOptions) -> anyhow::Result<()> {
Ok(())
}
fn validate_component_options(
component: &ComponentLoggingOptions,
names: &mut HashSet<String>,
) -> anyhow::Result<()> {
if component.name.trim().is_empty() || !names.insert(component.name.clone()) {
return Err(anyhow::anyhow!(
"logging component names must be non-empty and unique"
));
}
parse_log_level(&component.level).map_err(|error| {
anyhow::anyhow!(
"invalid log level for component '{}': {error}",
component.name
)
})?;
if !matches!(
component.format.to_ascii_lowercase().as_str(),
"text" | "json"
) {
return Err(anyhow::anyhow!(
"invalid log format '{}' for component '{}'",
component.format,
component.name
));
}
if matches!(&component.output, LogOutput::File { path, .. } if path.as_os_str().is_empty()) {
return Err(anyhow::anyhow!(
"file output path for component '{}' must not be empty",
component.name
));
}
if matches!(&component.output, LogOutput::File { max_files: 0, .. }) {
return Err(anyhow::anyhow!(
"file retention for component '{}' must keep at least one file",
component.name
));
}
Ok(())
}
fn target_prefixes_overlap(left: &str, right: &str) -> bool {
let is_prefix = |prefix: &str, target: &str| {
target == prefix
@ -309,9 +314,9 @@ fn target_prefixes_overlap(left: &str, right: &str) -> bool {
fn target_is_owned_by_component(
target: &str,
is_server: bool,
is_global: bool,
component_targets: &[String],
specialized_targets: &[String],
all_component_targets: &[String],
) -> bool {
let matches = |configured: &[String]| {
configured.iter().any(|prefix| {
@ -321,8 +326,8 @@ fn target_is_owned_by_component(
.is_some_and(|suffix| suffix.starts_with("::"))
})
};
if is_server {
!matches(specialized_targets)
if is_global {
!matches(all_component_targets)
} else {
matches(component_targets)
}
@ -445,13 +450,7 @@ pub(crate) fn parse_log_level(level: &str) -> anyhow::Result<Level> {
}
pub fn effective_log_level(config: &LoggingOptions) -> anyhow::Result<Level> {
let component = config
.components
.iter()
.find(|component| component.name == "server")
.or_else(|| config.components.first())
.ok_or_else(|| anyhow::anyhow!("at least one logging component is required"))?;
parse_log_level(&component.level)
parse_log_level(&config.global.level)
}
/// Generate a trace ID for request tracing.
@ -471,64 +470,61 @@ mod tests {
use tempfile::tempdir;
#[test]
fn default_logging_has_a_server_component() {
fn default_logging_has_a_global_output() {
let config = LoggingOptions::default();
assert_eq!(config.components.len(), 1);
assert_eq!(config.components[0].name, "server");
assert_eq!(config.global.name, "global");
assert!(config.global.targets.is_empty());
assert!(config.components.is_empty());
}
#[test]
fn effective_level_comes_from_server_component() {
fn effective_level_comes_from_global_output() {
let config = LoggingOptions {
components: vec![
ComponentLoggingOptions {
name: "health".to_string(),
level: "error".to_string(),
targets: vec!["synctv::health".to_string()],
..default_component("health")
},
ComponentLoggingOptions {
name: "server".to_string(),
level: "debug".to_string(),
..default_component("server")
},
],
global: ComponentLoggingOptions {
level: "debug".to_string(),
..default_component("global")
},
components: vec![ComponentLoggingOptions {
level: "error".to_string(),
targets: vec!["synctv::health".to_string()],
..default_component("health")
}],
};
assert_eq!(
effective_log_level(&config).expect("server level should be valid"),
effective_log_level(&config).expect("global level should be valid"),
Level::DEBUG
);
}
#[test]
fn specialized_targets_are_excluded_from_server_layer() {
let specialized = vec!["synctv::health".to_string()];
fn component_targets_are_excluded_from_global_layer() {
let component_targets = vec!["synctv::health".to_string()];
let health = vec!["synctv::health".to_string()];
assert!(target_is_owned_by_component(
"synctv::health::probe",
false,
&health,
&specialized
&component_targets
));
assert!(!target_is_owned_by_component(
"synctv::health::probe",
true,
&[],
&specialized
&component_targets
));
assert!(target_is_owned_by_component(
"synctv_api_http",
true,
&[],
&specialized
&component_targets
));
}
#[test]
fn overlapping_specialized_routes_are_rejected() {
let config = LoggingOptions {
global: default_component("global"),
components: vec![
default_component("server"),
ComponentLoggingOptions {
targets: vec!["synctv::cluster".to_string()],
..default_component("cluster")
@ -545,20 +541,18 @@ mod tests {
#[test]
fn components_sharing_standard_output_have_independent_workers() {
let config = LoggingOptions {
components: vec![
default_component("server"),
ComponentLoggingOptions {
targets: vec!["synctv::health".to_string()],
..default_component("health")
},
],
global: default_component("global"),
components: vec![ComponentLoggingOptions {
targets: vec!["synctv::health".to_string()],
..default_component("health")
}],
};
let (_subscriber, guards) =
build_subscriber(&config).expect("logging subscriber should build");
assert_eq!(guards.len(), 2);
assert_eq!(
guards.dropped_lines(),
vec![("server".to_string(), 0), ("health".to_string(), 0)]
vec![("global".to_string(), 0), ("health".to_string(), 0)]
);
}
@ -589,14 +583,15 @@ mod tests {
#[test]
fn zero_file_retention_is_rejected() {
let config = LoggingOptions {
components: vec![ComponentLoggingOptions {
global: ComponentLoggingOptions {
output: LogOutput::File {
path: PathBuf::from("server.log"),
path: PathBuf::from("global.log"),
rotation: LogRotation::Daily,
max_files: 0,
},
..default_component("server")
}],
..default_component("global")
},
components: Vec::new(),
};
assert!(validate_component_routes(&config).is_err());
}
@ -635,47 +630,46 @@ mod tests {
fn component_layers_use_independent_routes_levels_formats_and_files() {
let dir = tempdir().expect("temporary log directory should be created");
let config = LoggingOptions {
components: vec![
ComponentLoggingOptions {
format: "json".to_string(),
output: LogOutput::File {
path: dir.path().join("server.log"),
rotation: LogRotation::Never,
max_files: 2,
},
..default_component("server")
global: ComponentLoggingOptions {
format: "json".to_string(),
output: LogOutput::File {
path: dir.path().join("global.log"),
rotation: LogRotation::Never,
max_files: 2,
},
ComponentLoggingOptions {
targets: vec!["synctv::health".to_string()],
level: "warn".to_string(),
output: LogOutput::File {
path: dir.path().join("health.log"),
rotation: LogRotation::Never,
max_files: 2,
},
..default_component("health")
..default_component("global")
},
components: vec![ComponentLoggingOptions {
targets: vec!["synctv::health".to_string()],
level: "warn".to_string(),
output: LogOutput::File {
path: dir.path().join("health.log"),
rotation: LogRotation::Never,
max_files: 2,
},
],
..default_component("health")
}],
};
let (subscriber, guards) =
build_subscriber(&config).expect("logging subscriber should build");
assert_eq!(guards.len(), 2);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(target: "synctv_api_http", "server-only-event");
tracing::info!(target: "synctv_core::cache", "global-only-event");
tracing::info!(target: "synctv::health", "filtered-health-event");
tracing::warn!(target: "synctv::health", "health-only-event");
});
drop(guards);
let server_log = read_log_with_prefix(dir.path(), "server");
let global_log = read_log_with_prefix(dir.path(), "global");
let health_log = read_log_with_prefix(dir.path(), "health");
assert!(server_log.contains("server-only-event"));
assert!(server_log.contains("\"target\":\"synctv_api_http\""));
assert!(!server_log.contains("health-only-event"));
assert!(global_log.contains("global-only-event"));
assert!(global_log.contains("\"target\":\"synctv_core::cache\""));
assert!(!global_log.contains("health-only-event"));
assert!(!global_log.contains("filtered-health-event"));
assert!(health_log.contains("health-only-event"));
assert!(!health_log.contains("filtered-health-event"));
assert!(!health_log.contains("server-only-event"));
assert!(!health_log.contains("global-only-event"));
assert!(!health_log.trim_start().starts_with('{'));
}

@ -16,6 +16,25 @@
# 1. Config file sibling keys: jwt.secret_file, management.auth_token_file, ...
# 2. Env file variables: SYNCTV_JWT_SECRET_FILE, SYNCTV_MANAGEMENT_AUTH_TOKEN_FILE, ...
# Process-wide logging for shared infrastructure, background workers, database,
# Redis, configuration/bootstrap code, and third-party library targets.
# Environment variables: SYNCTV_LOGGING_LEVEL, SYNCTV_LOGGING_FORMAT,
# SYNCTV_LOGGING_COLOR, SYNCTV_LOGGING_OUTPUT, SYNCTV_LOGGING_OUTPUT_PATH,
# SYNCTV_LOGGING_OUTPUT_ROTATION_STRATEGY, SYNCTV_LOGGING_OUTPUT_ROTATION_MAX_FILES.
# Service logging blocks use the corresponding SYNCTV_<SERVICE>_LOGGING_* prefix.
logging:
level: "info"
format: "text"
output: "stdout"
color: "auto"
# File output:
# output:
# type: "file"
# path: "logs/global.log"
# rotation:
# strategy: "daily"
# max_files: 30
server:
host: "0.0.0.0"
port: 8080
@ -236,7 +255,9 @@ management:
# `data_dir` is the shared root for runtime-owned local files.
# It affects relative:
# - management.unix_socket_path
# - <component>.logging.output.path for server, health, metrics, cluster, and management
# - logging.output.path
# - <component>.logging.output.path for server, health, metrics, management,
# cluster, livestream, and webrtc
# - livestream.hls_storage.path
# - proxy_slice_cache.file_cache_dir
# It does NOT affect:
@ -382,6 +403,11 @@ livestream:
# client-reachable domain, LoadBalancer address, or node public IP.
# If empty, SyncTV uses only the local bind host fallback for local/single-node use.
# public_rtmp_host: "live.example.com"
logging:
level: "info"
format: "text"
output: "stdout"
color: "auto"
gop_cache_size: 2
stream_timeout_seconds: 300
# How often to check for idle streams (seconds)
@ -767,6 +793,11 @@ webrtc:
# stun_external_addr: ""
# Filter private/internal ICE candidates before sending them to clients.
filter_private_ice_candidates: false
logging:
level: "info"
format: "text"
output: "stdout"
color: "auto"
# --- External ICE Servers ---
# Configured dynamically via the settings API (no restart required):
# "webrtc.external_ice_servers" - JSON array of ICE server objects, e.g.:

@ -1832,6 +1832,7 @@ mod tests {
fn minimal_valid_startup_config() -> Config {
Config {
logging: crate::app_config::LoggingConfig::default(),
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,

@ -945,6 +945,7 @@ impl std::fmt::Debug for HlsS3Config {
pub struct LivestreamConfig {
pub rtmp_port: u16,
pub public_rtmp_host: String,
pub logging: LoggingConfig,
pub gop_cache_size: u32,
pub stream_timeout_seconds: u64,
pub cleanup_check_interval_seconds: u64,
@ -963,6 +964,7 @@ impl Default for LivestreamConfig {
Self {
rtmp_port: 1935,
public_rtmp_host: String::new(),
logging: LoggingConfig::default(),
gop_cache_size: 2,
stream_timeout_seconds: 300,
cleanup_check_interval_seconds: 60,
@ -1301,6 +1303,7 @@ pub struct WebRTCConfig {
pub stun_host: String,
pub stun_external_addr: String,
pub filter_private_ice_candidates: bool,
pub logging: LoggingConfig,
}
impl Default for WebRTCConfig {
@ -1312,6 +1315,7 @@ impl Default for WebRTCConfig {
stun_host: "0.0.0.0".to_string(),
stun_external_addr: String::new(),
filter_private_ice_candidates: false,
logging: LoggingConfig::default(),
}
}
}
@ -1688,6 +1692,7 @@ pub struct RateLimitScopeRule {
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub logging: LoggingConfig,
pub server: ServerConfig,
pub health: HealthConfig,
pub time: TimeConfig,
@ -1718,6 +1723,7 @@ pub struct AppConfig {
impl std::fmt::Debug for AppConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppConfig")
.field("logging", &self.logging)
.field("server", &self.server)
.field("health", &self.health)
.field("time", &self.time)
@ -1750,6 +1756,7 @@ impl std::fmt::Debug for AppConfig {
impl Default for AppConfig {
fn default() -> Self {
Self {
logging: LoggingConfig::default(),
server: ServerConfig::default(),
health: HealthConfig::default(),
time: TimeConfig::default(),

@ -564,43 +564,46 @@ impl AppConfig {
}
}
for (name, logging) in [
("server", &self.server.logging),
("health", &self.health.logging),
("metrics", &self.metrics.logging),
("cluster", &self.cluster.logging),
("management", &self.management.logging),
for (path, logging) in [
("logging", &self.logging),
("server.logging", &self.server.logging),
("health.logging", &self.health.logging),
("metrics.logging", &self.metrics.logging),
("cluster.logging", &self.cluster.logging),
("management.logging", &self.management.logging),
("livestream.logging", &self.livestream.logging),
("webrtc.logging", &self.webrtc.logging),
] {
if logging.level.parse::<tracing::Level>().is_err() {
errors.push(format!(
"{name}.logging.level '{}' must be one of: trace, debug, info, warn, error",
"{path}.level '{}' must be one of: trace, debug, info, warn, error",
logging.level
));
}
if !matches!(logging.format.as_str(), "text" | "json") {
errors.push(format!(
"{name}.logging.format '{}' must be text or json",
"{path}.format '{}' must be text or json",
logging.format
));
}
if let LogOutput::File(output) = &logging.output {
if output.r#type != "file" {
errors.push(format!("{name}.logging.output.type must be file"));
errors.push(format!("{path}.output.type must be file"));
}
if output.path.trim().is_empty() {
errors.push(format!("{name}.logging.output.path must not be empty"));
errors.push(format!("{path}.output.path must not be empty"));
}
if !matches!(
output.rotation.strategy.as_str(),
"daily" | "hourly" | "never"
) {
errors.push(format!(
"{name}.logging.output.rotation.strategy must be daily, hourly, or never"
"{path}.output.rotation.strategy must be daily, hourly, or never"
));
}
if output.rotation.max_files == 0 {
errors.push(format!(
"{name}.logging.output.rotation.max_files must be at least 1"
"{path}.output.rotation.max_files must be at least 1"
));
}
}
@ -1423,7 +1426,8 @@ mod tests {
valid_sha256_certificate_fingerprint, validate_project_url,
};
use crate::app_config::{
AppConfig, ClusterChannelConfig, ClusterDiscoveryMode, SecurityConfig, SsrfConfig,
AppConfig, ClusterChannelConfig, ClusterDiscoveryMode, LogFileOutput, LogOutput,
SecurityConfig, SsrfConfig,
};
#[test]
@ -1439,6 +1443,26 @@ mod tests {
}
}
#[test]
fn logging_validation_covers_global_and_network_components() {
let mut config = AppConfig::default();
config.logging.level = "verbose".to_string();
config.livestream.logging.format = "pretty".to_string();
config.webrtc.logging.output = LogOutput::File(LogFileOutput::default());
let errors = config.validate().expect_err("logging settings must fail");
assert!(errors
.iter()
.any(|error| error.contains("logging.level 'verbose'")));
assert!(errors
.iter()
.any(|error| error.contains("livestream.logging.format 'pretty'")));
assert!(errors
.iter()
.any(|error| error.contains("webrtc.logging.output.path must not be empty")));
}
#[test]
fn native_app_association_identifiers_accept_platform_formats() {
assert!(valid_apple_application_identifier(

@ -519,14 +519,15 @@ mod tests {
#[test]
fn effective_log_level_uses_synctv_config_for_database_policy() {
let logging = LoggingOptions {
components: vec![synctv_core::logging::ComponentLoggingOptions {
name: "server".to_string(),
global: synctv_core::logging::ComponentLoggingOptions {
name: "global".to_string(),
level: "debug".to_string(),
targets: Vec::new(),
format: "text".to_string(),
output: synctv_core::logging::LogOutput::Stdout,
color: synctv_core::logging::LogColor::Auto,
}],
},
components: Vec::new(),
};
let effective = synctv_core::logging::effective_log_level(&logging)

@ -98,6 +98,7 @@ fn apply_redis_url_component_env_overrides(
/// Format: `SYNCTV_<SECTION>_<FIELD>=<value>`
///
/// Examples:
/// - `SYNCTV_LOGGING_LEVEL=info`
/// - `SYNCTV_SERVER_HOST=0.0.0.0`
/// - `SYNCTV_DATABASE_URL=postgresql://...`
/// - `SYNCTV_SERVER_ADVERTISE_HOST=10.0.0.1`
@ -129,7 +130,11 @@ pub(crate) fn apply_env_overrides_with(
};
let apply_logging_env =
|service: &str, logging: &mut LoggingConfig| -> Result<(), ConfigError> {
let prefix = format!("SYNCTV_{service}_LOGGING");
let prefix = if service.is_empty() {
"SYNCTV_LOGGING".to_string()
} else {
format!("SYNCTV_{service}_LOGGING")
};
if let Some(value) = get_env(&format!("{prefix}_LEVEL")) {
logging.level = value;
}
@ -707,11 +712,14 @@ pub(crate) fn apply_env_overrides_with(
&mut config.webauthn.timeout_seconds,
)?;
apply_logging_env("", &mut config.logging)?;
apply_logging_env("SERVER", &mut config.server.logging)?;
apply_logging_env("HEALTH", &mut config.health.logging)?;
apply_logging_env("METRICS", &mut config.metrics.logging)?;
apply_logging_env("CLUSTER", &mut config.cluster.logging)?;
apply_logging_env("MANAGEMENT", &mut config.management.logging)?;
apply_logging_env("LIVESTREAM", &mut config.livestream.logging)?;
apply_logging_env("WEBRTC", &mut config.webrtc.logging)?;
env_override_parse(
"SYNCTV_LIVESTREAM_RTMP_PORT",
@ -1235,11 +1243,14 @@ pub(crate) fn resolve_owned_local_paths(
};
for logging in [
&mut config.logging,
&mut config.server.logging,
&mut config.health.logging,
&mut config.metrics.logging,
&mut config.cluster.logging,
&mut config.management.logging,
&mut config.livestream.logging,
&mut config.webrtc.logging,
] {
if let LogOutput::File(output) = &mut logging.output {
let path = output.path.trim();
@ -1453,9 +1464,13 @@ mod tests {
}
#[test]
fn service_logging_and_internal_listener_environment_overrides() {
fn global_and_service_logging_environment_overrides() {
let mut config = Config::default();
let env = HashMap::from([
("SYNCTV_LOGGING_LEVEL", "trace".to_string()),
("SYNCTV_LOGGING_FORMAT", "json".to_string()),
("SYNCTV_LOGGING_COLOR", "always".to_string()),
("SYNCTV_LOGGING_OUTPUT_PATH", "logs/global".to_string()),
("SYNCTV_SERVER_LOGGING_LEVEL", "debug".to_string()),
("SYNCTV_SERVER_LOGGING_FORMAT", "json".to_string()),
("SYNCTV_SERVER_LOGGING_COLOR", "never".to_string()),
@ -1474,6 +1489,8 @@ mod tests {
"SYNCTV_HEALTH_LOGGING_OUTPUT_ROTATION_MAX_FILES",
"72".to_string(),
),
("SYNCTV_LIVESTREAM_LOGGING_LEVEL", "warn".to_string()),
("SYNCTV_WEBRTC_LOGGING_LEVEL", "error".to_string()),
("SYNCTV_HEALTH_PORT", "18081".to_string()),
("SYNCTV_CLUSTER_ENABLED", "true".to_string()),
("SYNCTV_CLUSTER_HOST", "0.0.0.0".to_string()),
@ -1487,6 +1504,13 @@ mod tests {
apply_env_overrides_with(&mut config, &|name| env.get(name).cloned())
.expect("service configuration environment overrides should apply");
assert_eq!(config.logging.level, "trace");
assert_eq!(config.logging.format, "json");
assert!(matches!(config.logging.color, LogColor::Always));
let LogOutput::File(global_output) = &config.logging.output else {
panic!("global logging output should be a file output");
};
assert_eq!(global_output.path, "logs/global");
assert_eq!(config.server.logging.level, "debug");
assert_eq!(config.server.logging.format, "json");
assert!(matches!(config.server.logging.color, LogColor::Never));
@ -1499,6 +1523,8 @@ mod tests {
assert_eq!(health_output.path, "logs/health");
assert_eq!(health_output.rotation.strategy, "hourly");
assert_eq!(health_output.rotation.max_files, 72);
assert_eq!(config.livestream.logging.level, "warn");
assert_eq!(config.webrtc.logging.level, "error");
assert_eq!(config.health.port, 18081);
assert!(config.cluster.enabled);
assert_eq!(config.cluster.port, 15051);
@ -1506,12 +1532,16 @@ mod tests {
}
#[test]
fn relative_component_log_paths_resolve_under_data_dir() {
fn relative_global_and_component_log_paths_resolve_under_data_dir() {
let dir = tempdir().expect("temp dir should be created");
let config_path = dir.path().join("synctv.yaml");
let data_dir = dir.path().join("data");
let mut config = Config::default();
config.data_dir = data_dir.display().to_string();
config.logging.output = LogOutput::File(LogFileOutput {
path: "logs/global".to_string(),
..LogFileOutput::default()
});
config.server.logging.output = LogOutput::File(LogFileOutput {
path: "logs/server".to_string(),
..LogFileOutput::default()
@ -1520,9 +1550,24 @@ mod tests {
path: "logs/health".to_string(),
..LogFileOutput::default()
});
config.livestream.logging.output = LogOutput::File(LogFileOutput {
path: "logs/livestream".to_string(),
..LogFileOutput::default()
});
config.webrtc.logging.output = LogOutput::File(LogFileOutput {
path: "logs/webrtc".to_string(),
..LogFileOutput::default()
});
resolve_owned_local_paths(&mut config, Some(&config_path), false, None);
let LogOutput::File(output) = config.logging.output else {
panic!("global logging output should remain a file output");
};
assert_eq!(
output.path,
data_dir.join("logs/global").display().to_string()
);
let LogOutput::File(output) = config.server.logging.output else {
panic!("server logging output should remain a file output");
};
@ -1537,5 +1582,19 @@ mod tests {
output.path,
data_dir.join("logs/health").display().to_string()
);
let LogOutput::File(output) = config.livestream.logging.output else {
panic!("livestream logging output should remain a file output");
};
assert_eq!(
output.path,
data_dir.join("logs/livestream").display().to_string()
);
let LogOutput::File(output) = config.webrtc.logging.output else {
panic!("webrtc logging output should remain a file output");
};
assert_eq!(
output.path,
data_dir.join("logs/webrtc").display().to_string()
);
}
}

@ -852,8 +852,9 @@ mod tests {
#[cfg(target_os = "macos")]
use super::{default_config_search_paths, user_home_dir};
use super::{
load_config, load_config_with_options, load_public_id_config_with_options,
public_id_config_extensions, ConfigLoadExtensions, LoadConfigOptions,
load_config, load_config_with_options, load_core_config_file,
load_public_id_config_with_options, public_id_config_extensions, ConfigLoadExtensions,
LoadConfigOptions,
};
use std::fmt::Debug;
use std::sync::{Mutex, MutexGuard, OnceLock};
@ -893,6 +894,26 @@ mod tests {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn example_config_covers_every_logging_destination() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../synctv.example.yaml");
let (config, unknown) = load_core_config_file(path, &public_id_config_extensions())
.checked("example config should deserialize");
assert!(
unknown.is_empty(),
"example config keys should be supported"
);
assert_eq!(config.logging.level, "info");
assert_eq!(config.server.logging.level, "info");
assert_eq!(config.health.logging.level, "info");
assert_eq!(config.metrics.logging.level, "warn");
assert_eq!(config.management.logging.level, "info");
assert_eq!(config.cluster.logging.level, "warn");
assert_eq!(config.livestream.logging.level, "info");
assert_eq!(config.webrtc.logging.level, "info");
}
#[cfg(target_os = "macos")]
#[test]
fn test_default_config_search_paths_use_home_hidden_config_dir_on_macos() {

@ -40,8 +40,19 @@ use crate::app_config::{
pub fn logging_options(config: &AppConfig) -> LoggingOptions {
LoggingOptions {
global: component_logging("global", &config.logging, Vec::new()),
components: vec![
component_logging("server", &config.server.logging, Vec::new()),
component_logging(
"server",
&config.server.logging,
vec![
"synctv::server".to_string(),
"synctv_api".to_string(),
"synctv_api_common".to_string(),
"synctv_api_grpc".to_string(),
"synctv_api_http".to_string(),
],
),
component_logging(
"health",
&config.health.logging,
@ -50,7 +61,10 @@ pub fn logging_options(config: &AppConfig) -> LoggingOptions {
component_logging(
"metrics",
&config.metrics.logging,
vec!["synctv::metrics".to_string()],
vec![
"synctv::metrics".to_string(),
"synctv_core::metrics".to_string(),
],
),
component_logging(
"cluster",
@ -66,6 +80,24 @@ pub fn logging_options(config: &AppConfig) -> LoggingOptions {
&config.management.logging,
vec!["synctv_management".to_string()],
),
component_logging(
"livestream",
&config.livestream.logging,
vec![
"synctv::bootstrap::livestream".to_string(),
"synctv::rtmp_auth".to_string(),
"synctv_livestream".to_string(),
"synctv_xiu".to_string(),
],
),
component_logging(
"webrtc",
&config.webrtc.logging,
vec![
"synctv::bootstrap::webrtc".to_string(),
"synctv_core::service::stun_server".to_string(),
],
),
],
}
}
@ -642,6 +674,18 @@ pub fn hls_s3_options(storage: &HlsStorageConfig) -> HlsS3Options {
mod tests {
use super::*;
#[test]
fn global_logging_is_the_unscoped_output() {
let options = logging_options(&AppConfig::default());
assert_eq!(options.global.name, "global");
assert!(options.global.targets.is_empty());
assert!(options
.components
.iter()
.all(|component| !component.targets.is_empty()));
}
#[test]
fn cluster_logging_includes_application_bootstrap_target() {
let options = logging_options(&AppConfig::default());
@ -656,4 +700,23 @@ mod tests {
.iter()
.any(|target| target == "synctv::bootstrap::cluster"));
}
#[test]
fn independent_network_services_have_dedicated_logging_targets() {
let options = logging_options(&AppConfig::default());
let targets_for = |name: &str| {
options
.components
.iter()
.find(|component| component.name == name)
.map(|component| component.targets.as_slice())
.expect("logging component must exist")
};
assert!(targets_for("server").contains(&"synctv_api_http".to_string()));
assert!(targets_for("metrics").contains(&"synctv_core::metrics".to_string()));
assert!(targets_for("livestream").contains(&"synctv_livestream".to_string()));
assert!(targets_for("livestream").contains(&"synctv_xiu".to_string()));
assert!(targets_for("webrtc").contains(&"synctv_core::service::stun_server".to_string()));
}
}

@ -59,6 +59,7 @@ fn test_file_storage_config() -> FileStorageConfig {
/// Create a minimal standalone config for testing (no Redis, no distributed mode)
fn standalone_test_config() -> Config {
Config {
logging: LoggingConfig::default(),
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
@ -113,6 +114,7 @@ fn standalone_test_config() -> Config {
/// Create a config with distributed mode enabled
fn cluster_test_config() -> Config {
Config {
logging: LoggingConfig::default(),
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,

@ -493,14 +493,15 @@ static TEST_LOGGING: OnceLock<synctv_core::logging::LoggingGuards> = OnceLock::n
fn ensure_test_logging() {
TEST_LOGGING.get_or_init(|| {
let logging = synctv_core::logging::LoggingOptions {
components: vec![synctv_core::logging::ComponentLoggingOptions {
name: "server".to_string(),
global: synctv_core::logging::ComponentLoggingOptions {
name: "global".to_string(),
targets: Vec::new(),
level: "debug".to_string(),
format: "text".to_string(),
output: synctv_core::logging::LogOutput::Stdout,
color: synctv_core::logging::LogColor::Auto,
}],
},
components: Vec::new(),
};
synctv_core::logging::init_logging(&logging)
.expect("test tracing subscriber should initialize")

Loading…
Cancel
Save