diff --git a/frigate/analytics/reporter.py b/frigate/analytics/reporter.py index e77a5d952d..0fc5f7e2c1 100644 --- a/frigate/analytics/reporter.py +++ b/frigate/analytics/reporter.py @@ -8,6 +8,10 @@ from collections.abc import Callable from multiprocessing.synchronize import Event as MpEvent from typing import TYPE_CHECKING +from pydantic import ValidationError +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError + from frigate.analytics.context import ReportContext from frigate.analytics.report import build_report from frigate.analytics.state import ( @@ -21,8 +25,10 @@ from frigate.analytics.state import ( from frigate.analytics.transport import SendOutcome, send_report from frigate.config import FrigateConfig from frigate.config.holder import ConfigHolder +from frigate.config.telemetry import TelemetryConfig from frigate.const import ANALYTICS_URL from frigate.notices import raise_notice, resolve_notice +from frigate.util.config import find_config_file if TYPE_CHECKING: from frigate.notices.registry import NoticeRegistry @@ -37,6 +43,24 @@ FIRST_DELAY_S = (15 * 60, 45 * 60) PROMPT_KIND = "analytics_prompt" +def saved_consent(path: str) -> bool | None: + """telemetry.analytics as saved in the config file, or None if unreadable.""" + try: + with open(path) as f: + raw = YAML(typ="safe").load(f) + except (OSError, ValueError, YAMLError): + return None + + if not isinstance(raw, dict): + return None + + # the live config's model, so yes, on, and 1 read as consent there too + try: + return TelemetryConfig.model_validate(raw.get("telemetry") or {}).analytics + except ValidationError: + return None + + class AnalyticsReporter(threading.Thread): """Follows the live config, so a settings save needs no restart.""" @@ -48,6 +72,7 @@ class AnalyticsReporter(threading.Thread): stop_event: MpEvent | threading.Event, *, state_path: str = STATE_PATH, + config_path: str | None = None, url: str = ANALYTICS_URL, send: Callable[[str, str], SendOutcome] = send_report, clock: Callable[[], float] = time.time, @@ -59,6 +84,7 @@ class AnalyticsReporter(threading.Thread): self.notice_registry = notice_registry self.stop_event = stop_event self.state_path = state_path + self.config_path = config_path or find_config_file() self.url = url self.send = send self.clock = clock @@ -125,11 +151,18 @@ class AnalyticsReporter(threading.Thread): if now < self.first_due: return - state = load_state(self.state_path) or new_state() + loaded = load_state(self.state_path) + state = loaded or new_state() if not self._due(state.last_attempt_at, now): return + # a YAML editor save or a hand edit reaches the live config only on + # restart, so an opt-out saved that way is read from the file + if saved_consent(self.config_path) is False: + delete_state(self.state_path) + return + # saved before sending, so a failing endpoint or a crash mid-send # still waits a full interval; without saved state every boot would # send under a new install ID @@ -146,7 +179,7 @@ class AnalyticsReporter(threading.Thread): self.interval = self._next_interval() # the lock stays free during the request, so a save never waits on it - self._send(state.install_id, now) + self._send(state.install_id, now, fresh=loaded is None) def _due(self, last_attempt_at: float, now: float) -> bool: # a last attempt stamped in the future came from a wrong clock @@ -154,7 +187,12 @@ class AnalyticsReporter(threading.Thread): now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S ) - def _send(self, install_id: str, now: float) -> None: + def _send(self, install_id: str, now: float, fresh: bool) -> None: + # a new install ID counts notices from the opt-in, so nothing raised + # before consent is sent + if fresh: + self.notice_registry.mark_reported(self.notice_registry.stats()) + notice_stats = self.notice_registry.stats() ctx = ReportContext( config=self.config_holder.config, diff --git a/frigate/test/test_analytics_reporter.py b/frigate/test/test_analytics_reporter.py index bc16e41f08..eed723b862 100644 --- a/frigate/test/test_analytics_reporter.py +++ b/frigate/test/test_analytics_reporter.py @@ -4,7 +4,7 @@ import os import tempfile import threading import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch from frigate.analytics import reporter as reporter_module from frigate.analytics.reporter import ( @@ -38,6 +38,8 @@ class TestAnalyticsReporter(unittest.TestCase): self.dir = tempfile.TemporaryDirectory() self.addCleanup(self.dir.cleanup) self.path = os.path.join(self.dir.name, ".analytics.json") + self.config_path = os.path.join(self.dir.name, "config.yml") + self.save_config("telemetry:\n analytics: true\n") self.now = 1_790_000_000.0 self.send = Mock(return_value=SendOutcome.accepted) self.registry = Mock() @@ -57,6 +59,14 @@ class TestAnalyticsReporter(unittest.TestCase): self.build_report = patcher.start() self.addCleanup(patcher.stop) + def save_config(self, text: str) -> None: + with open(self.config_path, "w") as f: + f.write(text) + + def seed_state(self) -> None: + # an install that has reported before, so no fresh-ID reset runs + save_state(AnalyticsState("a" * 32, 0.0), self.path) + def reporter(self, path: str | None = None) -> AnalyticsReporter: # uniform(a, b) returns a: the shortest startup delay and interval rng = Mock() @@ -68,6 +78,7 @@ class TestAnalyticsReporter(unittest.TestCase): self.registry, threading.Event(), state_path=path or self.path, + config_path=self.config_path, url="https://example.test/report", send=self.send, clock=lambda: self.now, @@ -78,6 +89,7 @@ class TestAnalyticsReporter(unittest.TestCase): self.now += seconds def test_waits_for_the_startup_delay_then_sends(self): + self.seed_state() reporter = self.reporter() reporter.tick() @@ -116,6 +128,7 @@ class TestAnalyticsReporter(unittest.TestCase): def test_a_failed_send_keeps_watermarks_and_waits_a_full_interval(self): self.send.return_value = SendOutcome.failed + self.seed_state() reporter = self.reporter() self.advance(FIRST_DELAY_S[0]) @@ -128,6 +141,7 @@ class TestAnalyticsReporter(unittest.TestCase): def test_an_accepted_report_without_health_keeps_the_watermarks(self): self.built.health = None + self.seed_state() reporter = self.reporter() self.advance(FIRST_DELAY_S[0]) @@ -136,6 +150,56 @@ class TestAnalyticsReporter(unittest.TestCase): self.send.assert_called_once() self.registry.mark_reported.assert_not_called() + def test_a_new_install_id_skips_notices_from_before_the_opt_in(self): + before = [{**SNAPSHOT[0], "occurrences": 2}] + self.registry.stats.side_effect = [before, SNAPSHOT] + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + # the reset runs before the report's snapshot, which the 204 then marks + self.assertEqual( + self.registry.mark_reported.call_args_list, [call(before), call(SNAPSHOT)] + ) + self.assertIs(self.build_report.call_args.args[0].notice_stats, SNAPSHOT) + + def test_an_opt_out_saved_only_to_the_file_stops_the_send(self): + self.seed_state() + self.save_config("telemetry:\n analytics: false\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_not_called() + self.assertFalse(os.path.exists(self.path)) + self.raise_notice.assert_not_called() + + def test_yaml_truthy_spellings_in_the_file_count_as_consent(self): + for value in ("yes", "on", "1", "'true'"): + with self.subTest(value=value): + self.seed_state() + self.send.reset_mock() + self.save_config(f"telemetry:\n analytics: {value}\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_called_once() + self.assertTrue(os.path.exists(self.path)) + + def test_an_unreadable_config_file_falls_back_to_the_live_config(self): + self.seed_state() + self.save_config("telemetry: [unclosed\n") + reporter = self.reporter() + self.advance(FIRST_DELAY_S[0]) + + reporter.tick() + + self.send.assert_called_once() + def test_consent_withdrawn_while_the_report_builds_stops_the_send(self): reporter = self.reporter() self.advance(FIRST_DELAY_S[0])