Files
frigate/frigate/test/test_analytics_transport.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
2.2 KiB
Python

"""Tests for sending a report."""
import unittest
from unittest.mock import Mock, patch
import requests
from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report
from frigate.version import VERSION
URL = "https://example.test/report"
def post(status: int = 204, text: str = "", side_effect: Exception | None = None):
return patch(
"frigate.analytics.transport.requests.post",
return_value=Mock(status_code=status, text=text),
side_effect=side_effect,
)
class TestSendReport(unittest.TestCase):
def test_posts_the_body_as_json_with_a_user_agent(self):
with post() as mock_post:
outcome = send_report(URL, '{"a":1}')
self.assertIs(outcome, SendOutcome.accepted)
args, kwargs = mock_post.call_args
self.assertEqual(args[0], URL)
self.assertEqual(kwargs["data"], b'{"a":1}')
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}")
self.assertFalse(kwargs["allow_redirects"])
self.assertEqual(kwargs["timeout"], TIMEOUT_S)
def test_maps_statuses_to_outcomes(self):
cases = (
(200, SendOutcome.accepted),
(400, SendOutcome.rejected),
(429, SendOutcome.rate_limited),
(302, SendOutcome.failed),
(500, SendOutcome.failed),
)
for status, outcome in cases:
with self.subTest(status=status), post(status):
self.assertIs(send_report(URL, "{}"), outcome)
def test_logs_the_rejection_reason(self):
with (
post(400, "install_id: bad"),
self.assertLogs("frigate.analytics.transport", "DEBUG") as logs,
):
send_report(URL, "{}")
self.assertIn("install_id: bad", logs.output[0])
def test_a_network_error_fails_without_raising(self):
# the user can't act on a failed send, so it stays out of warnings
with (
post(side_effect=requests.ConnectionError("down")),
self.assertNoLogs("frigate.analytics.transport", "WARNING"),
):
self.assertIs(send_report(URL, "{}"), SendOutcome.failed)