Files
frigate/frigate/api/fastapi_app.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

207 lines
7.1 KiB
Python

import asyncio
import logging
import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from joserfc.jwk import OctKey
from playhouse.sqliteq import SqliteQueueDatabase
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette_context import middleware, plugins
from starlette_context.plugins import Plugin
from frigate.api import (
analytics,
auth,
camera,
chat,
classification,
debug_replay,
event,
export,
hardware,
media,
motion_search,
notices,
notification,
preview,
record,
review,
)
from frigate.api import app as main_app
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
)
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
from frigate.embeddings import EmbeddingsContext
from frigate.genai import GenAIClientManager
from frigate.notices.registry import NoticeRegistry
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer
logger = logging.getLogger(__name__)
def check_csrf(request: Request) -> bool:
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
return True
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return False
return True
# Used to retrieve the remote-user header: https://starlette-context.readthedocs.io/en/latest/plugins.html#easy-mode
class RemoteUserPlugin(Plugin):
key = "Remote-User"
def create_fastapi_app(
frigate_config: FrigateConfig,
database: SqliteQueueDatabase,
embeddings: EmbeddingsContext | None,
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
stats_emitter: StatsEmitter,
event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: DebugReplayManager,
dispatcher: Dispatcher | None = None,
profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True,
config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | None = None,
):
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
yield
logger.info("Starting FastAPI app")
app = FastAPI(
debug=False,
lifespan=lifespan,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin
else [],
)
# update the request_address with the x-forwarded-for header from nginx
# https://starlette-context.readthedocs.io/en/latest/plugins.html#forwarded-for
app.add_middleware(
middleware.ContextMiddleware,
plugins=(plugins.ForwardedForPlugin(),),
)
# Middleware to connect to DB before and close connection after request
# https://github.com/fastapi/full-stack-fastapi-template/issues/224#issuecomment-737423886
# https://fastapi.tiangolo.com/tutorial/middleware/#before-and-after-the-response
@app.middleware("http")
async def frigate_middleware(request: Request, call_next):
# Before request
if not check_csrf(request):
return JSONResponse(
content={"success": False, "message": "Missing CSRF header"},
status_code=401,
)
if database.is_closed():
database.connect()
response = await call_next(request)
# After request https://stackoverflow.com/a/75487519
if not database.is_closed():
database.close()
return response
# Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False
else:
auth.rateLimiter.set_limit(frigate_config.auth.failed_login_rate_limit)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# Routes
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
app.include_router(analytics.router)
app.include_router(auth.router)
app.include_router(camera.router)
app.include_router(chat.router)
app.include_router(classification.router)
app.include_router(review.router)
app.include_router(main_app.router)
app.include_router(preview.router)
app.include_router(notification.router)
app.include_router(export.router)
app.include_router(hardware.router)
app.include_router(notices.router)
app.include_router(event.router)
app.include_router(media.router)
app.include_router(motion_search.router)
app.include_router(record.router)
app.include_router(debug_replay.router)
# App Properties
app.frigate_config = frigate_config
# snapshot the port nginx bound at startup, the live config can be swapped
app.auth_internal_port = frigate_config.networking.listen.internal_port
app.genai_manager = GenAIClientManager(frigate_config)
app.embeddings = embeddings
app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer
app.camera_error_image = None
app.onvif = onvif
app.stats_emitter = stats_emitter
app.notice_registry = notice_registry
app.event_metadata_updater = event_metadata_updater
app.config_publisher = config_publisher
app.replay_manager = replay_manager
app.dispatcher = dispatcher
app.profile_manager = profile_manager
app.config_holder = config_holder
if frigate_config.auth.enabled:
secret = get_jwt_secret()
key_bytes = None
if isinstance(secret, str):
# If the secret looks like hex (e.g., generated by secrets.token_hex), use raw bytes
if len(secret) % 2 == 0 and re.fullmatch(r"[0-9a-fA-F]+", secret or ""):
try:
key_bytes = bytes.fromhex(secret)
except ValueError:
key_bytes = secret.encode("utf-8")
else:
key_bytes = secret.encode("utf-8")
elif isinstance(secret, (bytes, bytearray)):
key_bytes = bytes(secret)
else:
key_bytes = str(secret).encode("utf-8")
app.jwt_token = OctKey.import_key(key_bytes)
app.user_sessions = auth.load_user_sessions()
else:
app.jwt_token = None
app.user_sessions = {}
return app