Skip to main content

Camera Snapshot Stream

Before this feature every open tab polled /api/cameras-all-operational (up to 50 viewport cameras, with images) and up to three /api/camera-filters/* endpoints every 10 seconds. Each poll re-ran a Cosmos query plus one sequential Table Storage read per camera on the backend, for every user, whether anything had changed or not. That is what capped the app at tens of thousands of concurrent users.

The Camera Snapshot stream replaces that with two channels:

  • Snapshot — GET /api/camera-snapshot, one document with every public camera's operational row and the offline id list. It is identical for every caller, so Front Door caches it at the edge for 5 seconds (signed-in users included). Origin sees roughly one request per POP per TTL no matter how many users are connected.
  • Deltas — SignalR ReceiveCameraUpdate on camera-updates:<cameraId> groups (the grid's viewport set plus the selected camera) and ReceiveCameraStatusEvent on one fleet-wide camera-status-events group (offline / movement / AI / patrol flips only). Published by ImageAcquisition from the Cosmos change feed on the operational container.

Role-gated data (AI detection flags and scores, dam cameras) never enters the cached snapshot. The public row keeps every field the legacy operational endpoint returns to an anonymous caller today, so nothing the app renders is lost. Signed-in users fetch it from GET /api/camera-snapshot-private, which is small, served from backend memory and never cached.

The switch​

One SQL feature-flag row gates the entire feature across all three tiers:

TierReads enable-camera-snapshot-streamEffect when OFF
Frontendvia /api/feature-flags (lib/featureFlags.ts)useCameraSnapshotMode() returns legacy; every legacy poller runs exactly as before
Frontend.APICameraSnapshotHostedService re-reads it every 30 sBuilder idles, /CameraSnapshot answers 503 no-store
ImageAcquisitionCameraUpdatesPublisher re-reads it every 30 sChange-feed function returns immediately, publishes nothing

Frontend.API and ImageAcquisition live in the Alert.CA.ImageAcquisition mono repo (with Alert.CA.Infrastructure as a project reference, so there is no package publish step); the backend half of this feature is one PR there, this repo's half is a second PR, and the Front Door route is a third in Terraform.

The row does not exist yet in any environment; a missing row is off. To turn the feature on:

INSERT INTO dbo.FeatureFlag (FlagName, IsEnabled) VALUES ('enable-camera-snapshot-stream', 1);
-- or, once the row exists:
UPDATE dbo.FeatureFlag SET IsEnabled = 1 WHERE FlagName = 'enable-camera-snapshot-stream';

Each tier picks it up within ~30 seconds (browsers on their next page load). There is also a deploy-time master per backend (CameraSnapshotOptions:Enabled and CameraUpdatesOptions:Enabled in App Configuration, default true) for the case where you want the code deployed but the feature impossible to enable.

Rollback​

Set the flag to 0. Nothing else is required:

  1. Frontend.API stops building within 30 s and answers 503 with Cache-Control: no-store (never cached by Front Door).
  2. Every open tab that next touches the snapshot gets that 503 and puts itself on legacy polling for 5 minutes (SNAPSHOT_DEGRADE_MS), then re-checks. New page loads read the flag and never enter snapshot mode.
  3. ImageAcquisition stops publishing; the change-feed lease keeps advancing so re-enabling later causes no catch-up storm.

The legacy endpoints, hooks and stores are untouched by this feature, so a rollback restores the exact pre-feature behaviour. A full code revert is also safe: the only shared-state change is additive (new hub groups, new store fields).

Automatic fallback​

The client does not trust the flag alone. useCameraSnapshotStream marks the session degraded on any non-200 from /api/camera-snapshot (backend flag off, snapshot not built yet, stale snapshot, backend unreachable). While degraded, useCameraSnapshotMode() reports legacy and the old pollers run. The window expires on its own and the snapshot path is retried. An CameraSnapshot_Degraded App Insights event records each fallback.

AI detections: the fast path​

An operator with the AI role does not wait for the overlay to learn about a new detection. The moment Notification patches a camera's AI flags (new image detection, video clip, or the stale-flag cleanup) it pushes the camera's new AI state, with its confidence and detection dates, through the hub's BroadcastAiDetection to the ai-detection-events group. The browser applies it straight to the camera row and the AI filter's hit list (realTimeCameraStore.applyAiDetection, cameraSnapshotStore.applyAiDetection), so the badge, the AI-first order, the map marker and the chime follow about a second after Condor reported it. Who may join the group is decided by the connection's own identity: the hub connection is negotiated through the BFF (pages/api/camera-hub/negotiate.ts), which attaches the user's Entra bearer the way it does for every other API call, so Azure SignalR carries that principal and the hub admits the AI role only. The socket itself still connects straight to the Azure SignalR service; only the negotiate call goes through the BFF, and it is repeated on every reconnect. Anonymous visitors negotiate without a bearer, exactly as before. The ai status event and the private overlay still arrive and remain the reconciliation; a session without the AI role is refused by the hub and keeps the value-free status event only.

The push outranks any overlay built before it. The overlay is rebuilt every five seconds, so the refetch an ai event triggers often returns a document that predates the detection; applied as-is it would wipe the badge the push had just lit, and the catch-up refetch a few seconds later would light it again. cameraSnapshotStore.reconcileOverlay therefore folds every pushed detection newer than the overlay's asOfTime into it (hit upserted, or removed when the push cleared the camera) before either store sees it, and forgets a push once an overlay at least as new has arrived, or after two minutes. The push also stamps lastAiEventAt, so the catch-up loop runs even if the status event is lost.

What each client does​

ClientSnapshotDeltas
Signed-in operator, SignalR uponce at load, then every 5 min as a safety net, plus on reconnect and tab focusviewport cameras + selected camera, plus status events. SubscribeCameraUpdates also returns the subscribed cameras' current rows from the server's in-memory snapshot, applied like deltas, so a tile that scrolls into view is current at once instead of after the camera's next capture
Signed-in, SignalR down/offevery 10 s from the edgenone
Anonymous visitorevery 10 s from the edgenone (sockets and messages are reserved for operators)

Filters in snapshot mode call no endpoint at all: Offline/Online use the snapshot's offlineCameraIds (same durable-entity source as the old endpoint); Active is derived from each row's movexp (the old endpoint's exact predicate, re-derived every 30 s as expiries pass); AI detected compares each overlay hit's confidence (the latest event's best fire/smoke score, the same value /camera-filters/latest-detections compared server-side) with the slider in the browser. Moving the slider is instant and free.

Hidden tabs keep refetching the snapshot and the overlay, exactly like the legacy operational poll never paused: the AI alert sound watches the real-time store from a background tab and must keep seeing new detections there. The snapshot's masked AI flags (all four, including hasRecentVideoDetectionHit) are stripped before they reach the store, so a resync can never flip a detected camera false and back to true (which the sound hook would read as a new detection); only the overlay writes those flags.

Deltas are subscribed for the tiles inside the viewport (cap 60), not the grid's 50-camera polling set: push volume follows what is on screen. Deltas are applied in 300 ms batches so the grid renders at most about three times a second under load, below its legacy once-per-poll rate.

Verifying it is on​

  • GET /api/camera-snapshot returns 200 with ETag, X-Snapshot-AsOf and Cache-Control: public, max-age=0, s-maxage=5, must-revalidate; behind Front Door the second request within 5 s shows X-Cache: TCP_HIT.
  • Network tab: no cameras-all-operational / camera-filters/offline / camera-filters/recently-moved requests from the grid.
  • Redux DevTools: ALERTCalifornia/cameraSnapshotStore.lastAsOfTime is set and ALERTCalifornia/signalRStore.cameraUpdateSubscriptions lists the viewport.
  • Tiles update without a poll: watch a subscribed camera's imageTimestamp change in realTimeCameraStore when it captures.

Code map​

PieceWhere
Mode decisionhooks/useCameraSnapshotMode.ts
Orchestrator (mounted once from usePageUpdates)hooks/useCameraSnapshotStream.ts
Queriesqueries/useCameraSnapshot.ts
Session state (degrade window, offline set, viewport ids)stores/cameraSnapshotStore.ts
Delta batching + merge (newest-wins) and overlaystores/realTimeCameraStore.ts (applyCameraUpdate, flushCameraUpdates, applyCameraSnapshotOverlay)
Hub subscriptions + handlers (and the subscribe reply, applyCurrentRows)stores/signalRStore.ts
Image age the offline badge judges byutil/cameras/effectiveImageAge.ts: in stream mode a row's age is measured as of when the row was received (snapshot asOfTime / delta updatedAt, kept in realTimeCameraStore.cameraUpdateTimes), and silence counts only while the camera is subscribed and beyond three capture intervals (signalRStore.cameraUpdateSubscribedAt). Off-screen cameras therefore never show a false "offline" tier; a subscribed camera that really stops still climbs the tiers
BFF passthroughspages/api/camera-snapshot.ts (anonymous, must stay out of proxy.ts so the edge can cache it), pages/api/camera-snapshot-private.ts (in the proxy.ts matcher like latest-detections: session enforced and bearer injected before the handler)
Legacy pollers gated on modehooks/usePageUpdates.ts, components/organisms/TileView/CameraList/CameraListComponent.tsx, hooks/useCameraFilters.ts, components/molecules/MapNotifications/CameraCallout/CameraCallout.tsx (the open callout's camera is subscribed to deltas instead of polled)
Edge cacheTerraform cached-camera-snapshot-route + cacheCameraSnapshot rule set