| User story |
| As an organization running Fleet at scale, |
| I want the communication overhead between agents and the server to be minimal, |
| so that infrastructure costs are significantly reduced while maintaining full functionality. |
Goal
Eliminate the empty-poll traffic on distributed/read by letting the server tell agents when there's something to read, instead of every agent asking every 10 seconds.
At 50k hosts, distributed/read is 38.2% of daily requests, and 99.7% come back empty.
Design
The full architectural design, security analysis, deployment plan, and sequence diagrams are in the ADR:
ADR-0011: Agent WebSocket Transport
Summary: Replace polling with a persistent WebSocket connection per agent. The server pushes a "check now" nudge only when there is actual work. Orbit acts as osquery's distributed plugin so osquery's 10s poll becomes a localhost call. The WebSocket is a notification channel only; all data still flows over HTTP.
Phase 1 scope (this issue)
distributed/read only
- Server: feature flag, WebSocket endpoint, Redis pub/sub wake-up
- Agent: orbit as distributed plugin, WebSocket client with jitter
- Polling stays as permanent fallback
Deliverables
- A working experiment: patched fleetd + server where no
distributed/read requests reach the server while idle, and live queries still work
- The ADR (linked above) proposing the production design
- Cost validation from load testing
Sub-tasks (in development order)
Each step is independently testable. See linked sub-issues.
What success looks like
- Zero
distributed/read requests reach the server while nothing is happening (verify at the load balancer, not agent logs)
- A live query still completes, with results arriving within ~10 seconds of the "check now" message
- The open connection survives the load balancer's idle timeout (keepalive pings every 5 minutes)
- If the WebSocket can't connect, the agent falls back to polling with no one noticing
Test plan (PR #51427)
https://claude.ai/code/artifact/fe548e97-fe92-43f2-b7d5-f3cf891352aa?org=d01beebc-c6b2-4cd3-85d7-80cc031bf982
Sources: PR #51427 diff, ADR-0011, and the implementation-notes doc QA sections. Priorities: P0 = ship blocker, P1 = before GA, P2 = nice to have.
Setup: two Fleet instances + tooling (expand)
Two instances is the baseline for every scenario (production setup for all customers):
./build/fleet serve --server_address=0.0.0.0:8080 --dev --dev_license --websocket_transport_enabled=true --logging_debug
./build/fleet serve --server_address=0.0.0.0:8082 --dev --dev_license --websocket_transport_enabled=true --logging_debug
# Dashboards (one per instance)
go run ./tools/agentws-viz -server https://localhost:8080 -token $TEST_TOKEN -addr 127.0.0.1:3001 -insecure
go run ./tools/agentws-viz -server https://localhost:8082 -token $TEST_TOKEN -addr 127.0.0.1:3002 -insecure
# Build orbit into the local TUF repo for real-device runs
./tools/tuf/test/build_and_push_orbit.sh
# Simulated fleet at scale
go run ./cmd/osquery-perf -host_count 500 -websocket_prob 1.0 ...
Config knobs: websocket.transport_enabled (default false), websocket.check_interval (30s, drives refetch/re-notify latency), websocket.check_batch_size (500), websocket.ping_interval (5m), websocket.pong_timeout (30s).
Feature off (default) — regression
Feature on — happy path
Live queries (highest-risk area — test all APIs, not just UI)
Failure & recovery
Auth & security
ABM and Autopilot enrollment with feature enabled
Host online status
Multi-instance & Redis
Toggle & rollback (the escape hatch — must be bulletproof)
Backwards / forwards compatibility
Existing orbit functionality (while WS-connected)
Platform matrix (real devices; core loop = enroll → connect → live query → refetch → toggle off)
Server config validation
Scale & load (osquery-perf)
Observability & tooling
Automated suites
Goal
Eliminate the empty-poll traffic on
distributed/readby letting the server tell agents when there's something to read, instead of every agent asking every 10 seconds.At 50k hosts,
distributed/readis 38.2% of daily requests, and 99.7% come back empty.Design
The full architectural design, security analysis, deployment plan, and sequence diagrams are in the ADR:
ADR-0011: Agent WebSocket Transport
Summary: Replace polling with a persistent WebSocket connection per agent. The server pushes a "check now" nudge only when there is actual work. Orbit acts as osquery's distributed plugin so osquery's 10s poll becomes a localhost call. The WebSocket is a notification channel only; all data still flows over HTTP.
Phase 1 scope (this issue)
distributed/readonlyDeliverables
distributed/readrequests reach the server while idle, and live queries still workSub-tasks (in development order)
Each step is independently testable. See linked sub-issues.
What success looks like
distributed/readrequests reach the server while nothing is happening (verify at the load balancer, not agent logs)Test plan (PR #51427)
https://claude.ai/code/artifact/fe548e97-fe92-43f2-b7d5-f3cf891352aa?org=d01beebc-c6b2-4cd3-85d7-80cc031bf982
Sources: PR #51427 diff, ADR-0011, and the implementation-notes doc QA sections. Priorities: P0 = ship blocker, P1 = before GA, P2 = nice to have.
Setup: two Fleet instances + tooling (expand)
Two instances is the baseline for every scenario (production setup for all customers):
Config knobs:
websocket.transport_enabled(defaultfalse),websocket.check_interval(30s, drives refetch/re-notify latency),websocket.check_batch_size(500),websocket.ping_interval(5m),websocket.pong_timeout(30s).Feature off (default) — regression
distributed/readevery 10s as before.websocket_transportdirective; noGET /api/fleet/orbit/notificationstraffic; orbit never opens a WS connection.distributed_plugin: tlsis NOT stripped from served config when the flag is off.Feature on — happy path
websocket_transport.json, restarts once, connects (visible on agentws-viz).fleet_orbit_distributed(SELECT * FROM osquery_flags WHERE name='distributed_plugin'); osqueryd launched with--extensions_require=com.fleetdm.orbit.osquery_extension.v1 --extensions_timeout=60; no/api/v1/osquery/distributed/readpolling in server logs (that path = built-in tls plugin; orbit's client uses/api/osquery/).config_tls_refreshcycles connected — host never reverts to tls polling (distributed_pluginstripped from served options). This regressed twice during development.check_interval(≤30s; slower than old ≤10s — known/accepted).Live queries (highest-risk area — test all APIs, not just UI)
POST /api/latest/fleet/hosts/{id}/query.fleetctl query, run saved report (/api/latest/fleet/queries/{id}/run), UI websocket campaign API — against both instances.liveQueryMemCacheDurationrace). Any silent empty campaign is a failure.windows_updatesin Windows arm64 hosts Queryingwindows_updatestable crashes fleetd on Windows ARM64 (go-oleGetVariantDateABI bug) #51569): orbit restarts osqueryd; in-flight WS pass closes (no wedged awaiting-write); host answers later campaigns.livequery:host:{id}Redis targeting.Failure & recovery
kill -9osqueryd; SIGSTOP osqueryd (hang); kill orbit — recovery in all cases, no stuck distributed pass.Auth & security
GET /api/fleet/orbit/notifications): missing/malformed/invalid node keys → 401 before upgrade, no connection held; valid orbit key succeeds.distributed/read+write; osquery keys keep working everywhere; verify orbit key behavior on other osquery endpoints (config,log) matches intent — fallback grants nothing beyond what an enrolled host had. (Candidate for security-auditor pass.)/debug/agentwsrequires the debug auth token.ABM and Autopilot enrollment with feature enabled
Host online status
Multi-instance & Redis
redis-cli PUBSUB NUMSUB agent_notificationsequals live instance count (watch for zombiefleet serveprocesses double-subscribing).Toggle & rollback (the escape hatch — must be bulletproof)
distributed_pluginno longer stripped so osqueryd reverts to tls; all hosts healthy with no manual intervention.websocket_transport.jsonconsistent; exactly one restart per change; no restart loop.Backwards / forwards compatibility
osquery-perf -websocket_prob 0.5.Existing orbit functionality (while WS-connected)
--extensions_require/--extensions_timeout— orbit merges requires (theirs + orbit's) and takes max timeout; both extensions load.Platform matrix (real devices; core loop = enroll → connect → live query → refetch → toggle off)
Server config validation
ping_interval/pong_timeout/check_interval/check_batch_size: server refuses to start with a clear error naming the key.check_interval=5s,check_batch_size=10, short ping/pong): latencies scale; batching visible with >batch-size hosts.Scale & load (osquery-perf)
-websocket_prob 1.0, both instances, 1h steady state: memory/CPU/goroutines stable; sim stats show connected ≈ host count, errors ≈ 0.distributed/readrate drops dramatically; interval-checker cost bounded by batch size; per-host GETBIT probes every 30s while a campaign is active is the known cost (documented for later optimization).Observability & tooling
/debug/agentwspayload sanity: connected hosts,next_check_in_ms,check_interval_seconds, byte counters match reality.Automated suites
go test ./server/agentws/... ./server/config/... ./orbit/pkg/wstransport/... ./client/... ./cmd/osquery-perf/...MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/andREDIS_TEST=1 go test ./server/pubsub/...go test -race ./server/agentws/... ./server/pubsub/... ./orbit/pkg/wstransport/...(only known-benign subscriber-shutdown finding allowed).