honor file-only analytics opt-outs and skip pre-consent notices

An opt-out saved through the YAML editor's "Save only" or a hand edit never reached the live config, so reports kept going out under the old install ID until a restart. The reporter now reads telemetry.analytics from the config file when a report is due, validated with TelemetryConfig so yes, on, and 1 still count as consent, and an opt-out there deletes the state file and skips the send. The first report under a new install ID also carried every notice since the counters started, including ones from before the opt-in, so a fresh state file now resets the notice watermarks before the report's snapshot.
This commit is contained in:
Josh Hawkins
2026-11-01 11:52:15 -06:00
parent 00214bcec8
commit 72e0485cfd
2 changed files with 106 additions and 4 deletions
+41 -3
View File
@@ -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,
+65 -1
View File
@@ -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])