Skip to content
ImmersiveFusionPublic

About

A small system you can break. Paste a Mermaid diagram, break a call, fire one request, and real OpenTelemetry comes out the other side. Fully synthetic: no account, no install, nothing to deploy.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Latest commit

 

History

135 Commits

Folders and files

An open box holding a small glowing system on a grid, one connection broken and sparking

Shoebox

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.

Shoebox showing a pasted diagram, the rendered topology, and the result of one fired request

What it does

  1. Paste a diagram. Mermaid flowchart in, topology rendered.
  2. Break a call. A label on an edge is all it takes.
  3. 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.
  4. 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.
  5. Start from an example. Eighteen prebaked scenarios, grouped.
  6. 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.

The diagram language

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)]
Loading

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]
Loading

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.

Where the telemetry goes

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.

Configuring a deployment

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.

Configuring your machine

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.Api

That 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/status

configured: 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.

For agents

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.

Pacing

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.

Timed firing

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.

Running it

Prerequisites

  • .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 ci only warns, which makes this easy to misdiagnose as an Angular problem.

Nothing else. No SQL Server, no Redis, no containers.

Two processes

# 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 start

Open 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.

Checking the API on its own

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}'

Building and testing

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 tests

How isolation works

One 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.

For contributors

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.

Status

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.

The family

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.

Contributing

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

Connect

Email | LinkedIn | Discord | GitHub | YouTube

License

Apache License 2.0, see LICENSE.

Copyright 2026 ImmersiveFusion

About

A small system you can break. Paste a Mermaid diagram, break a call, fire one request, and real OpenTelemetry comes out the other side. Fully synthetic: no account, no install, nothing to deploy.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages