mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-15 18:13:01 +03:00
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.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user