Museum API + Dashboard¶
dataland-museum is the live visitor telemetry service. It is the only
service that bridges the external Refik Anadol Studio data-center redis ("RDC")
into the Dataland stack. It subscribes to the RDC pub/sub feed, holds the latest
biometric + position payload per wearable in an in-process cache, normalises
each event onto the internal museum:telemetry stream, mirrors RDC's active-ticket
list at 1 Hz, and serves enriched vitals + room/chapter metadata to the
Agent, the curator dashboard, and the
Notification pipeline.
| Containers | dataland-museum (museum-api), dataland-simulator (museum-simulator, optional) |
| Image | dataland/museum-api:${IMAGE_TAG} / dataland/museum-simulator:${IMAGE_TAG} (one Dockerfile, two entrypoints) |
| Public port | ${MUSEUM_PUBLIC_PORT:-4144} → container :5001 (Cloudflare → museum.dataland.chat) |
| Internal URL | http://dataland-museum:5001 |
| Memory / CPU | 512 MB / 1 core |
| Healthcheck | GET /health |
| Entrypoint | uvicorn run:app (museum-api); python main.py (simulator, --profile simulator only) |
Recent changes (2026-06-03 → 2026-06-04)
- DAT-289 — RDC values that are stored as plain UTF-8 rather than msgpack
(Q-SYS server-health strings, per-room audio-player flags) now decode
correctly via a msgpack-then-UTF-8 fallback. New endpoints
/api/qsysand/api/audioexpose them. - DAT-287 / DAT-290 — the 1 Hz active-ticket loop now also emits a
visit_endedtelemetry event when a ticket leavesVisitors:ActiveTicketIDs(checkout). The notification worker turns that into the "How was the experience?" push. A/api/tickets/{id}/session-statusendpoint exposes the raw checkout signals (in_active_tickets,kiosk_outro). - DAT-288 —
default_reference_*placeholder images are purged fromchapters.json(and GCScobanov-public/chapters); the loader drops any remaining placeholder defensively. - GB mapping —
ROOM_TO_GALLERY/GALLERY_ART_IDSnow coverGA/GB/GC/GD.
What it does¶
- RDC subscriber — opens a
PSUBSCRIBEto the external RDC redis (RDC_REDIS_URL) on five channel patterns (BioSensors, BlueIoTSensors,Visitors:Register, per-ticketStatus, per-ticketAssignDevice) and keeps the latest decoded payload per wearable in an in-processRDCStateCache. - Telemetry bridge — when
MUSEUM_TELEMETRY_BRIDGE_ENABLED=true(default), every BioSensors message is normalised andXADD-ed tomuseum:telemetryondataland-redis, stampedsimulator_source=rdc-bridge. The notification worker tails that stream. - Active-ticket mirror — replicates RDC's
Visitors:ActiveTicketIDsinto the localdataland-redisSETmuseum:active_ticket_idsevery 1 Hz, atomically, so the agent and the mobile app can answer "is this ticket live?" without touching RDC. - visit_ended emit (DAT-287/290) — in the same 1 Hz loop, detects tickets
that dropped out of the active set (active → passive = checkout) and publishes
a
visit_endedevent ontomuseum:telemetry. - Vitals API —
GET /api/tickets/{ticket_id}/vitalscomposes the agent-facing vitals dict (heart rate, skin conductance, room, chapter, position, ambient AQI, crowd density) from the SET-key surface + the subscriber cache. - Room / ambient / Q-SYS / audio / kiosk APIs — read-only views over the RDC data plane for the dashboard and operators.
- Dashboard — a Jinja-rendered live monitor at
/, gated byMUSEUM_PASSWORD, fed over a single WebSocket (/ws) that piggybacks vitals + AQI + visitor list. - Simulator (optional) —
dataland-simulator(main.py) generates synthetic telemetry directly ontomuseum:telemetry. It exists so the notification pipeline has events even when no real visitors are in the building. The museum-api process never reads fromdataland-redis.
Architecture¶
graph LR
subgraph RDC["RDC redis (Refik Anadol Studio, read-only)"]
BIO["Wearables:WatchDevices:*:BioSensors"]
POS["...:BlueIoTSensors"]
VIS["Visitors:* (Status / Register / AssignDevice / ActiveTicketIDs)"]
GAL["Galleries:*:SceneControl:*"]
IO["IO:AQI:* / Rooms:DATALAND:Qsys:* / Rooms:*:Audio_Player_*"]
end
subgraph M["dataland-museum (museum-api)"]
SUB["RDCSubscriber<br/>PSUBSCRIBE → RDCStateCache"]
READ["rdc_reader<br/>on-demand MGET/GET"]
LOOP["1 Hz broadcast loop"]
end
BIO -.PSUBSCRIBE.-> SUB
POS -.PSUBSCRIBE.-> SUB
VIS -.PSUBSCRIBE / GET.-> SUB
GAL -.GET.-> READ
IO -.GET.-> READ
SUB -.XADD museum:telemetry.-> RD[(dataland-redis)]
LOOP -.SADD museum:active_ticket_ids.-> RD
LOOP -.XADD visit_ended.-> RD
READ -.GET /api/tickets/{id}/vitals.-> AG[dataland-agent]
LOOP -.WS /ws.-> DASH["dashboard browser"]
RD -.XREADGROUP.-> NOTIF[dataland-notification]
Two Redis servers¶
This is the only service that talks to both redis instances. They serve different purposes:
| Redis | Purpose | Direction |
|---|---|---|
RDC_REDIS_URL (RAS data center) |
Production data plane — live BioSensors / BlueIoT PUBLISH feed + the Visitors:* / Galleries:* / IO:* SET surface |
read only (PSUBSCRIBE + on-demand GET/MGET) |
dataland-redis (internal, on dataland-network) |
museum:telemetry fan-out + museum:active_ticket_ids mirror |
write (XADD, SADD/RENAME) |
The museum API itself does not read museum data from dataland-redis — that
path exists purely to feed the notification pipeline and to mirror the active
list for the agent/mobile.
No fallback for the RDC redis
RDC_REDIS_URL is validated at config-load time in
app/config.py — if it is empty the service raises
RuntimeError and refuses to start. There is no host/port fallback and no
substitute store. If the RDC redis is unreachable, museum-api streams no
data; it does not pretend to work against a local container. The URL accepts
redis://user:pw@host:6379/0 or rediss://…:6380/0 for TLS.
RDC data decode: msgpack + plain UTF-8 (DAT-289)¶
The RDC redis uses mixed encoding. Most keys and pub/sub payloads are msgpack, but a handful are stored as plain UTF-8 strings:
Rooms:DATALAND:Qsys:*server-health values (e.g."5.03%","52.0°C", and a plainSTATUSlabel).Rooms:<room>:Audio_Player_<n>_{Playing,Stopped}flags.
rdc_reader._decode_value() therefore tries msgpack.unpackb(...) first and
falls back to raw.decode("utf-8"). Usage/temperature strings are additionally
parsed to floats by _strip_numeric() (stripping % and °C), so each Q-SYS
metric is returned as both a raw string and a parsed value.
Where the encoding map lives
The canonical key + channel + encoding reference is
obsidian.md/dataland/data-flow/museum-rdc-redis-data-dictionary.md. The
code cites it directly. When in doubt about a key's shape, read that first.
Subscribed channels¶
The RDCSubscriber (app/services/rdc_subscriber.py) PSUBSCRIBEs to:
| Pattern | Cache slot | Notes |
|---|---|---|
Wearables:WatchDevices:*:BioSensors |
cache.bio[serial] |
~9 fields: Heart_Rate_Value, Skin_Conductivity_Value, Skin_Temperature_Value, Accelerometer_Value, Activity, Steps, Battery_1, … Drives the telemetry bridge. |
Wearables:WatchDevices:*:BlueIoTSensors |
cache.position[serial] |
BlueIoT_Position (xy) + Room_ID. |
Visitors:Register |
cache.register_events (ring, 50) |
Onboarding control plane; surfaced to the dashboard. |
Visitors:*:Status |
cache.visitor_status[ticket] |
Per-ticket status updates. |
Visitors:*:AssignDevice |
cache.assign_device_events (ring, 50) |
Rebinds a watch to a ticket; clears the serial→ticket map for immediate effect. |
The subscriber loop is resilient: on ConnectionError/OSError it backs off
exponentially (1 s → 30 s cap) and re-subscribes. A TCP timeout to RDC is the
common failure mode and is not reflected in /health (see below).
Heart-rate hold-through¶
The wrist PPG intermittently reports 0 BPM on dropout / off-wrist / motion.
0 is not a real reading, so the subscriber records the last real (> 0)
heart rate per serial in cache.last_valid_hr, and compose_vitals holds that
value through gaps for up to HR_HOLD_WINDOW_SECONDS (120 s). Beyond the
window, heart_rate is reported as null rather than 0. The response flags
heart_rate_held: true when a held value is being surfaced. This is part of the
vitals physiological-sanity work (DAT-285).
Serial → ticket resolution (telemetry bridge)¶
A BioSensors message arrives keyed by watch serial, but museum:telemetry
events must carry a ticket_id. The subscriber lazily resolves the binding by:
- Reading
Visitors:ActiveTicketIDsfrom RDC. SCAN-ingVisitors:*:EmpaticaDeviceIDand inverting it to aserial → ticketmap (preferring tickets that are currently active).- Caching the map for
_SERIAL_TICKET_TTL_SECONDS(60 s); anAssignDeviceevent clears it immediately.
If no binding exists yet (visitor hasn't completed device assignment), the
message is counted as skipped_no_ticket and not bridged. Each bridged
event is enriched with room_code, chapter_id, chapter_name, art_name,
and etemp before the XADD.
Bridge metrics (DAT-131)¶
GET /api/bridge/metrics exposes the three outcomes plus freshness:
| Field | Meaning |
|---|---|
published |
BioSensors messages successfully XADD-ed. |
skipped_no_ticket |
No serial→ticket binding yet (expected setup race). |
publish_failed |
Redis hiccup / build error (operators triage). |
last_published_at |
Wall-clock of the last successful publish. |
seconds_since_last_published |
Derived freshness (-1.0 = nothing ever published). |
bridge_enabled |
Reflects MUSEUM_TELEMETRY_BRIDGE_ENABLED. |
Active-ticket mirror + visit_ended (DAT-287/290)¶
A single 1 Hz loop (_rdc_broadcast_loop) drives both the mirror and the
checkout signal:
sequenceDiagram
participant L as 1 Hz loop
participant RDC as RDC redis
participant DR as dataland-redis
participant N as notification worker
L->>RDC: GET Visitors:ActiveTicketIDs
RDC-->>L: [ticket_001, ticket_002]
L->>DR: stage SADD museum:active_ticket_ids:staging → RENAME (atomic swap)
L->>L: prev - current = checked-out tickets
L->>DR: XADD museum:telemetry {event_type: visit_ended} (per checked-out ticket)
N->>DR: XREADGROUP → VisitEndedRule → "How was the experience?" push
- Mirror —
museum:active_ticket_idsis rewritten every tick to match RDC verbatim (atomic stage-then-RENAME; deleted when RDC reports zero). No TTL, no debounce — RDC is trusted as the source of truth. - Contract — the agent reads the exact key
museum:active_ticket_idsfrom the samedataland-redis(dataland-agent/app/services/session_state.py). The key is a fixed constant (ACTIVE_TICKETS_MIRROR_KEY), not env-overridable, so writer and reader can never drift. - Resilience — the mirror + visit_ended only run after a successful RDC read; a failed read skips the whole tick and leaves the last good set in place rather than wiping it.
- Fire-once is the consumer's job — museum-api only emits the raw
visit_endedevent on the active→passive edge. The dedupe/fire-once state lives in the notification worker (DAT-287).
Both behaviours are independently gated:
MUSEUM_ACTIVE_TICKETS_MIRROR_ENABLED and MUSEUM_TELEMETRY_BRIDGE_ENABLED
(each default true), using separate async redis clients.
Room codes → galleries → names¶
RDC writes a wearable's location as a RoomCode. The service maps that to a
gallery whose Galleries:<gallery>:<art_id>:SceneControl:* keys describe the
active chapter:
ROOM_TO_GALLERY = {"GA": "GA", "GB": "GB", "GC": "GC", "GD": "GD"} # (1)!
GALLERY_ART_IDS = {"GA": "ga_art_v1", "GB": "gb_art_v1",
"GC": "gc_art_v1", "GD": "gd_art_v1"} # (2)!
- Identity map of the four gallery room codes
GA/GB/GC/GD. AnyRoomCodenot present here is treated as non-gallery, so chapter fields resolve tonulland_classify_corridor()decides lobby / onboarding / corridor instead. - Each gallery resolves to a single canonical art id used to read its
Galleries:<gallery>:<art_id>:SceneControl:*keys. NoteGBcollapses the legacyb1/b2/b3_art_v1per-zone activations down togb_art_v1— the wearable only ever reports the gallery codeGBat runtime.
Rooms not in the map are treated as non-gallery — chapter fields come back
null. _classify_corridor() further labels LO → lobby, ON →
onboarding, empty/CORRIDOR → corridor, everything else → gallery.
The agent never speaks bare room codes to visitors — it translates to the human gallery names (DAT-281). The canonical mapping:
| Room code | Gallery name |
|---|---|
GA |
Data Pavilion |
GB |
Latent Gallery |
GC |
Infinity Room |
GD |
The Sanctuary |
ON |
Discovery Portal (onboarding) |
LO |
Lobby |
GB vs B1/B2/B3 in the catalog
chapters.json predates the gallery consolidation: its GB artworks still
carry per-zone room hints in their chapter ids (MDR-B1-* Machine Dreams,
MDR-B2-* Data Universe, MDR-B3-* Machine Drawings) and b1/b2/b3_art_v1
activations. At runtime the wearable reports the gallery code (GB), which
resolves through GALLERY_ART_IDS["GB"] = "gb_art_v1".
chapters.json catalog¶
app/data/chapters.json is the static room/chapter/artwork catalog, loaded once
at startup by load_chapters_from_json() and indexed by chapter_id. It holds
26 raw rows across four galleries:
| Gallery | Rows | Artwork(s) |
|---|---|---|
GA (Data Pavilion) |
14 | Machine Dreams : Rainforest (MDR-CH-*) |
GB (Latent Gallery) |
6 | Machine Dreams, Data Universe, Machine Drawings |
GC (Infinity Room) |
2 | Ruwe Pinu |
GD (The Sanctuary) |
4 | Machine Hallucinations: Rainforest |
Each loaded chapter carries chapter_id, chapter_name, room_code,
art_name, activation_name, scent_activation, reference_images,
synthetic ambient values (lux/noise/temp), and an intensity label.
default_reference purge (DAT-288)
Placeholder default_reference_* filenames have been removed from
chapters.json and from the GCS cobanov-public/chapters bucket. The loader
is defensive too: it skips any reference image that is empty or whose name
(lowercased) starts with default_reference. These placeholders were never
in Qdrant, so no RAG re-ingest was needed. 31 real reference images remain
(e.g. cellImage_0_0.jpeg).
Reference image filenames are joined to full URLs at serve time:
${GCS_PUBLIC_BASE_URL}/${GCS_BUCKET_NAME}/chapters/<file>
(default https://storage.googleapis.com/cobanov-public/chapters/<file>).
API endpoints¶
All routes are served by app/web/server.py.
Operations¶
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness only — does not verify the RDC connection. |
GET |
/ |
Live visitor monitor (HTML; redirects to /login when auth configured). |
GET |
/login |
Password login page. |
GET |
/api/bridge/metrics |
Bridge counters + freshness (DAT-131). |
Telemetry¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/tickets/{ticket_id}/vitals |
Composed vitals + chapter details + interpretations. The agent's get_visitor_vitals source. 404 when no RDC mapping. |
GET |
/api/tickets/{ticket_id}/session-status |
Raw checkout signals: in_active_tickets, kiosk_outro, outro_kiosk_id (DAT-287/290). |
GET |
/api/active-tickets |
Live ticket ids from Visitors:ActiveTicketIDs (read fresh each call). |
GET |
/api/aqi |
Per-room Pimoroni Enviro+ payloads from IO:AQI:* (one MGET). temp is °F. |
GET |
/api/qsys |
Q-SYS server-health from Rooms:DATALAND:Qsys:* (raw + parsed, DAT-289). |
GET |
/api/audio |
Per-room audio-player playing/stopped flags (DAT-289). |
GET |
/api/onboarding/kiosks |
Onboarding kiosk lifecycle (scene + stage data) from Rooms:ON:Kiosks:*. |
GET |
/api/simulator/visitors |
Active visitors synthesised from Visitors:ActiveTicketIDs (+ user enrichment). |
GET |
/api/visitors/{visitor_id}/vitals |
Deprecated alias for /api/tickets/{id}/vitals. |
Museum content¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/chapters |
All unique chapters with full reference-image URLs. |
GET |
/api/rooms/{room_code}/chapters |
Chapters for one room code (GA, GB, GC, GD). |
GET |
/images/{filepath} |
Serve a local chapter image (path-traversal hardened, DAT-121). |
Realtime¶
| Method | Path | Purpose |
|---|---|---|
WS |
/ws |
Live monitor stream. Pushes per-ticket vitals (1 Hz), {type:"aqi"} (1 Hz), and {type:"visitor_list"} (every 5 s) on one socket (DAT-135). Closes 4401 without a valid session cookie. |
Vitals shape (selected fields)¶
compose_vitals translates RDC field names + units into the agent-facing
schema. Every field is nullable:
{
"ticket_id": "ticket_001",
"status": "Active",
"empatica_device_id": "...", "scent_device_id": "...",
"room_code": "GA", "zone": 1,
"is_in_corridor": false, "location_type": "gallery",
"position_xy": [12.3, 4.5], "position_room_id": 1,
"chapter_id": "MDR-CH-I-A", "rt_chapter": "...", "dds_chapter": "...",
"is_playing": true, "playhead": 1234, "stack": "...",
"heart_rate": 78, "heart_rate_held": false,
"skin_conductance": 0.42, "skin_temperature": 33.1,
"activity": 1, "activity_state": "active", "steps": 210, "battery": 88,
"watch_alive": true, "is_alive": true, "last_bio_seen_seconds_ago": 0.7,
"ambient_temp": 22.0, "ambient_lux": 350.0, "ambient_humidity": 41.0,
"crowd_density": 6
}
is_alivereflects bio freshness withinRDC_WATCH_STALE_AFTER_SECONDS.crowd_density(Augmenta cluster count) exists forGAonly today.ambient_tempis converted RDC °F → °C;ambient_lux/humiditycome fromIO:AQI:<gallery>.
Dashboard auth¶
- Single shared password (
MUSEUM_PASSWORD). Session cookie is HMAC-signed withMUSEUM_SESSION_SECRET(falls back to the password if unset); 12-hour TTL (MUSEUM_AUTH_SESSION_MAX_AGE_SECONDS=43200). MUSEUM_AUTH_COOKIE_SECUREdefaults totruein code /.env.example(production sits behind TLS); the infracompose.ymloverrides it tofalsefor the host-port path unless TLS is in front. Local dev overhttp://localhostmust set itfalse, otherwise the browser silently drops theSecurecookie on each redirect.- Per-IP rate limiting on the login path (DAT-122); the constant-time password compare runs only after the rate-limit check.
- The
/wsWebSocket enforces the same session cookie (close code4401).
Key env vars¶
# RDC data plane (required, no fallback)
RDC_REDIS_URL=redis://default:***@<rdc-host>:6379/0 # (1)!
RDC_WATCH_STALE_AFTER_SECONDS=10.0 # (2)!
# Internal redis (telemetry bridge + active-ticket mirror sink)
REDIS_HOST=dataland-redis
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=*** # (3)!
MUSEUM_TELEMETRY_BRIDGE_ENABLED=true # (4)!
MUSEUM_ACTIVE_TICKETS_MIRROR_ENABLED=true # (5)!
# Dashboard auth
MUSEUM_AUTH_ENABLED=true
MUSEUM_PASSWORD=***
MUSEUM_SESSION_SECRET=*** # (6)!
MUSEUM_AUTH_SESSION_MAX_AGE_SECONDS=43200 # (7)!
MUSEUM_AUTH_COOKIE_SECURE=true # (8)!
# Reference image + agent enrichment
GCS_BUCKET_NAME=cobanov-public
GCS_PUBLIC_BASE_URL=https://storage.googleapis.com
AGENT_BASE_URL=http://dataland-agent:4141
AGENT_SERVICE_TOKEN=*** # (9)!
- The external Refik Anadol Studio data-center redis. Validated at config-load time in
app/config.py— if empty the service raisesRuntimeErrorand refuses to start. There is no host/port fallback. Acceptsredis://user:pw@host:6379/0orrediss://…:6380/0for TLS. - Bio freshness window (seconds). A watch whose last BioSensors message is older than this is reported
is_alive: falsein the vitals payload. - DAT-76
requirepasson the internaldataland-redis. Required toXADD/SADDagainst the telemetry + active-ticket-mirror sink. - Default
true. Gates the BioSensors →museum:telemetrybridge; disabling it stops the notification worker from receiving live events. - Default
true. Independently gates the 1 Hzmuseum:active_ticket_idsmirror (separate async redis client from the bridge). - HMAC key for the dashboard session cookie. Falls back to
MUSEUM_PASSWORDif unset — set it explicitly so rotating the password doesn't invalidate sessions. - Session cookie TTL in seconds (43200 = 12 h).
- Defaults
truein code /.env.examplesince production sits behind TLS. Setfalseonly for localhttp://localhost, otherwise the browser silently drops theSecurecookie on each redirect and login loops. - Authorises the museum's
GET ${AGENT_BASE_URL}/v1/service/tickets/{ticket}/userenrichment lookups. Without it, enrichment is skipped (tickets still list, just withoutuser_id/external_idmapping).
Why the museum needs an agent token
/api/simulator/visitors and the dashboard visitor list enrich each ticket
with user_id / external_id by calling
GET ${AGENT_BASE_URL}/v1/service/tickets/{ticket}/user. Lookups are cached
(60 s positive, 300 s negative) in a bounded cache so RDC tickets that were
never registered through the mobile app don't flood the agent's Postgres
pool. Without AGENT_SERVICE_TOKEN, enrichment is skipped (tickets still
list, just without user mapping).
Simulator (dataland-simulator)¶
Built from the same image, run with command: ["uv","run","python","main.py"]
under the simulator compose profile. It seeds default visitors and publishes
synthetic telemetry to museum:telemetry every second so the notification rules
have events when the building is empty. Events are stamped
simulator_source (museum-ui / default-seed); default-seed events set
notification_enabled=false so they don't trigger pushes. The simulator is the
only writer that the museum-api process does not own, and museum-api never
reads back from dataland-redis.
Playback / simulation isolation
Never replay museum-simulation Avro or simulator output into the live
dataland-redis. If you need to exercise playback, spin up a dedicated redis
container. Mixing simulated events into the live stream pollutes the
notification pipeline and the dashboard.
Reaching it¶
# Public:
curl -fsS https://museum.dataland.chat/health # (1)!
# Direct host:
curl -fsS http://localhost:4144/health
# Vitals (requires session cookie or, in production, fronting auth):
curl -fsS http://localhost:4144/api/tickets/ticket_001/vitals # (2)!
# Live ticket list + bridge freshness:
curl -fsS http://localhost:4144/api/active-tickets
curl -fsS http://localhost:4144/api/bridge/metrics # (3)!
/healthis liveness only — it confirms the FastAPI process answers but does not verify the RDC connection, so a green health check can still mean zero telemetry is flowing.- The agent's
get_visitor_vitalssource. Returns404when no RDC serial→ticket mapping exists yet for the ticket. - Best single freshness signal:
seconds_since_last_published == -1means nothing has ever been bridged, a large positive value means the bridge stalled (commonly a TCP timeout to the RDC host).
When the dashboard looks healthy but data is stale¶
/health only checks that the FastAPI process answers; it does not verify
the RDC connection. To confirm real telemetry is flowing:
# Bridge freshness (best signal). seconds_since_last_published == -1 means
# nothing has ever been bridged; a large positive number means the bridge stalled.
curl -fsS http://localhost:4144/api/bridge/metrics
# Stream depth on the internal redis:
docker exec dataland-redis sh -c 'redis-cli -a "$REDIS_PASSWORD" --no-auth-warning XLEN museum:telemetry' # (1)!
# Active-ticket mirror (should match RDC's Visitors:ActiveTicketIDs):
docker exec dataland-redis sh -c 'redis-cli -a "$REDIS_PASSWORD" --no-auth-warning SMEMBERS museum:active_ticket_ids' # (2)!
# Logs for the RDC subscriber:
docker logs --tail 50 dataland-museum 2>&1 | grep -i rdc # (3)!
XLENon the bridge's output stream.--no-auth-warningsuppresses the-apassword warning; the password is read from the container's ownREDIS_PASSWORD(DAT-76 requirepass) so it never appears in the command.- Reads the mirrored SET directly. Its membership should match RDC's
Visitors:ActiveTicketIDsverbatim — the mirror is rewritten every tick via atomic stage-then-RENAME, so a mismatch points at a stalled 1 Hz loop, not a sync race. - Surfaces the RDC subscriber's reconnect/backoff lines. A TCP timeout to the RDC host (usually a downed tailnet route) shows here even though
/healthstays green.
A common failure mode is a TCP timeout to the RDC redis — usually because the
tailnet route to the RDC host is down. The subscriber keeps retrying with
exponential backoff; nothing in /health reflects it, but
seconds_since_last_published climbs and skipped_no_ticket / cache staleness
become visible.
Related pages¶
- Agent — consumes vitals + active-ticket mirror;
get_visitor_vitals/get_room_info/get_scene_flow. - Notification — consumes
museum:telemetry, includingvisit_ended. - RAG — holds the museum sections/scenes/overview knowledge the agent searches.
- Redis & data stores —
dataland-redisvs the external RDC redis.