Skip to content

Commit 7f97400

Browse files
committed
refactor metrics, add generic operation metrics, add dedicated logging class
1 parent 1f99418 commit 7f97400

25 files changed

Lines changed: 1376 additions & 433 deletions

.envrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
export BRANCH_NAME=$(git branch --show-current | tr '/[:upper:]' '-[:lower:]')
2+
export RENAMARR_OTEL_ENABLED="true"
3+
export OTEL_SERVICE_NAME=renamarr
4+
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319

.github/workflows/buildImage.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,21 @@ jobs:
5050
exit 1
5151
fi
5252
- name: Checkout repository
53-
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
53+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
5454
- name: Set up QEMU
55-
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
55+
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
5656
- name: Set up Docker Buildx
57-
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
57+
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
5858

5959
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
6060
- name: Log in to the Container registry
61-
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
61+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
6262
with:
6363
registry: ${{ env.REGISTRY }}
6464
username: ${{ github.actor }}
6565
password: ${{ secrets.GITHUB_TOKEN }}
6666
- name: Log in to Docker Hardened Images registry
67-
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
67+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
6868
with:
6969
registry: ${{ env.DHI_REGISTRY }}
7070
username: ${{ vars.DHI_USERNAME }}
@@ -73,7 +73,7 @@ jobs:
7373
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
7474
- name: Extract metadata (tags, labels) for Docker
7575
id: meta
76-
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
76+
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
7777
with:
7878
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
7979
flavor: |
@@ -99,7 +99,7 @@ jobs:
9999
env:
100100
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
101101
- name: Build and push Docker image
102-
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
102+
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
103103
with:
104104
context: .
105105
file: Dockerfile

.github/workflows/python-ci.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ jobs:
1717
#----------------------------------------------
1818
# check-out repo and set-up python
1919
#----------------------------------------------
20-
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
20+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
2121
- name: Set up Python
22-
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
22+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
2323
with:
2424
python-version: '3.14'
2525
architecture: x64
2626
- name: Set up uv
27-
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
27+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
2828
with:
2929
enable-cache: true
3030
- name: Install dependencies
@@ -36,15 +36,15 @@ jobs:
3636
- name: Audit dependencies with uv
3737
run: uv audit --frozen --preview-features audit
3838
- name: Upload coverage
39-
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
39+
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
4040
with:
4141
token: ${{ secrets.CODECOV_TOKEN }}
4242
report_type: coverage
4343
disable_search: true
4444
files: ./coverage.xml
4545
fail_ci_if_error: true
4646
- name: Upload test results to Codecov
47-
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
47+
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
4848
with:
4949
token: ${{ secrets.CODECOV_TOKEN }}
5050
report_type: test_results

.github/workflows/renovate-validate.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ jobs:
1818
validate:
1919
runs-on: ubuntu-latest
2020
steps:
21-
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
22-
- uses: suzuki-shunsuke/github-action-renovate-config-validator@ee9f69e1f683ed0d08225086482b34fc9abe9300 # v2.1.0
21+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
22+
- uses: suzuki-shunsuke/github-action-renovate-config-validator@ee9f69e1f683ed0d08225086482b34fc9abe9300 # v2.1.0
2323
with:
2424
config_file_path: renovate.json
2525
strict: 'true'

src/main.py

Lines changed: 20 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import os
22
from collections.abc import Callable
33
from contextlib import contextmanager
4-
from sys import stdout
54
from time import perf_counter
65
from time import sleep
6+
from time import time
77

88
import schedule
99
from dotenv import load_dotenv
@@ -12,12 +12,8 @@
1212
from pyconfigparser import ConfigError, ConfigFileNotFoundError, configparser
1313

1414
from config_schema import CONFIG_SCHEMA
15-
from renamarr.observability import (
16-
ServiceName,
17-
configure_observability,
18-
enrich_log_record_with_trace,
19-
is_otel_enabled,
20-
)
15+
from renamarr.logging_config import LoggingConfigurator
16+
from renamarr.observability import ServiceName, configure_observability
2117
from renamarr.radarr.services.renamarr import RadarrRenamarr
2218
from renamarr.sonarr.services.renamarr import SonarrRenamarr
2319
from renamarr.sonarr.services.series_scanner import SonarrSeriesScanner
@@ -29,77 +25,13 @@ class Main:
2925
"""
3026

3127
RUN_SCHEDULER = True
32-
_LOG_FORMAT = (
33-
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
34-
"<level>{level}</level> | "
35-
"{extra[instance]} | "
36-
"{extra[item]} | "
37-
"<level>{message}</level>"
38-
)
39-
_DEBUG_LOG_FORMAT = (
40-
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
41-
"<level>{level}</level> | "
42-
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
43-
"{extra[instance]} | "
44-
"{extra[item]} | "
45-
"<level>{message}</level>"
46-
)
47-
_TRACE_LOG_FORMAT = "trace_id={extra[trace_id]} | span_id={extra[span_id]} | "
4828

4929
def __init__(self):
5030
load_dotenv(".env.local")
51-
log_level = os.getenv("LOG_LEVEL", "INFO")
52-
53-
otel_enabled = is_otel_enabled()
54-
base_logger_format = (
55-
self._DEBUG_LOG_FORMAT if log_level.upper() == "DEBUG" else self._LOG_FORMAT
56-
)
57-
self._logger_format = (
58-
base_logger_format.replace(
59-
"<level>{message}</level>",
60-
f"{self._TRACE_LOG_FORMAT}<level>{{message}}</level>",
61-
)
62-
if otel_enabled
63-
else base_logger_format
64-
)
65-
log_extra = {"instance": "", "item": ""}
66-
if otel_enabled:
67-
log_extra |= {"trace_id": "", "span_id": ""}
68-
logger.configure(
69-
extra=log_extra,
70-
patcher=enrich_log_record_with_trace if otel_enabled else None,
71-
)
72-
logger.remove()
73-
logger.add(stdout, format=self._logger_format, level=log_level)
31+
self._logging_configurator = LoggingConfigurator()
32+
self._logging_configurator.configure_stdout()
7433
self.observability = configure_observability()
7534

76-
def __configure_file_logging(self, service: str, instance_name: str) -> bool:
77-
log_dir = os.getenv("LOG_DIR", "/logs")
78-
log_rotation = os.getenv("LOG_ROTATION", "00:00")
79-
log_retention = os.getenv("LOG_RETENTION", "7 days")
80-
log_path = os.path.join(log_dir, service, f"{instance_name}.log")
81-
try:
82-
logger.add(
83-
log_path,
84-
format=self._logger_format,
85-
level=os.getenv("LOG_LEVEL", "INFO"),
86-
rotation=log_rotation,
87-
retention=log_retention,
88-
# filter ensures that instance logs go to the correct file
89-
filter=lambda record, configured_service=service, configured_name=instance_name: (
90-
record["extra"].get("service") == configured_service
91-
and record["extra"].get("instance") == configured_name
92-
),
93-
)
94-
except OSError as exc:
95-
with logger.contextualize(service=service, instance=instance_name):
96-
logger.warning(
97-
f"Unable to write logs to {log_path!r}; continuing with stdout logging only."
98-
)
99-
logger.warning(exc)
100-
return False
101-
return True
102-
10335
def __external_cron(self) -> bool:
10436
return os.getenv("EXTERNAL_CRON", "false").lower() == "true"
10537

@@ -111,6 +43,12 @@ def __run_observed_job(
11143
job: Callable[[], None],
11244
) -> None:
11345
start_time = perf_counter()
46+
self.observability.record_job_started(
47+
service,
48+
instance_name,
49+
job_name,
50+
time(),
51+
)
11452
result = "success"
11553
try:
11654
with self.observability.start_span(
@@ -125,6 +63,9 @@ def __run_observed_job(
12563
except CliArrError as exc:
12664
result = "failed"
12765
logger.error(exc)
66+
except Exception:
67+
result = "failed"
68+
raise
12869
finally:
12970
self.observability.record_job(
13071
service,
@@ -246,13 +187,17 @@ def __start(self) -> None:
246187
self.__schedule_sonarr_series_scanner(sonarr_config)
247188
if sonarr_config.renamarr.enabled:
248189
if sonarr_config.renamarr.log_to_file:
249-
self.__configure_file_logging("sonarr", sonarr_config.name)
190+
self._logging_configurator.configure_instance_file(
191+
"sonarr", sonarr_config.name
192+
)
250193
self.__schedule_sonarr_renamarr(sonarr_config)
251194

252195
for radarr_config in config.radarr:
253196
if radarr_config.renamarr.enabled:
254197
if radarr_config.renamarr.log_to_file:
255-
self.__configure_file_logging("radarr", radarr_config.name)
198+
self._logging_configurator.configure_instance_file(
199+
"radarr", radarr_config.name
200+
)
256201
self.__schedule_radarr_renamarr(radarr_config)
257202
else:
258203
with logger.contextualize(instance=radarr_config.name):

src/renamarr/logging_config.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import os
2+
from sys import stdout
3+
4+
from loguru import logger
5+
6+
from renamarr.observability import enrich_log_record_with_trace, is_otel_enabled
7+
8+
9+
class LoggingConfigurator:
10+
"""Configure Renamarr Loguru sinks."""
11+
12+
_LOG_FORMAT = (
13+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
14+
"<level>{level}</level> | "
15+
"{extra[instance]} | "
16+
"{extra[item]} | "
17+
"<level>{message}</level>"
18+
)
19+
_DEBUG_LOG_FORMAT = (
20+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
21+
"<level>{level}</level> | "
22+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
23+
"{extra[instance]} | "
24+
"{extra[item]} | "
25+
"<level>{message}</level>"
26+
)
27+
_TRACE_LOG_FORMAT = "trace_id={extra[trace_id]} | span_id={extra[span_id]} | "
28+
29+
def configure_stdout(self) -> None:
30+
"""Configure the default stdout sink."""
31+
otel_enabled = is_otel_enabled()
32+
logger.configure(
33+
extra={
34+
"service": "",
35+
"instance": "",
36+
"item": "",
37+
"trace_id": "",
38+
"span_id": "",
39+
},
40+
patcher=enrich_log_record_with_trace if otel_enabled else None,
41+
)
42+
logger.remove()
43+
logger.add(stdout, **self._logger_options(otel_enabled))
44+
45+
def configure_instance_file(self, service: str, instance_name: str) -> bool:
46+
"""Configure a file sink for a service instance."""
47+
log_dir = os.getenv("LOG_DIR", "/logs")
48+
log_path = os.path.join(log_dir, service, f"{instance_name}.log")
49+
try:
50+
logger.add(
51+
log_path,
52+
**self._logger_options(is_otel_enabled()),
53+
rotation=os.getenv("LOG_ROTATION", "00:00"),
54+
retention=os.getenv("LOG_RETENTION", "7 days"),
55+
filter=lambda record, configured_service=service, configured_name=instance_name: (
56+
record["extra"].get("service") == configured_service
57+
and record["extra"].get("instance") == configured_name
58+
),
59+
)
60+
except OSError as exc:
61+
with logger.contextualize(service=service, instance=instance_name):
62+
logger.warning(
63+
f"Unable to write logs to {log_path!r}; continuing with stdout logging only."
64+
)
65+
logger.warning(exc)
66+
return False
67+
return True
68+
69+
def _logger_options(self, otel_enabled: bool) -> dict[str, object]:
70+
log_level = os.getenv("LOG_LEVEL", "INFO")
71+
if os.getenv("LOG_FORMAT", "text").lower() == "json":
72+
return {"level": log_level, "serialize": True}
73+
return {
74+
"format": self._logger_format(log_level, otel_enabled),
75+
"level": log_level,
76+
}
77+
78+
def _logger_format(self, log_level: str, otel_enabled: bool) -> str:
79+
base_logger_format = (
80+
self._DEBUG_LOG_FORMAT
81+
if log_level.upper() == "DEBUG"
82+
else self._LOG_FORMAT
83+
)
84+
if not otel_enabled:
85+
return base_logger_format
86+
return base_logger_format.replace(
87+
"<level>{message}</level>",
88+
f"{self._TRACE_LOG_FORMAT}<level>{{message}}</level>",
89+
)

0 commit comments

Comments
 (0)