Files
frigate/frigate/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

87 lines
2.3 KiB
Python

"""The install's analytics identity and last attempt time, kept under /config."""
import json
import logging
import os
import re
from dataclasses import dataclass
from uuid import uuid4
from frigate.const import CONFIG_DIR
logger = logging.getLogger(__name__)
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
@dataclass(frozen=True)
class AnalyticsState:
install_id: str
last_attempt_at: float
def new_state() -> AnalyticsState:
"""A fresh install ID that has never attempted a report."""
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
"""The saved state, or None when the file is missing or unusable."""
try:
with open(path) as f:
data = json.load(f)
except FileNotFoundError:
return None
except (OSError, ValueError):
logger.debug("Ignoring unreadable analytics state at %s", path)
return None
if not isinstance(data, dict):
return None
install_id = data.get("install_id")
last_attempt_at = data.get("last_attempt_at")
if (
not isinstance(install_id, str)
or not INSTALL_ID_PATTERN.fullmatch(install_id)
or isinstance(last_attempt_at, bool)
or not isinstance(last_attempt_at, int | float)
):
logger.debug("Ignoring invalid analytics state at %s", path)
return None
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
"""Write the state atomically, returning False when it can't be written."""
temp_path = f"{path}.tmp"
try:
with open(temp_path, "w") as f:
json.dump(
{
"install_id": state.install_id,
"last_attempt_at": state.last_attempt_at,
},
f,
)
os.replace(temp_path, path)
except OSError:
return False
return True
def delete_state(path: str = STATE_PATH) -> None:
"""Forget the install ID, so a later opt-in starts a fresh identity."""
try:
os.remove(path)
except FileNotFoundError:
pass
except OSError:
logger.warning("Unable to delete analytics state at %s", path)