Files
frigate/frigate/analytics/report.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

92 lines
2.8 KiB
Python

"""Build a report from the section collectors."""
import logging
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from pydantic import BaseModel
from frigate.analytics.collectors import (
cameras,
detection,
features,
hardware,
health,
install,
)
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
from frigate.analytics.state import load_state
from frigate.config import FrigateConfig
if TYPE_CHECKING:
from frigate.notices.registry import NoticeRegistry
from frigate.stats.emitter import StatsEmitter
logger = logging.getLogger(__name__)
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
"install": install.collect,
"hardware": hardware.collect,
"detection": detection.collect,
"cameras": cameras.collect,
"features": features.collect,
"health": health.collect,
}
# shown in the preview until the first report creates a real ID
PREVIEW_INSTALL_ID = "0" * 32
def build_report(
ctx: ReportContext, install_id: str, sent_at: int | None = None
) -> AnalyticsReport:
"""Run every collector; one that fails sends its section as null."""
sections: dict[str, Any] = {}
for name, collect in COLLECTORS.items():
try:
sections[name] = collect(ctx)
except Exception:
# a collector bug must cost one section, never the whole report
logger.debug("Analytics %s section failed", name, exc_info=True)
sections[name] = None
return AnalyticsReport.model_validate(
{
"schema_version": SCHEMA_VERSION,
"install_id": install_id,
"report_id": str(uuid4()),
"sent_at": int(time.time()) if sent_at is None else sent_at,
**sections,
}
)
def gather_context(
config: FrigateConfig,
stats_emitter: "StatsEmitter | None",
notice_registry: "NoticeRegistry | None",
) -> ReportContext:
"""Snapshot the config, latest stats, and notice counts for one report."""
return ReportContext(
config=config,
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
notice_stats=notice_registry.stats() if notice_registry is not None else [],
)
def preview_report(
config: FrigateConfig,
stats_emitter: "StatsEmitter | None",
notice_registry: "NoticeRegistry | None",
) -> AnalyticsReport:
"""The report the next send would carry, without sending it."""
state = load_state()
# an opt-in resets the baseline, so before one there are no notices to show
ctx = gather_context(config, stats_emitter, notice_registry if state else None)
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)