diff --git a/.env.synctv.example b/.env.synctv.example index 37f54886..5992406b 100644 --- a/.env.synctv.example +++ b/.env.synctv.example @@ -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 diff --git a/Makefile b/Makefile index 71fa997c..ab35083f 100644 --- a/Makefile +++ b/Makefile @@ -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)'; \ diff --git a/docs/src/content/docs/configuration/full-example.mdx b/docs/src/content/docs/configuration/full-example.mdx index 95d3c276..76370863 100644 --- a/docs/src/content/docs/configuration/full-example.mdx +++ b/docs/src/content/docs/configuration/full-example.mdx @@ -64,6 +64,12 @@ curl -fsS http://localhost:8081/health/ready ```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 diff --git a/docs/src/content/docs/configuration/how-configuration-works.mdx b/docs/src/content/docs/configuration/how-configuration-works.mdx index 09eb3c11..85ce7e23 100644 --- a/docs/src/content/docs/configuration/how-configuration-works.mdx +++ b/docs/src/content/docs/configuration/how-configuration-works.mdx @@ -115,7 +115,8 @@ export SYNCTV_JWT_SECRET_FILE=/run/secrets/jwt_secret 会受 `data_dir` 影响的路径: - `management.unix_socket_path` -- `.logging.output.path` for `server`, `health`, `metrics`, `cluster`, and `management` +- `logging.output.path` +- `.logging.output.path`,其中 `` 为 `server`、`health`、`metrics`、`management`、`cluster`、`livestream` 或 `webrtc` - `livestream.hls_storage.path` - `proxy_slice_cache.file_cache_dir` diff --git a/docs/src/content/docs/configuration/server-and-runtime.mdx b/docs/src/content/docs/configuration/server-and-runtime.mdx index 00481e84..e8c5acf0 100644 --- a/docs/src/content/docs/configuration/server-and-runtime.mdx +++ b/docs/src/content/docs/configuration/server-and-runtime.mdx @@ -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`) | | `.logging.level` | `info`(metrics/cluster 为 `warn`) | 组件日志级别 | | `.logging.format` | `text` | 组件日志格式(`text` 或 `json`) | | `.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` diff --git a/docs/src/content/docs/en/configuration/full-example.mdx b/docs/src/content/docs/en/configuration/full-example.mdx index 3c587cc3..ad4a7cef 100644 --- a/docs/src/content/docs/en/configuration/full-example.mdx +++ b/docs/src/content/docs/en/configuration/full-example.mdx @@ -64,6 +64,12 @@ Production Compose users should maintain `.env.postgres`, `.env.redis`, and `.en ```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 diff --git a/docs/src/content/docs/en/configuration/how-configuration-works.mdx b/docs/src/content/docs/en/configuration/how-configuration-works.mdx index 1c5f5e6d..8ac91754 100644 --- a/docs/src/content/docs/en/configuration/how-configuration-works.mdx +++ b/docs/src/content/docs/en/configuration/how-configuration-works.mdx @@ -119,7 +119,8 @@ Relative `*_file` paths are resolved relative to the configuration file director Paths affected by `data_dir`: - `management.unix_socket_path` -- `.logging.output.path` for `server`, `health`, `metrics`, `cluster`, and `management` +- `logging.output.path` +- `.logging.output.path`, where `` is `server`, `health`, `metrics`, `management`, `cluster`, `livestream`, or `webrtc` - `livestream.hls_storage.path` - `proxy_slice_cache.file_cache_dir` diff --git a/docs/src/content/docs/en/configuration/server-and-runtime.mdx b/docs/src/content/docs/en/configuration/server-and-runtime.mdx index 96d43e22..c8772fcd 100644 --- a/docs/src/content/docs/en/configuration/server-and-runtime.mdx +++ b/docs/src/content/docs/en/configuration/server-and-runtime.mdx @@ -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`) | | `.logging.level` | `info` (`warn` for metrics/cluster) | Component log level | | `.logging.format` | `text` | Component log format (`text` or `json`) | | `.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 diff --git a/docs/src/content/docs/en/reference/configuration-index.mdx b/docs/src/content/docs/en/reference/configuration-index.mdx index faddec4d..a8174d59 100644 --- a/docs/src/content/docs/en/reference/configuration-index.mdx +++ b/docs/src/content/docs/en/reference/configuration-index.mdx @@ -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. `` 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 | | `.logging.level` | `error`, `warn`, `info`, `debug`, or `trace` | `info` (`warn` for metrics/cluster) | Component log level | | `.logging.format` | `text` or `json` | `text` | Component log format | | `.logging.output` | `stdout`, `stderr`, or a file object | `stdout` | Independent component output | +| `.logging.output.path` | file path | `""` | Component log file path; relative paths resolve under `data_dir` | | `.logging.output.rotation.strategy` | `daily`, `hourly`, or `never` | `daily` | File rotation period | | `.logging.output.rotation.max_files` | positive integer | `30` | Files retained per component | | `.logging.color` | `auto`, `always`, or `never` | `auto` | Component color output | diff --git a/docs/src/content/docs/en/reference/environment-variables.mdx b/docs/src/content/docs/en/reference/environment-variables.mdx index c5b70be9..1bbfee55 100644 --- a/docs/src/content/docs/en/reference/environment-variables.mdx +++ b/docs/src/content/docs/en/reference/environment-variables.mdx @@ -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` | diff --git a/docs/src/content/docs/reference/configuration-index.mdx b/docs/src/content/docs/reference/configuration-index.mdx index 3f7c5051..9fad730d 100644 --- a/docs/src/content/docs/reference/configuration-index.mdx +++ b/docs/src/content/docs/reference/configuration-index.mdx @@ -198,6 +198,8 @@ curl -fsS http://localhost:8081/health/ready ## `jwt` 和 `logging` +顶层 `logging` 接收数据库、Redis、缓存、代理、realtime、outbox、迁移、启动流程、第三方依赖和其余 target 的日志。`` 可取 `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` | 全局日志颜色输出 | | `.logging.level` | `error`、`warn`、`info`、`debug`、`trace` | `info`(metrics/cluster 为 `warn`) | 组件日志级别 | | `.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` | 组件颜色输出 | diff --git a/docs/src/content/docs/reference/environment-variables.mdx b/docs/src/content/docs/reference/environment-variables.mdx index f14fda23..a62c6899 100644 --- a/docs/src/content/docs/reference/environment-variables.mdx +++ b/docs/src/content/docs/reference/environment-variables.mdx @@ -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` | ## 管理端点 diff --git a/helm/synctv/README.md b/helm/synctv/README.md index 55a39dd8..87f9e218 100644 --- a/helm/synctv/README.md +++ b/helm/synctv/README.md @@ -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 | diff --git a/helm/synctv/templates/configmap.yaml b/helm/synctv/templates/configmap.yaml index 8743440c..f69126d9 100644 --- a/helm/synctv/templates/configmap.yaml +++ b/helm/synctv/templates/configmap.yaml @@ -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 }} diff --git a/helm/synctv/values.yaml b/helm/synctv/values.yaml index ccc1967d..8bcbff45 100644 --- a/helm/synctv/values.yaml +++ b/helm/synctv/values.yaml @@ -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: diff --git a/synctv-api-common/src/metrics_auth.rs b/synctv-api-common/src/metrics_auth.rs index 2bb9a073..6902a18c 100644 --- a/synctv-api-common/src/metrics_auth.rs +++ b/synctv-api-common/src/metrics_auth.rs @@ -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 } diff --git a/synctv-core/src/logging.rs b/synctv-core/src/logging.rs index 251c5d0a..b22e048f 100644 --- a/synctv-core/src/logging.rs +++ b/synctv-core/src/logging.rs @@ -62,20 +62,22 @@ pub struct ComponentLoggingOptions { #[derive(Debug, Clone)] pub struct LoggingOptions { + pub global: ComponentLoggingOptions, pub components: Vec, } 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 { let (subscriber, guards) = build_subscriber(config)?; subscriber @@ -135,26 +137,26 @@ type LoggingSubscriber = Layered + Send + Sync>>, Re fn build_subscriber(config: &LoggingOptions) -> anyhow::Result<(LoggingSubscriber, LoggingGuards)> { validate_component_routes(config)?; - let specialized_targets: Vec = config + let component_targets: Vec = config .components .iter() - .filter(|component| component.name != "server") .flat_map(|component| component.targets.iter().cloned()) .collect(); let mut layers: Vec + 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, +) -> 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 { } pub fn effective_log_level(config: &LoggingOptions) -> anyhow::Result { - 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('{')); } diff --git a/synctv.example.yaml b/synctv.example.yaml index ae1ee20e..dccb9c68 100644 --- a/synctv.example.yaml +++ b/synctv.example.yaml @@ -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__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 -# - .logging.output.path for server, health, metrics, cluster, and management +# - logging.output.path +# - .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.: diff --git a/synctv/src/app.rs b/synctv/src/app.rs index 3f844ce4..77d22cbc 100644 --- a/synctv/src/app.rs +++ b/synctv/src/app.rs @@ -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, diff --git a/synctv/src/app_config/mod.rs b/synctv/src/app_config/mod.rs index 6b8eb620..2cd8d1e0 100644 --- a/synctv/src/app_config/mod.rs +++ b/synctv/src/app_config/mod.rs @@ -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(), diff --git a/synctv/src/app_config/validation.rs b/synctv/src/app_config/validation.rs index 30b456f5..f7f96981 100644 --- a/synctv/src/app_config/validation.rs +++ b/synctv/src/app_config/validation.rs @@ -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::().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( diff --git a/synctv/src/bootstrap/database.rs b/synctv/src/bootstrap/database.rs index 93059592..b2f0d1c7 100644 --- a/synctv/src/bootstrap/database.rs +++ b/synctv/src/bootstrap/database.rs @@ -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) diff --git a/synctv/src/config_env.rs b/synctv/src/config_env.rs index 3fab6957..689a4e9f 100644 --- a/synctv/src/config_env.rs +++ b/synctv/src/config_env.rs @@ -98,6 +98,7 @@ fn apply_redis_url_component_env_overrides( /// Format: `SYNCTV_
_=` /// /// 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() + ); } } diff --git a/synctv/src/config_loader.rs b/synctv/src/config_loader.rs index 5aecf9fd..8d0f4c04 100644 --- a/synctv/src/config_loader.rs +++ b/synctv/src/config_loader.rs @@ -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() { diff --git a/synctv/src/resource_options.rs b/synctv/src/resource_options.rs index d8a7d774..7540b260 100644 --- a/synctv/src/resource_options.rs +++ b/synctv/src/resource_options.rs @@ -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())); + } } diff --git a/synctv/tests/cluster_startup_failure_tests.rs b/synctv/tests/cluster_startup_failure_tests.rs index 848d16f7..25d1301c 100644 --- a/synctv/tests/cluster_startup_failure_tests.rs +++ b/synctv/tests/cluster_startup_failure_tests.rs @@ -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, diff --git a/synctv/tests/full_stack_e2e_tests.rs b/synctv/tests/full_stack_e2e_tests.rs index a68066a3..afa574b4 100644 --- a/synctv/tests/full_stack_e2e_tests.rs +++ b/synctv/tests/full_stack_e2e_tests.rs @@ -493,14 +493,15 @@ static TEST_LOGGING: OnceLock = 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")