Skip to Content
ConceptsObserving a simulator

Concepts

Observing a simulator

Every simulator records what it holds, what it was asked and what it would have sent. The examples use the Stripe simulator with one request made; every simulator serves the same routes.

rystic pull stripe-twin
PORT=$(rystic run stripe-twin -d --name observe --port 0 --scenario design_studio --json | jq -r .data.port)
curl -s localhost:$PORT/v1/customers -u sk_test_twin: -d email=ada@example.com | jq -r .id
# -> cus_lllllllllllllc
WantRead
The whole stateGET /_rystic/inspect
Every request your code madeGET /_rystic/calls
A live feed of requests, effects and control callsGET /_rystic/events, rystic tail
What it would have sent outGET /_rystic/egress
All of it in a browser/_rystic/ui

State

GET /_rystic/inspect returns every register that holds a record, keyed by register, then by record key. An empty simulator answers {}.

curl -s localhost:$PORT/_rystic/inspect | jq -c 'map_values(length)'
# -> {"state_charge":269,"state_customer":31,"state_event":1,"state_payment_intent":276,…}
  1. The dump is a register map: post it to /_rystic/seed after a reset and the state comes back the same (Seeding).
  2. ?view=identities folds the alias keys a world files a user under, such as member on Slack and GitHub, into the user they name. Read with it; seed from the default dump, which keeps the aliases.
  3. GET /_rystic/registers describes each register (Registers).

Requests

GET /_rystic/calls lists every API request since the last reset, oldest first. Control-plane calls are not in it.

curl -s localhost:$PORT/_rystic/calls | jq -c '.calls[0]'
# -> {"actor":"member","at":"2026-03-01T00:00:00.001Z","duration_ms":0,"method":"POST","path":"/v1/customers","principal":"","seq":1,"status":200,"template":"/v1/customers"}
  1. actor is the identity the request acted as, template the route it matched, and at the simulator’s clock.
  2. total counts every call since the last reset; a reset empties the list and restarts seq at 1.
  3. It keeps as many calls as the egress log keeps events (below).

Live feed

GET /_rystic/events is a Server-Sent Events stream. Each event is an id: line and a data: line, of kind request, effect or control.

curl -sN localhost:$PORT/_rystic/events
# -> id: 1
# -> data: {"seq":1,"kind":"control","at":"2026-01-01T00:00:00Z","data":{"detail":null,"op":"reset"}}
# -> id: 2
# -> data: {"seq":2,"kind":"control","at":"2026-03-01T00:00:00Z","data":{"detail":{"frames":0,"name":"design_studio"},"op":"scenario"}}
# -> …
  1. On connect it sends up to the last 1,000 events since the last reset, then streams live.
  2. seq keeps counting across resets. Reconnect with Last-Event-ID: <seq> or ?cursor=<seq> to get only what came after.

rystic tail prints the same feed, one line per event, stamped with the simulator’s time of day:

rystic tail --addr http://localhost:$PORT
# -> 00:00:00.000 CTL reset
# -> 00:00:00.000 CTL scenario frames=0 name=design_studio
# -> 00:00:00.001 REQ POST /v1/customers -> 200 (0ms)
  1. The default shows writes and control calls. --events all adds reads (REQ GET …) and effects (FX customer.created …). CLI flag reference → tail lists the scopes.
  2. A detached simulator writes the same lines to its log: rystic logs -f observe.

Egress

GET /_rystic/egress holds what the simulator would have sent out, in order: Stripe’s webhooks, Resend’s emails, Kalshi’s WebSocket frames. Webhooks covers them.

curl -s localhost:$PORT/_rystic/egress | jq -c '{total, dropped, types: [.events[].type]}'
# -> {"total":1,"dropped":0,"types":["customer.created"]}
  1. Each event carries seq, effect, type and payload. ?tail=N returns the last N.
  2. total counts everything emitted since the last reset; events may hold fewer.
  3. A reset empties the log and restarts seq at 1.

Retention

  1. The log keeps 100,000 events by default. Beyond that the oldest drop, and dropped counts them: check it before trusting the log as complete.
  2. RYSTIC_EGRESS_RETAIN (rystic run --egress-retain) sets the bound at boot; POST /_rystic/egress-retain with {"retain":N} sets it at runtime. 0 removes it.
  3. GET /_rystic/version shows the bound as egress_retain. A reset keeps it.

The state UI

Open http://localhost:$PORT/_rystic/ui in a browser. Its cards:

  1. Activity: the live feed.
  2. Store: the state, register by register.
  3. Effects: the egress log.
  4. Network: the network profile (Network profiles).
  5. Reset, in the header, wipes the simulator as POST /_rystic/reset does.

A simulator with its own view of the vendor’s app, such as Stripe, Lob and PayPal, adds its cards above these; the simulator’s page describes them.

Done with the example: rystic stop observe.

Last updated on