A small system you can break.
Paste a Mermaid diagram of a system, break something in it, and fire one request. Real OpenTelemetry comes out the other side. No account, no install, nothing to deploy.
It is fully synthetic. There is no database, no cache and no real infrastructure behind it. The diagram is the whole state, which is what makes it safe to hand to a stranger and cheap to run.
- Paste a diagram. Mermaid flowchart in, topology rendered.
- Break a call. A label on an edge is all it takes.
- Fire one request. Nothing moves until you say so. That is the point: you fired one request, you know its path, and you know what you broke.
- Read what it says back. The dot flies the path the request actually took, arrows it never crossed fade, and the run's notes appear under the result: a queue nothing consumed, a line that could not be read, a cycle it did not go round twice. That panel is the only place the run can tell you it did not match the diagram you drew -- the trace looks complete either way -- so it is worth reading before believing the picture.
- Start from an example. Eighteen prebaked scenarios, grouped.
- Share the link. The diagram travels in the URL, so a link is a runnable repro.
The diagram lives in the URL fragment, not the query string. That is a privacy
decision rather than tidiness: the pitch is "paste a diagram of your system", so
real internal service names land in these links, and a query string reaches server
logs, CDN logs and Referer headers. A fragment never leaves the browser.
Plain Mermaid, read for meaning. The shapes already map onto OpenTelemetry semantic conventions, so nothing new has to be learned.
| You write | Shoebox reads |
|---|---|
api[Orders API] |
a service |
db[(Postgres)] |
a datastore |
q[[Job Queue]] |
a queue |
cache((Redis)) |
a cache |
ext{{Stripe}} |
a third party |
worker[Worker x5] |
five replicas, load balanced |
worker[Worker #2] |
one named instance |
worker[Worker x5 rev2] |
a rollout that replaces every pod: new template hash, new pod names |
a -->|broken| b |
this call always fails |
a -->|broken: wrong table| b |
and this is why |
a -->|broken on #3| b |
only instance 3 fails |
q -->|phantom| b |
b never runs, so nothing consumes what q published |
a --> q and q --> a |
a cycle: runs, terminates, reported in cyclicPods |
A broken call into a database fails the way the database would. The diagram says what went wrong; the trace says it the way a driver does, so someone who has never seen the diagram can name the query and the cause:
flowchart LR
api[Orders API] -->|broken: wrong column 'Discount'| db[(SQL Server)]
The SELECT dbo.Orders client span is ERROR with db.system.name
microsoft.sql_server, db.query.text
SELECT Id, Status, Total, Discount FROM dbo.Orders WHERE Id = @Id,
db.response.status_code 207, and an exception event carrying a
Microsoft.Data.SqlClient.SqlException with the message
Invalid column name 'Discount'. and a stack trace, plus server.address
sql-server and server.port 1433 (5432 for PostgreSQL). Every synchronous span
above it, up to the entry point, fails with the same message; the error stops at a
queue, because a publish succeeded once the broker took the message. A run's
failedSpanCount counts failed edges, not red spans, so this one reports 1 while
two spans are red. Leave out the quoted name and the column is
Discount. wrong table, syntax error and division by zero get their own
error numbers and statements, a label that does not say SQL Server gets the
PostgreSQL equivalents (42703: column "discount" does not exist), and any other
reason fails with the reason as the message. A healthy call to the same database
carries the statement it ran and stays green.
A phantom is a dead consumer, the same thing Snowglobe means by it: services you did not know you had, so the platform infers the missing ones from the topology.
It is not a failure. -->|broken| leaves a span with an error on it, which is
evidence. -->|phantom| leaves nothing: no span from that service, no span naming
it, no entry in served-by, and nothing downstream of it either. Every span in the
trace is green.
What makes it findable at all is the half that still happens. The producer
publishes normally and its span carries messaging.destination.name, and no
receive ever correlates to it. A backend can tell something ought to be consuming
that destination; the trace can only show you that nothing did. Put the consumer
back and the phantom disappears, which is the point.
Which means a queue needs a far side. Draw orders --> q[[Job Queue]] and stop
there, and the run publishes to a destination nothing receives: the same telemetry
a declared phantom produces, span for span, so a backend reports it unconsumed
without anyone having written the word. A datastore may be the last thing in a
diagram, because a trace only ever learns about one from its caller. A queue may
not, because the whole reason to draw a queue is what happens on the other side.
The run tells you when you have done it.
The broker comes off the label. q[[Kafka]] publishes with
messaging.system = kafka, and a label naming no broker gets rabbitmq. That
matters more than it looks: a reader keys a queue on (system, destination) and
often labels the node with the system, so a hardcoded one puts every queue in
your diagram on screen under the same name.
Replicas are load balanced. Separate arrows are fan-out. q --> worker[Worker x5]
sends one request to one worker. Two arrows out of one node call both.
A revision is a redeploy.
Pods are named the way a Kubernetes Deployment names them,
{service}-{templateHash}-{suffix}, for example orders-api-9fgkfp96z4-bh4ks.
The template hash is 10 characters and the suffix 5, both from the alphabet
Kubernetes uses for generated names, and both derived from SHA-256, so the same
diagram and run index name the same pods on every machine. This replaced the old
{service}-{n} ids, so a backend that saw a diagram before the change sees new
pod names for it now.
Names are stable within a revision: x3 to x5 keeps the first three pods' names
and adds two, and going back drops those two again, as a scale-out and scale-in
would. A revision is the other case, a rollout that replaces every pod of the
service. Add rev<N> to the label and the template hash changes, so every pod gets
a new name while service.name stays the same; other services keep theirs. A node
with no marker is revision 0. The marker is lowercase rev and a number, after or
before the replica count (x3 rev2 or rev2 x3); leading zeros normalize (rev02
is rev2). A label that already ended in rev<digits>, such as
a[Payments rev2], is read as a marker, so its service.name loses that word.
Write one marker per node; a second one stays in the name and notes says so. The
N in xN, #N and broken on #N is still a position, not part of any name:
/topology/parse returns each node's pod names in that order as instanceIds.
Within the first 256 positions two pods of one service never share a name: a
taken suffix is redrawn, as Kubernetes does. That is also how many names
instanceIds lists. A position above 256 (from #300 or a large xN) is named
the same way but not checked against the others, so it can, very rarely, repeat.
For the overlap window of a rollout, draw both revisions as two nodes with the
same label, the way [Worker #1] and [Worker #2] are two pods of one service:
flowchart LR
gw[Gateway] --> old[Orders API x2 rev1]
gw --> new[Orders API x2 rev2]
Each request reaches both, so pods from two template hashes emit in the same window
under one orders-api. That is mirrored traffic rather than a load balancer's
split, because a run is exactly one request; what it shows honestly is the thing a
backend has to get right, two pod sets of one service live at once. To test that a
tool keeps one node per service through all of it, fire the same diagram edited in
steps: api[Orders API x2 rev1], then x4 rev1, then x2 rev1 (scaling, names
kept), then the two-node overlap above, then api[Orders API x2 rev2] alone (every
old pod gone).
A cycle is an architecture, not a mistake. Draw a pub/sub topic with arrows
going both ways — accounting --> topic and topic --> accounting — and a request
can arrive back where it started. Shoebox runs it. A trace is a tree and a topology
is a graph, and the way one becomes the other is that a request never visits the
same pod twice on one causal path, exactly as a real request does not. The walk
therefore ends because it runs out of paths, not because it hits a ceiling: nothing
is capped and nothing is cut off. notes names the edges it declined to take a
second time, /topology/parse returns cyclicPods, and a run returns notTaken —
edges crossed nowhere at all, as {from, to, reason} on pod ids. The editor fades
those arrows and stops their traffic animation, so a run that walked two thirds of a
diagram no longer looks like one that walked all of it. It is usually empty even for a cyclic diagram, because an
arrow refused on one path is normally crossed on another; the Azure diagram that
started all this leaves every one of its arrows crossed somewhere.
Per path, not per run. A service called by two different callers still runs twice, because those are two paths — a shared database appearing three times in one trace is correct.
If the loop is the thing you meant to model, the two directions through a topic are
almost always two different events — OrderPlaced in, AccountingCompleted out.
Drawing them as two destinations removes the cycle and is the more faithful picture
anyway.
There is a 500-span budget behind all of this, and it is a backstop rather than the mechanism: path enumeration is factorial in the worst case, so a pathological mesh can still be stopped. An honest diagram never reaches it. The Azure reference architecture that first exposed all of this comes out at 44 spans.
Labels become telemetry names, and are slugified on the way. A queue label goes
through the same slugifier as a service name, so q[[orders.created]] publishes to a
destination called orders-created. Worth knowing before you paste a real destination in
and go looking for it in your backend.
Each simulated pod gets its own TracerProvider with a Resource carrying
service.name, and its own ActivitySource. That is the same pattern
Snowglobe runs at 28 services and
59 pods, so the shape of the output is proven rather than invented here.
One destination per instance, decided at startup by whoever runs it. Snowglobe
takes -endpoint and -headers on the command line; Shoebox reads the same two
things from configuration, in the same two formats, so knowing one tool means
knowing the other.
| Setting | Environment equivalent | Effect |
|---|---|---|
Otlp:Endpoint |
Otlp__Endpoint |
Where to export. A URL, or a bare host:port, which is assumed to be TLS |
Otlp:Headers |
Otlp__Headers |
key=value pairs, comma-separated |
When those are empty the standard OpenTelemetry variables are read instead, so an
environment already configured for OTel needs nothing Shoebox-specific:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, then OTEL_EXPORTER_OTLP_ENDPOINT, and
OTEL_EXPORTER_OTLP_HEADERS.
Jaeger, Tempo, Grafana, SigNoz, a Collector, or anything else that speaks OTLP. No vendor name appears anywhere in the decision, which is the point.
Nothing configured is a supported state. Runs still execute, they just do not
go anywhere, and the UI says so, because a person seeing no traces needs to tell an
unset endpoint from a broken diagram. What is not supported is a broken endpoint:
a malformed Otlp:Endpoint refuses to start rather than exporting nowhere quietly
for the life of the deployment.
A visitor has no say in this and needs none. Nothing about a run carries a destination, so a request cannot redirect telemetry. Whoever is looking at a deployed instance reads their traces in whatever backend that deployment is wired to.
Application settings on the host, the ordinary way. On Azure App Service, an app
setting named Otlp__Endpoint becomes Otlp:Endpoint with no code involved. Do
not put a key in appsettings.json: it is committed.
Every settings file in this repo is committed, appsettings.Development.json and
launchSettings.json included, so an API key in any of them is an API key in the
history. Local settings go in user secrets instead, which live under your profile
rather than the working tree and are read in Development only:
dotnet user-secrets --project src/Shoebox.Api set "Otlp:Endpoint" "otlp.example.com:443"
dotnet user-secrets --project src/Shoebox.Api set "Otlp:Headers" "api-key=YOUR_KEY"dotnet user-secrets --project src/Shoebox.Api list shows what is set, and prints
the key in full, so mind who is looking.
Environment variables work as well and need no project change, which suits a one-off run:
Otlp__Endpoint=otlp.example.com:443 Otlp__Headers="api-key=YOUR_KEY" \
dotnet run --project src/Shoebox.ApiThat does put the key in shell history and in the process listing, so it is the worse of the two for anything you type twice.
Confirm either one landed without going near the UI:
curl -s localhost:5168/otlp/statusconfigured: true and the endpoint echoed back means the exporter is on. It does
not mean the backend accepted anything: export is best effort and a run will not
fail because the far end refused it, so check your backend for the trace id the run
returns.
Models write Mermaid fluently and reach for me unprompted, so the instructions they need are served rather than assumed:
https://shoebox.deepcube.ai/llms.txt
It covers minting a shoebox, the diagram language, every arrow form, firing, reading a run back, the share-link format, and the pacing below.
Writing it found a real hole. A model writes far more Mermaid than the parser used to
read, and everything outside the subset became a note while the run still returned
200 with a trace id — a green trace for a system nobody drew, which is worse than a
parse failure because a parse failure is visible. A real Azure reference architecture,
pasted unedited, came through as 6 of its 11 edges with 18 ignored lines and its
service names carrying the n from a escape.
Chains (a --> b --> c), dotted arrows (-.->, -. text .->), thick arrows (==>),
inline labels (-- text -->) and undirected links (---) are all read now, and the
same diagram comes through as 12 of 12. subgraph/end/direction/style are
skipped in silence, because they are understood perfectly well and simply have no
counterpart in a trace. Two judgements are reported rather than assumed: an undirected
link is read left to right and says so in the notes, and an edge pointing at a
subgraph becomes a service and says so. A line is all or nothing — half a parsed
line is how a diagram silently becomes a different one.
POST /topology/parse is free, emits nothing, and is still the thing to call before
firing anything you did not write yourself.
One shared instance serves everybody, so runs are throttled. Enforced in the app, and
restated in every 429 alongside a Retry-After:
| What | Limit |
|---|---|
POST /run per shoebox |
20 in a row, then 12 a minute |
POST /run per source address |
40 in a row, then 12 a minute, across every shoebox it mints |
POST /shoebox per source address |
5, then one every 5 minutes |
POST /topology/parse and POST /share per source address |
60 a minute |
The burst is deliberate: replica selection is round robin on runIndex, so seeing what
broken on #3 of five does takes five runs, and a burst of twenty buys two of those
back to back with spare. Eighteen shipped examples is also more than one sitting at a
tighter rate. The per-source layer exists because a shoebox id is minted by asking for
one, so a limit keyed on it alone is evaded by asking again -- which is why minting has
its own, tighter budget that did not move when the run rate rose. The two guard
different things: a run costs emission, a mint costs nothing and is only limited
because it is the way around the run limit.
These numbers are priced against emission. A run used to be unbounded (one was measured at 23,428 spans), which made this table the only ceiling on what the shared backend received. A run is now capped at 500 spans and typically emits twenty to forty. Twelve a minute is one run every five seconds, the fastest timed firing goes, and the address layer holds the same twelve so extra shoeboxes buy a bigger burst and nothing more sustained. An hour flat out from one address is 760 runs: about 33,000 spans at a typical run, and 380,000 if every run hit the cap. That ceiling is higher than it was at two a minute, and it is the price of letting the page fire on a timer.
This is politeness enforcement, not abuse defence. A distributed slam is a job for the edge; this is what stops the ordinary way a public tool falls over, which is an agent in a loop.
The play button fires one request. Under it, Keep firing fires the same request on a timer, from your browser, so a dashboard or a trace view has something arriving while you watch it:
- Pick how long first: 5 minutes up to 2 hours. There is no open-ended choice, and two hours is a hard ceiling.
- Pick how often: every 5 seconds by default, anything from 5 to 60.
- While it runs it says how often, how many runs so far, and when it stops. Stop ends it, and +15 min adds time without ever leaving more than two hours to go.
- Every run sends the diagram as it is at that moment, so an edit shows up on the next run.
- If a run is still out when the next one is due, that tick is skipped, not queued.
- A 429 is shown as it is and firing carries on at the same pace. Nothing speeds up to catch up.
- It lives only in the open page. Leave the page and it stops; reload and it is off.
Nothing on the server knows it is happening; it is just the ordinary
POST /run, inside the pacing above.
- .NET 10 SDK
- Node.js >= 24.15.0. Not optional and not a warning: the Angular 22 CLI
refuses to start on anything older, and it says so in a message that looks
nothing like a build error.
npm cionly warns, which makes this easy to misdiagnose as an Angular problem.
Nothing else. No SQL Server, no Redis, no containers.
# API on 5168. Where it exports comes from user secrets, see above. With none
# set, runs still execute, they just do not go anywhere, and the UI says so.
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5168 \
dotnet run --project src/Shoebox.Api --no-launch-profile
# SPA on 4200, proxied to the API by proxy.conf.json
npm --prefix src/Shoebox.Spa ci
npm --prefix src/Shoebox.Spa startOpen http://localhost:4200. The proxy is what makes the second process useful:
the SPA calls relative paths, so without it every request lands on the dev server
and nothing fires.
The fastest way to tell an API problem from a UI one:
curl -X POST http://localhost:5168/shoebox
curl -X POST "http://localhost:5168/run?shoeboxId=$ID" \
-H "Content-Type: application/json" \
-d '{"diagram":"flowchart LR\n api[Orders API] -->|broken: wrong table| db[(SQL Server)]","runIndex":1}'npm --prefix src/Shoebox.Spa run build -- --configuration production # ~434 kB initial, no budget warning
dotnet test tests/Shoebox.Api.UnitTests/Shoebox.Api.UnitTests.csproj # 65 testsOne shared instance serves everyone. Isolation is logical, not provisioned: each
visitor gets a GUID shoeboxId, it rides OpenTelemetry Baggage onto every span as
shoebox.id, and per-shoebox state is held in memory and deliberately not
persisted.
That is a live demonstration of baggage propagation inside a tool for learning to read telemetry, and it is also what makes a no-signup, no-install public tool sustainable.
Six things in here will cost you an afternoon if nobody tells you.
Never apply transition-duration to *. A prefers-reduced-motion block
doing that reaches inside the rendered SVG, and Mermaid then lays the graph out
roughly forty times too large: nodes at the correct size but two thousand pixels
apart, so the whole diagram scales to hairlines and the pane looks blank. The rule
in styles.scss is scoped *:not(svg):not(svg *) for exactly this reason. Do not
simplify it back. Bisected: animation-duration and animation-iteration-count
are harmless, transition-duration alone does it.
Decoration must never cost a render. decorate() in diagram-style.ts
appends classDef, class and linkStyle to the source and is wrapped in a
try/catch that returns the original text. A styling bug must not present to the
user as a syntax error. It also bails out entirely if the diagram already contains
classDef, style or linkStyle, on the grounds that whoever wrote those has
opinions.
Color carries meaning. Teal is ours, violet is a third party (the one box you cannot go and fix), magenta is only ever damage. Spend magenta on decoration and the broken edge stops being the loudest thing on screen.
Mermaid is lazy-loaded on purpose. It is roughly a quarter of a megabyte and blows the 500 kB budget when bundled. The distribution model is somebody clicking a link in a forum thread, so the page paints first and the renderer arrives after.
A run must not inherit ASP.NET's Activity. ASP.NET leaves an unsampled Activity
on the request, and a parent-based sampler then drops every simulated span beneath it:
runs come back with zero spans while the hops and the notes look perfectly fine, which
reads like an exporter problem and is not one. TopologyRunner detaches from
Activity.Current before a run and restores it after. Do not simplify that away.
A running instance locks the build output. Any Shoebox.Api you left running holds
src/Shoebox.Api/bin/, and dotnet build or dotnet test then fails with MSB3027 rather
than with anything about your change. dotnet msbuild -t:Compile type-checks without
touching bin/; for anything that has to actually run, copy the project to a scratch
directory outside the tree.
Which leads to the rule worth more than the other six: if the claim is about what the
telemetry contains, print the telemetry. A session once went seven rounds reading code
for a defect that one pass of printing every emitted service.name found immediately.
tests/Shoebox.Api.UnitTests/Run/QueueShapeDumpTests.cs is where that lives now.
Extend it before reaching for a throwaway harness.
Adding an example must never require code. examples.ts is pure data. If a new
scenario needs a code change, the design has gone wrong.
Live at shoebox.deepcube.ai. Paste a diagram, break something, fire one request, read what comes out. No account and no install, which is the whole promise, and it is kept.
chaos.deepcube.ai points at the same deployment, so a link somebody saved before the rework still lands on something current rather than on the old build that needed SQL Server and Redis.
Deploys run from a release pipeline outside this repository, which is why there is no Dockerfile and no workflow in here to find. What is genuinely missing is continuous integration: the tests run when somebody runs them, not on every pull request, so a green tree here is a claim rather than something the repository checked.
| What it is | |
|---|---|
| Snowglobe | The sealed world, pre-made. One binary generating a 28-service system emitting logs, traces and metrics, with failures injected on purpose. You shake it. |
| Shoebox | The world you build. Start from a template, change it, fire one request, look. You reach in and rearrange it. |
| sos-beacon | Listens to the real world. Not a miniature. |
A snowglobe is sealed. In a shoebox you can open it up. Same OTLP, opposite directions.
Contributions welcome. Open an issue or send a PR:
- More example scenarios, which are pure data
- Mermaid syntax the parser does not read yet
- Anything that makes the output more useful to read
Email | LinkedIn | Discord | GitHub | YouTube
Apache License 2.0, see LICENSE.
Copyright 2026 ImmersiveFusion

