Compare commits

..
Author SHA1 Message Date
Josh Hawkins c02a3c975a reuse pooled upstream connections for nginx auth subrequests
The /auth subrequest proxied to a variable address, which nginx can't pool, and its location-level headers dropped the inherited Connection header so nginx sent Connection: close. Every authenticated request opened and closed its own loopback connection to the API. It now proxies to the frigate_api upstream with an empty Connection header so the keepalive pool is reused.
2026-10-22 16:54:46 -05:00
Josh Hawkins 2f33e6e18f retry camera snapshots after a failed load
AutoUpdatingCameraImage only scheduled the next frame from onLoad, so a single failed latest.webp request froze the image until the component remounted. It now retries on error, dropping the periodic cache key so the retry requests a fresh URL.
2026-10-22 16:54:46 -05:00
Josh Hawkins e347a72918 return a 500 instead of raising when go2rtc is unreachable
The two go2rtc stream GETs had no exception handling or timeout, so a go2rtc outage raised a connection error with a traceback, and a hung go2rtc held the request open indefinitely. Both now catch request errors, log the cause, and use a timeout. The per-stream probe gets a 35s read timeout because go2rtc probes each source in turn, and one unresponsive source takes about 10s.
2026-10-22 16:54:46 -05:00
Josh Hawkins d415be5d93 detect all AMD GPUs from vainfo
The vainfo check only matched "AMD Radeon Graphics", so drivers reporting names like "AMD Radeon Vega 8 Graphics", "AMD Radeon RX 6600", "AMD Custom GPU 0405", or older codename strings fell through and their GPU stats were collected as Intel. It now matches Mesa's "for AMD" driver string.
2026-10-22 16:54:46 -05:00
Josh Hawkins 366bab84ac fix untranslated unknown resolution text in camera info dialog 2026-10-22 16:54:46 -05:00
Josh Hawkins ac5d21c4ca only cache preview frames when recording is enabled
The segment-end check tested the record config object instead of record.enabled, and the first frame after startup was written unconditionally. With recording disabled, the first segment still cached frames and encoded a preview.
2026-10-22 16:54:46 -05:00
Josh Hawkins 4c47c6c85d snapshot websocket clients under the manager lock in the output loop
The output loop iterated ws4py's live websocket dict without its lock, so a client connecting or disconnecting mid-iteration raised "dictionary changed size during iteration" and killed the output loop. It now copies the clients under manager.lock once per frame and skips terminated ones, matching the jsmpeg and birdseye broadcasters.
2026-10-22 16:54:46 -05:00
Josh Hawkins 15c7f435ee keep uvicorn idle connections open longer than nginx's upstream keepalive
uvicorn closed idle connections after its 5s default while nginx kept them pooled for 60s, so a request nginx sent on a connection uvicorn was closing got a 502. nginx retries that on a fresh connection for most methods, but not for POST or PATCH.
2026-10-22 16:54:46 -05:00
Josh Hawkins d30772645d expose frigate_camera_events as a gauge
The metric reports the current count of events in the database, which drops when retention prunes old events. As a counter, each prune read as a counter reset, so increase() and rate() inflated event counts. The series name changes from frigate_camera_events_total to frigate_camera_events. The test's porch/inside assertion was missing its `in event.text` and always passed.
2026-10-22 15:18:47 -05:00
Josh Hawkins 8fa8761a44 keep recording cleanup running after a failed pass
An exception anywhere in a cleanup pass, such as an OSError from a read-only or unmounted storage path, ended the recording cleanup thread with nothing logged. Retention stopped until restart, and recordings, previews and database rows kept growing. Each pass now logs the exception and the loop continues.
2026-10-22 15:15:43 -05:00
Josh Hawkins 4d20a15283 fix fps overflow restarting ffmpeg after 3 seconds instead of 3 retry intervals
This is a regression from 0.18. The watchdog has restarted ffmpeg after 3 consecutive fps overflow checks since #13651, which was 3 retry intervals (30s by default) when the loop ran every retry_interval. #21297 moved the loop to 1s ticks without converting the count, so the window shrank to 3s. camera_fps is a 10s average, so one burst of buffered frames after a stream hiccup could keep it over the limit long enough to restart a healthy camera, and the next reconnect burst could repeat it. The threshold is now 3 * retry_interval ticks. When the count filled while restarts were still rate limited, the watchdog logged "Exiting ffmpeg...", marked the camera offline and reset the count without restarting. The check now waits for can_restart, so the log, status and restart always happen together.
2026-10-22 15:15:43 -05:00
Josh Hawkins 463cf29386 materialize event counts in metrics endpoint
The metrics endpoint stored a lazy peewee query on the shared Prometheus collector, and collect() ran it later. Overlapping scrapes run in separate threadpool threads, so two requests could execute and step the same query's sqlite cursor at once. The rows are now read with list() before they reach the collector.
2026-10-22 15:15:43 -05:00
16 changed files with 104 additions and 61 deletions
@@ -1,10 +1,8 @@
set $upstream_auth http://127.0.0.1:5001/auth;
## Virtual endpoint created by nginx to forward auth requests.
location /auth {
## Essential Proxy Configuration
internal;
proxy_pass $upstream_auth;
proxy_pass http://frigate_api/auth;
## Headers
@@ -39,6 +37,7 @@ location /auth {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead
proxy_redirect http:// $scheme://;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 4 32k;
+4 -5
View File
@@ -69,7 +69,7 @@ These gauges report the operating system's figures for the whole filesystem (the
### Event Metrics
- `frigate_camera_events{camera="", label=""}` - Count of camera events since exporter started
- `frigate_camera_events{camera="", label=""}` - Current number of events stored in the database for each camera and label
## Configuring Prometheus
@@ -101,8 +101,8 @@ rate(frigate_detection_fps{camera_name="front_door"}[5m])
# Storage usage percentage
(frigate_storage_used_bytes / frigate_storage_total_bytes) * 100
# Event count by camera in last hour
increase(frigate_camera_events[1h])
# Number of events currently stored in the database by camera
sum by (camera) (frigate_camera_events)
```
## Grafana Dashboard
@@ -121,8 +121,7 @@ A sample Grafana dashboard JSON will be provided in a future update.
The metrics exposed by Frigate use the following Prometheus metric types:
- **Counter**: Cumulative values that only increase (e.g., `frigate_camera_events`)
- **Gauge**: Values that can go up and down (e.g., `frigate_cpu_usage_percent`)
- **Gauge**: Values that can go up and down (e.g., `frigate_cpu_usage_percent`, `frigate_camera_events`)
- **Info**: Key-value pairs for metadata (e.g., `frigate_storage_mount_type`)
For more information about Prometheus metric types, see the [Prometheus documentation](https://prometheus.io/docs/concepts/metric_types/).
+3 -1
View File
@@ -177,7 +177,9 @@ def metrics(request: Request):
# Retrieve the latest statistics and update the Prometheus metrics
stats = request.app.stats_emitter.get_latest_stats()
# query DB for count of events by camera, label
event_counts: list[dict[str, Any]] = (
# Materialize the rows; the collector is shared across request threads and a
# lazy query would step one sqlite cursor from two threads.
event_counts: list[dict[str, Any]] = list(
Event.select(Event.camera, Event.label, fn.Count())
.group_by(Event.camera, Event.label)
.dicts()
+23 -14
View File
@@ -84,9 +84,13 @@ def _is_valid_host(host: str) -> bool:
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
async def go2rtc_streams(request: Request):
r = await asyncio.to_thread(requests.get, "http://127.0.0.1:1984/api/streams")
if not r.ok:
logger.error("Failed to fetch streams from go2rtc")
try:
r = await asyncio.to_thread(
requests.get, "http://127.0.0.1:1984/api/streams", timeout=10
)
r.raise_for_status()
except requests.RequestException as e:
logger.error("Failed to fetch streams from go2rtc: %s", e)
return JSONResponse(
content=({"success": False, "message": "Error fetching stream data"}),
status_code=500,
@@ -121,16 +125,21 @@ async def go2rtc_streams(request: Request):
dependencies=[Depends(require_go2rtc_stream_access)],
)
def go2rtc_camera_stream(request: Request, stream_name: str):
r = requests.get(
"http://127.0.0.1:1984/api/streams",
params={
"src": stream_name,
"video": "all",
"audio": "all",
"microphone": "",
},
)
if not r.ok:
try:
r = requests.get(
"http://127.0.0.1:1984/api/streams",
params={
"src": stream_name,
"video": "all",
"audio": "all",
"microphone": "",
},
# go2rtc probes each source in turn, so one unresponsive source
# can take 10s or more
timeout=(2, 35),
)
r.raise_for_status()
except requests.RequestException as e:
camera_config = request.app.frigate_config.cameras.get(stream_name)
if camera_config is None:
@@ -140,7 +149,7 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
break
if camera_config and camera_config.enabled:
logger.error("Failed to fetch streams from go2rtc")
logger.error("Failed to fetch streams from go2rtc: %s", e)
return JSONResponse(
content=({"success": False, "message": "Error fetching stream data"}),
+2
View File
@@ -731,6 +731,8 @@ class FrigateApp:
host="127.0.0.1",
port=5001,
log_level="error",
# outlive nginx's 60s upstream keepalive so nginx closes idle connections first
timeout_keep_alive=75,
)
finally:
self.stop()
+10 -4
View File
@@ -285,11 +285,16 @@ class OutputProcess(FrigateProcess):
)
preview_write_times[camera] = frame_time
manager = websocket_server.manager
with manager.lock:
websockets = list(manager.websockets.values())
# send camera frame to ffmpeg process if websockets are connected
if any(
ws.environ["PATH_INFO"].endswith(camera)
not ws.terminated
and ws.environ["PATH_INFO"].endswith(camera)
and ws_has_camera_access(ws, camera, self.config)
for ws in websocket_server.manager
for ws in websockets
):
# write to the converter for the camera if clients are listening to the specific camera
jsmpeg_cameras[camera].write_frame(frame.tobytes())
@@ -301,9 +306,10 @@ class OutputProcess(FrigateProcess):
and (
self.config.birdseye.restream
or any(
ws.environ["PATH_INFO"].endswith("birdseye")
not ws.terminated
and ws.environ["PATH_INFO"].endswith("birdseye")
and ws_has_camera_access(ws, "birdseye", self.config)
for ws in websocket_server.manager
for ws in websockets
)
)
):
+6 -4
View File
@@ -392,18 +392,20 @@ class PreviewRecorder:
) -> None:
self.offline = False
# always write the first frame
if self.start_time == 0:
self.start_time = frame_time
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
if self.config.record.enabled:
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
return
# check if PREVIEW clip should be generated and cached frames reset
if frame_time >= self.segment_end:
if len(self.output_frames) > 0:
# save last frame to ensure consistent duration
if self.config.record:
if self.config.record.enabled:
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
+9 -6
View File
@@ -516,10 +516,13 @@ class RecordingCleanup(threading.Thread):
logger.info("Exiting recording cleanup...")
break
self.clean_tmp_previews()
try:
self.clean_tmp_previews()
if counter == 0:
self.clean_tmp_clips()
maybe_empty_dirs = self.expire_recordings()
remove_empty_directories(Path(RECORD_DIR), maybe_empty_dirs)
self.truncate_wal()
if counter == 0:
self.clean_tmp_clips()
maybe_empty_dirs = self.expire_recordings()
remove_empty_directories(Path(RECORD_DIR), maybe_empty_dirs)
self.truncate_wal()
except Exception:
logger.exception("Recording cleanup pass failed")
+2 -3
View File
@@ -5,7 +5,6 @@ from typing import Any
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from prometheus_client.core import (
REGISTRY,
CounterMetricFamily,
GaugeMetricFamily,
InfoMetricFamily,
)
@@ -472,9 +471,9 @@ class CustomCollector:
yield storage_total
yield storage_used
camera_events = CounterMetricFamily(
camera_events = GaugeMetricFamily(
"frigate_camera_events",
"Count of camera events since exporter started",
"Current number of events stored in the database per camera and label",
labels=["camera", "label"],
)
+9 -12
View File
@@ -563,23 +563,20 @@ class TestHttpApp(BaseTestHttp):
assert "# TYPE frigate_detection_total_fps gauge" in event.text
assert "frigate_detection_total_fps 13.7" in event.text
assert (
"# HELP frigate_camera_events_total Count of camera events since exporter started"
"# HELP frigate_camera_events Current number of events stored in the database per camera and label"
in event.text
)
assert "# TYPE frigate_camera_events_total counter" in event.text
assert "# TYPE frigate_camera_events gauge" in event.text
assert (
'frigate_camera_events_total{camera="front_door",label="Mock"} 3.0'
'frigate_camera_events{camera="front_door",label="Mock"} 3.0' in event.text
)
assert (
'frigate_camera_events{camera="front_door",label="inside"} 2.0'
in event.text
)
assert (
'frigate_camera_events_total{camera="front_door",label="inside"} 2.0'
'frigate_camera_events{camera="front_door",label="outside"} 2.0'
in event.text
)
assert (
'frigate_camera_events_total{camera="front_door",label="outside"} 2.0'
in event.text
)
assert (
'frigate_camera_events_total{camera="porch",label="Mock"} 2.0' in event.text
)
assert 'frigate_camera_events_total{camera="porch",label="inside"} 2.0'
assert 'frigate_camera_events{camera="porch",label="Mock"} 2.0' in event.text
assert 'frigate_camera_events{camera="porch",label="inside"} 2.0' in event.text
+1 -1
View File
@@ -261,7 +261,7 @@ def is_vaapi_amd_driver() -> bool:
output = p.stdout.decode("unicode_escape").split("\n")
# VA Info will print out the friendly name of the driver
return any("AMD Radeon Graphics" in line for line in output)
return any(" for AMD " in line for line in output)
def get_amd_gpu_stats() -> dict[str, str] | None:
+6 -4
View File
@@ -483,16 +483,18 @@ class CameraWatchdog(threading.Thread):
elif self.camera_fps.value >= (self.config.detect.fps + 10):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
# Require 3 retry intervals of sustained overflow. camera_fps
# averages the last 10s, so one burst of buffered frames stays
# over the limit for several 1s ticks.
if self.fps_overflow_count >= 3 * self.sleeptime and can_restart:
self.detect_status.send("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
f"{self.config.name} exceeded fps limit. Exiting ffmpeg..."
)
if can_restart:
self.reset_capture_thread()
last_restart_time = now
self.reset_capture_thread()
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self.detect_status.send("offline", now)
self.camera_fps.value = 0
+3 -3
View File
@@ -5498,9 +5498,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -12,6 +12,7 @@ type AutoUpdatingCameraImageProps = {
};
const MIN_LOAD_TIMEOUT_MS = 200;
const ERROR_RETRY_MS = 1000;
export default function AutoUpdatingCameraImage({
camera,
@@ -68,6 +69,24 @@ export default function AutoUpdatingCameraImage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, setFps]);
const handleError = useCallback(() => {
if (reloadInterval == -1) {
return;
}
// the periodic cache url doesn't change between retries, so drop it
setIsCached(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(
() => setKey(Date.now()),
Math.max(reloadInterval, ERROR_RETRY_MS),
);
}, [reloadInterval]);
// periodic cache to reduce loading indicator
const [isCached, setIsCached] = useState(false);
@@ -92,6 +111,7 @@ export default function AutoUpdatingCameraImage({
<CameraImage
camera={camera}
onload={handleLoad}
onerror={handleError}
searchParams={cacheKey}
className={cameraClasses}
/>
@@ -11,6 +11,7 @@ type CameraImageProps = {
className?: string;
camera: string;
onload?: () => void;
onerror?: () => void;
searchParams?: string;
};
@@ -18,6 +19,7 @@ export default function CameraImage({
className,
camera,
onload,
onerror,
searchParams = "",
}: CameraImageProps) {
const { data: config } = useSWR("config");
@@ -96,6 +98,7 @@ export default function CameraImage({
"rounded-lg md:rounded-2xl",
)}
onLoad={handleImageLoad}
onError={onerror}
loading="lazy"
/>
) : (
@@ -149,7 +149,7 @@ export default function CameraInfoDialog({
<span>
{t("cameras.info.resolution")}{" "}
<span className="text-primary">
t("cameras.info.unknown")
{t("cameras.info.unknown")}
</span>
</span>
)}