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
ReceiveCameraUpdateoncamera-updates:<cameraId>groups (the grid's viewport set plus the selected camera) andReceiveCameraStatusEventon one fleet-widecamera-status-eventsgroup (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:
| Tier | Reads enable-camera-snapshot-stream | Effect when OFF |
|---|---|---|
| Frontend | via /api/feature-flags (lib/featureFlags.ts) | useCameraSnapshotMode() returns legacy; every legacy poller runs exactly as before |
| Frontend.API | CameraSnapshotHostedService re-reads it every 30 s | Builder idles, /CameraSnapshot answers 503 no-store |
| ImageAcquisition | CameraUpdatesPublisher re-reads it every 30 s | Change-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:
- Frontend.API stops building within 30 s and answers 503 with
Cache-Control: no-store(never cached by Front Door). - 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. - 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
| Client | Snapshot | Deltas |
|---|---|---|
| Signed-in operator, SignalR up | once at load, then every 5 min as a safety net, plus on reconnect and tab focus | viewport 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/off | every 10 s from the edge | none |
| Anonymous visitor | every 10 s from the edge | none (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-snapshotreturns 200 withETag,X-Snapshot-AsOfandCache-Control: public, max-age=0, s-maxage=5, must-revalidate; behind Front Door the second request within 5 s showsX-Cache: TCP_HIT.- Network tab: no
cameras-all-operational/camera-filters/offline/camera-filters/recently-movedrequests from the grid. - Redux DevTools:
ALERTCalifornia/cameraSnapshotStore.lastAsOfTimeis set andALERTCalifornia/signalRStore.cameraUpdateSubscriptionslists the viewport. - Tiles update without a poll: watch a subscribed camera's
imageTimestampchange inrealTimeCameraStorewhen it captures.
Code map
| Piece | Where |
|---|---|
| Mode decision | hooks/useCameraSnapshotMode.ts |
Orchestrator (mounted once from usePageUpdates) | hooks/useCameraSnapshotStream.ts |
| Queries | queries/useCameraSnapshot.ts |
| Session state (degrade window, offline set, viewport ids) | stores/cameraSnapshotStore.ts |
| Delta batching + merge (newest-wins) and overlay | stores/realTimeCameraStore.ts (applyCameraUpdate, flushCameraUpdates, applyCameraSnapshotOverlay) |
Hub subscriptions + handlers (and the subscribe reply, applyCurrentRows) | stores/signalRStore.ts |
| Image age the offline badge judges by | util/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 passthroughs | pages/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 mode | hooks/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 cache | Terraform cached-camera-snapshot-route + cacheCameraSnapshot rule set |