mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-02 20:00:51 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user