Files
frigate/frigate/test/test_analytics_state.py
T
Josh HawkinsandGitHub 4eb60af5a7 Implement opt-in analytics (#24440)
* Add analytics opt-in setting, prompt notice kind, and notice watermarks

* Add the analytics report schema

* Add the analytics state file

* Add the install and hardware analytics collectors

* Add the detection analytics collector

* Add the cameras analytics collector

* Add the features analytics collector

* Add the health analytics collector

* Build analytics reports from the section collectors

* Add the analytics report transport

* Send the daily analytics report from the main process

* Add the analytics preview API

* Generate the analytics schema and check it in CI

* Record the image variant in each Dockerfile

* Show the analytics preview in telemetry settings

* Document anonymous analytics

* change wording

* fixes

* update network requirements docs

* adapt analytics to free-form model scenes, split notice counts, and peewee 4

* fixes

* change log levels

* docstrings

* docstrings

* 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.

* take the notice baseline at opt-in instead of the first send

The watermark reset ran at the first send attempt, so notices raised between the opt-in and that send, up to 45 minutes after a startup, were absorbed into the reset and never reported. The reporter now writes the new install ID and resets the watermarks as soon as it sees consent without a state file.

* report notice counts cumulatively from a database baseline

Moving watermarks after each accepted send kept the install ID and the notice baseline out of step: pre-consent notices went out, notices raised before the first send were dropped, and a send that finished after an off/on toggle overwrote the new identity's baseline. health.notices now carries cumulative counts since the install ID was created. Creating an ID copies the lifetime counts into the existing reported_* columns before the state file names it, and nothing is written after a send, so the backend diffs consecutive reports per install ID.

* write held notice repeats before the analytics baseline reset

Repeats of batched kinds like failed_login wait in memory until the stats emitter flushes them, so a baseline reset taken while some were held left them out, and the next flush counted those pre-opt-in repeats as post-consent. The reset now writes held repeats to the notice history under the same lock before copying the counts.

* seed detector inference speed from the first inference

The average started at 0 so idle detectors read 0, but that made every detector's speed climb from 0 for its first couple dozen inferences after a start. Detectors now use the same InferenceSpeed helper as enrichments, so the first inference seeds the average and idle detectors still read 0.
2026-11-06 07:43:38 -07:00

65 lines
1.9 KiB
Python

"""Tests for the analytics state file."""
import os
import tempfile
import unittest
from frigate.analytics.state import (
AnalyticsState,
delete_state,
load_state,
new_state,
save_state,
)
class TestAnalyticsState(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.path = os.path.join(self.dir.name, ".analytics.json")
def write(self, content: str) -> None:
with open(self.path, "w") as f:
f.write(content)
def test_missing_file_loads_as_none(self):
self.assertIsNone(load_state(self.path))
def test_save_then_load_round_trips(self):
state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5)
self.assertTrue(save_state(state, self.path))
self.assertEqual(load_state(self.path), state)
def test_unusable_files_load_as_none(self):
for content in (
"{not json",
'["a"]',
'{"install_id": "short", "last_attempt_at": 1}',
'{"install_id": "' + "a" * 32 + '"}',
'{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}',
):
with self.subTest(content=content):
self.write(content)
self.assertIsNone(load_state(self.path))
def test_save_reports_failure_instead_of_raising(self):
missing = os.path.join(self.dir.name, "missing", ".analytics.json")
self.assertFalse(save_state(new_state(), missing))
def test_new_state_has_a_hex_id_and_no_attempt(self):
state = new_state()
self.assertRegex(state.install_id, r"^[0-9a-f]{32}$")
self.assertEqual(state.last_attempt_at, 0.0)
def test_delete_removes_the_file_and_tolerates_a_missing_one(self):
save_state(new_state(), self.path)
delete_state(self.path)
delete_state(self.path)
self.assertFalse(os.path.exists(self.path))