docs/limits.md: measured tables for sheet size, formula topologies, rendering, imports of real datasets, undo and JEV, with what is comfortable, degraded and unsupported and the dominant cost of each; the hotspots fixed with before and after numbers; and a prioritized list of structural changes that would raise the limits toward 10 M cells, with effort. docs/observability.md: turning the event log on, what it records, its overhead, DuckDB queries over logs and stress runs, and the Collector, ClickHouse and Grafana stack.
- commit
- d0aceb78f3ad14ded12164abff44ec6ac57ea135
- parent
- 464307812aa177bb9df1a6ad77e0e45f6c94ff57
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 3 | + |
| A | docs/ | 225 | ++++++++++++++++++++++++++++++ |
| A | docs/ | 192 | ++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
@@ -60,6 +60,8 @@ searches every command.
6060 - [Files](docs/files.md)6161 - [JEV functions](docs/jev.md)6262 - [Architecture](docs/architecture.md), [UX bar](docs/UX.md) and [testing](docs/testing.md)63+- [Limits](docs/limits.md): how big a sheet 012 handles and where it slows down64+- [Observability](docs/observability.md): event logs, DuckDB, and a local Collector, ClickHouse and Grafana stack6365 - [Roadmap](ROADMAP.md)6466 6567 ## Development@@ -70,6 +72,7 @@ make test # unit tests
7072 make e2e # the real binary in libghostty, Ghostty's terminal core (needs Zig 0.16+ and pkg-config)7173 make screens # golden screens and the review gallery7274 make oracle # formulas and formats against excelize75+make stress # benchmarks on synthetic and real data; make stress-report compares runs7376 ```7477 7578 See [docs/testing.md](docs/testing.md).@@ -0,0 +1,225 @@
1+# Bounds of support2+3+What 012 handles well, where it degrades, and why, measured. Numbers come4+from `make stress` (synthetic worst cases and real datasets, see5+[Measuring](#measuring)), `make stress-e2e` (key press to screen through a6+real terminal emulator), and the telemetry described in7+[observability.md](observability.md), which tracks how they move.8+9+Machine: Apple M5 Pro (18 cores), 48 GB, macOS, Go 1.27.1, arm64. Times10+are single-threaded: the engine and the UI run on one goroutine.11+12+The yardstick is a keystroke through to its frame: under 16 ms feels13+instant (one frame at 60 Hz), under 100 ms feels responsive, past that14+it lags, and past a second it stalls.15+16+## Summary17+18+| Dimension | Comfortable | Degraded | Unsupported | Dominant cost |19+|---|---|---|---|---|20+| Sheet size | Everything up to the hard limit, 8192 x 256 (2.1 M cells): navigation and drawing don't depend on size | Loading and saving a full sheet: 1 to 2 s; 600 MB of heap | More than 8192 rows or 256 columns: imports keep the first 8192 x 256 and say what they dropped | Heap per cell (about 300 B), JSON file format |21+| Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1 to 2 ms) | 1000 SUMs over a full column (1000 x 8192 reads): 170 ms per edit | 8192 running totals (`=SUM($A$1:An)`, 33 M reads): 0.8 s per edit | About 20 ns per cell read: two map lookups |22+| Full recalc | Any sheet without heavy range fan-in: under 40 ms for 213 k numbers | 1000 full-column SUMs: 170 ms | 8192 running totals: 0.7 s | Same as above |23+| Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |24+| Selection statistics | Any selection, once computed (cached) | Extending a selection over 2 M cells: 30 ms per key | | Summing 2 M map entries per change |25+| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet in about 1 s) | XLSX with formulas: 200 k cells/s | Data past the limits (dropped, with a note) | Building cells one at a time; XLSX formula translation |26+| Undo | One step of any size: undo costs what the edit cost | 100 steps that each rewrite a whole column: 217 MB held | | Whole-cell before-images, 264 B per cell per step |27+| JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |28+| Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |29+| Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |30+31+## Sheet size32+33+Hard limits: `sheet.MaxRows = 8192`, `sheet.MaxCols = 256` (A..IV), as in34+Lotus 1-2-3 Release 2.35+36+| Measure | 8192 x 26 (213 k cells) | 8192 x 256 (2.1 M cells) |37+|---|---|---|38+| Build through `Load`, then full recalc | 90 ms | 1.4 s |39+| Live heap | 64 MB | 602 MB (about 300 B per number cell) |40+| Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |41+| Open `.012` | 134 ms (116 MB allocated) | 2.08 s (1.3 GB allocated) |42+| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 87 / 40 ms | 0.71 / 0.66 / 0.91 / 0.72 s |43+| Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |44+45+Heap per non-blank cell after loading (`BenchmarkMemory`,46+`BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and47+reference lists), 200-character text 790 B, imported CSV 275 to 300 B.48+A `Cell` struct is 216 B; the rest is the map entry, the input string49+and a boxed number for the parsed literal.50+51+## Formula topologies52+53+`BenchmarkEdit` sets one cell and recalculates what depends on it;54+`BenchmarkRecalcAll` recomputes everything. 8192 is a full column.55+56+| Topology | Edit | Full recalc | Undo + redo |57+|---|---|---|---|58+| Independent cell in 8192 x 26 numbers | 0.5 us | 38 ms | 0.8 us |59+| Chain: A(n) = A(n-1)+1 down 8192 rows | 1.7 ms | 1.1 ms | 3.4 ms |60+| Fan-out: one cell read by 8192 formulas | 1.3 ms | 0.9 ms | 2.7 ms |61+| Volatile: 8192 TODAY() and RAND() | 1.25 ms | 0.8 ms | 2.5 ms |62+| 1000 named ranges, one formula each | 48 us | 2.8 ms | 99 us |63+| Fan-in: 1000 x SUM(A1:A8192) | 174 ms | 172 ms | 350 ms |64+| Running totals: 8192 x SUM($A$1:An) | 0.73 to 0.80 s | 0.75 s | 1.45 s |65+66+Every volatile formula is recomputed on every change, as in Sheets, so67+8192 volatile cells add about 1.2 ms to every edit anywhere.68+69+Through a real terminal (`make stress-e2e`: 200 x 60, 8192 x 26 imported,70+200 SUMs over column A): arrow keys p50 16.7 ms, p95 17.5 ms; an entry71+that recalculates the 200 SUMs p50 57 ms, p95 72 ms. The arrow floor is72+Bubble Tea's renderer, which draws at most 60 frames a second, so a key73+waits up to 16 ms for the next frame; 012's own share of an arrow key is74+0.2 to 1 ms.75+76+## Rendering77+78+Only visible rows and columns are drawn, so frame time depends on the79+terminal size, not the sheet. A frame is `View` plus what Bubble Tea80+does with its string: parse it into a cell buffer and diff it onto the81+terminal (`BenchmarkFrame`).82+83+| Sheet | 80 x 24 | 200 x 60 | 400 x 120 |84+|---|---|---|---|85+| Empty | 0.19 ms | 0.89 ms | 3.2 ms |86+| 8192 x 256 numbers | 0.20 ms | 1.05 ms | 4.1 ms |87+| 8192 rows of 500-character unicode text | 0.34 ms | 1.37 ms | 4.4 ms |88+| 1000 named ranges | 0.30 ms | 1.06 ms | 3.5 ms |89+| 20 charts (text) | 0.82 ms | 2.9 ms | 7.3 ms |90+91+About half of a frame at 400 x 120 is Bubble Tea's parse and diff of the92+view string. Key presses add little: an arrow key through to its frame93+is 0.21 ms at 80 x 24 and 1.06 ms at 200 x 60; Page Down 1.26 ms.94+95+With the whole of an 8192 x 256 sheet selected, the status line's Sum,96+Avg and Count cover 2.1 M cells: 29 ms to compute, then cached until a97+cell changes, so frames with a standing selection cost 0.3 ms; each key98+that changes the selection pays the 29 ms again.99+100+## Imports101+102+Real, openly licensed datasets fetched by `scripts/stress-data.sh`103+(sources, licenses and checksums in `scripts/stress-data.tsv`):104+105+| File | Shape | Import | Throughput | Heap per cell |106+|---|---|---|---|---|107+| OWID energy (CSV) | 23,377 x 130, 345 k cells kept | 195 ms | 1.8 M cells/s, 47 MB/s | 277 B |108+| Airport codes (CSV) | 86,134 x 13, first 8192 rows kept | 83 ms | 107 MB/s | 274 B |109+| NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |110+| Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |111+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas | 19 ms | 205 k cells/s | 358 B |112+| Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |113+| Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |114+115+Past the limits, imports keep the first 8192 rows and 256 columns and116+say how much they left out; reading the rest of the file still costs117+(the airport codes file is read to its end).118+119+## Undo120+121+Each step keeps a copy of every cell it changed (`MaxUndo = 100`122+steps). Undoing costs about what the change cost, since it recalculates123+the same cells. Clearing 213 k cells and undoing it takes 158 ms. The124+history is bounded by steps, not bytes: 100 steps that each rewrite a125+whole column of 8192 cells hold 217 MB.126+127+## JEV128+129+Each JEV formula is volatile: every recalculation looks its question up130+in the cache, and every answer that arrives recalculates all JEV131+cells. With a fake client answering instantly (`BenchmarkJEV`), the CPU132+per answer grows with the number of JEV cells:133+134+| JEV cells | Per answer | All answers |135+|---|---|---|136+| 100 | 26 us | 2.7 ms |137+| 1000 | 261 us | 0.26 s |138+| 4000 | 1.3 ms | 5.1 s |139+140+Against the real service the network dominates: at most 8 questions are141+in flight, so 4000 questions at 300 ms each take 2.5 minutes, of which142+5 s is recalculation spread over that time. Each answer blocks the UI143+for its recalculation (1.3 ms at 4000 cells).144+145+## Hotspots found and fixed146+147+Each with its benchmark before and after, in the commit that fixed it.148+149+| Hotspot | Evidence | Before | After |150+|---|---|---|---|151+| SUM, AVERAGE, COUNT allocated twice per cell read: `return &v` of the `Value` parameter moved it to the heap on every call | CPU profile of a fan-in edit: `toNum` and `aggregate.func1.1` allocating 3 GB | Edit/fanin 320 ms, 16.4 M allocs | 203 ms, 2 k allocs |152+| Every changed cell scanned every formula with a range reference | Running totals edit: 67 M `Rect.Contains` calls | Edit/running 1.83 s | 0.70 s |153+| `RecalcAll` traced dependents from every cell (it found nothing new) | RecalcAll/names spent its time in the name scan | RecalcAll/names 157 ms, fanin 395 ms | 2.8 ms, 172 ms |154+| Overflowing text cut with `ansi.Cut` per column, rescanning the text each time | CPU profile of View on long text: 90% in `ansi.Cut` | View/longtext 400 x 120: 40.5 ms | 2.1 ms |155+| `Names()` upper-cased both names in every sort comparison, every frame | 21 k allocations per View with 1000 names | View/names 80 x 24: 662 us | 197 us |156+| Status line summed a whole-sheet selection with 2 M map lookups, every frame | CPU profile: `RangeStats` 87% of a frame | 91 ms per frame | 0.3 ms (cached), 29 ms when the selection changes |157+| JEV cache keys encoded with `encoding/json` | CPU profile of answers: JSON 60% of recalc | JEV/1000 575 ms, JEV/4000 9.3 s | 263 ms, 5.1 s |158+159+## What would raise the bounds160+161+In order of value for effort. The first three are what raising the162+limits toward Google Sheets' 10 M cells (or Excel's 1,048,576 x 16,384)163+would take; without them a 10 M-cell sheet would need about 3 GB of164+heap, take 10 s to open and 140 ms per key to extend a selection over165+it.166+167+1. **Compact cell storage** (L, 2 to 3 weeks). Cells live in168+ `map[Addr]*Cell`, 300 B each: a 216 B struct carrying formula-only169+ fields (expression, reference lists, inferred format) on every cell,170+ plus the map entry, the input string and a boxed literal. Storing171+ columns in row blocks (say 1024 rows) of compact values, with172+ formulas, formats and styles in side tables keyed by address, and173+ the input string kept only when it differs from the canonical text174+ of the value, would bring numbers to about 20 to 40 B per cell and175+ turn every cell read in recalc from two hash lookups (20 ns) into an176+ index (2 to 3 ns). This is also the change the multi-sheet refactor177+ touches, so it is best planned with it.178+2. **Range dependency index and shared range results** (M, 1 week).179+ Range users are now indexed by column; an interval index per column180+ (or per row block) would make finding them independent of how many181+ share a column. Memoizing aggregate results per range within one182+ recalc would make 1000 identical `SUM(A1:A8192)` cost one, and183+ running totals could be computed from a prefix sum per column block:184+ the fan-in and running-total rows of the table above would drop to185+ milliseconds.186+3. **Streaming, compact file format** (M, 1 week). The `.012` file is187+ JSON decoded whole (1.3 GB allocated to open 47 MB). A streaming188+ decoder over the same format would roughly halve open time and cut189+ allocation tenfold; a columnar or gzip-compressed variant would cut190+ the size about fourfold.191+4. **Incremental selection statistics** (S, 2 days). Keep per-column192+ sums and counts, updated in `place` and after recalc, and compute a193+ selection's Sum and Count from column totals minus the rows outside194+ it; extending a whole-sheet selection would drop from 29 ms to well195+ under 1 ms.196+5. **JEV recalc by question** (S to M, 3 days). Index JEV cells by197+ question key and recalculate only the cells whose question was198+ answered (and their dependents), and coalesce answers that arrive in199+ the same frame: answering 4000 questions would cost 4000 small200+ recalcs instead of 4000 full ones.201+6. **Undo bounded by bytes** (S, 1 day). Cap the history by the memory202+ its before-images hold, and store formatting-only changes as diffs.203+7. **Frame rate** (S, hours). Bubble Tea draws at most 60 frames a204+ second; asking for 120 would halve the key-to-screen floor from about205+ 16 ms to 8 ms at the cost of more redraws.206+8. **Recursion limits** (S, hours). The parser and evaluator recurse207+ without a limit; a depth cap (Excel allows 64 nested functions) would208+ turn a pathological file into an error rather than a deep stack.209+210+## Measuring211+212+```sh213+make stress # fetch datasets, run everything, record the run (5 minutes)214+BENCH='Edit|Frame' make stress # a subset; BENCHTIME=2s for steadier numbers215+make stress-report # latest against previous and baseline, with trends216+make stress-e2e # key press to screen through libghostty217+```218+219+Benchmarks are built only with `-tags stress`, so `go test ./...` stays220+fast. They live in `internal/sheet/stress_test.go` (engine),221+`internal/ui/stress_test.go` (View, frames, keystrokes, JEV, telemetry222+overhead), `internal/fileio/stress_test.go` (imports and exports) and223+`e2e/stress_test.go`; the synthetic sheets are built by224+`internal/stress`. Results accumulate in `.deps/stress/results` and, with225+the observability stack up, in ClickHouse.@@ -0,0 +1,192 @@
1+# Observability2+3+How to see what 012 spends its time on, in a session or across stress4+runs. There are two paths that share one set of numbers:5+6+- **Zero setup**: 012 writes JSON event logs, `make stress` writes JSONL7+ run records, and DuckDB queries both files directly.8+- **Stack**: `make obs-up` starts an OpenTelemetry Collector, ClickHouse9+ and Grafana in Docker; the collector tails 012's logs and the stress10+ runs load into ClickHouse, with dashboards on top.11+12+The measured bounds these tools track are in [limits.md](limits.md).13+14+## Turning it on15+16+Telemetry is off unless a log file is given:17+18+```sh19+012 --log events.jsonl budget.012 # or --log=events.jsonl20+O12_LOG=events.jsonl 012 budget.012 # the same through the environment21+O12_LOG_LEVEL=debug # also one event per frame22+```23+24+Stdout belongs to the terminal UI, so events only ever go to the file,25+appended one JSON object per line. With no log file nothing is timed:26+each instrumented call site costs one atomic load (15 ns, no27+allocation; `go test -bench . ./internal/telemetry`), and a keystroke28+through to its frame costs the same with telemetry on or off (1.06 ms on29+a 200x60 screen of an 8192x26 sheet, `BenchmarkTelemetry` under30+`-tags stress`). With it on, each event costs about 0.5 us.31+32+Events carry sizes, counts and durations, never cell contents, file33+contents or secrets. The JEV API key is never logged; neither are file34+names.35+36+## What is recorded37+38+Every event has `time`, `level`, `msg` (the event name), `event` (the39+same, as an attribute for queries), `service` (`012`) and `dur_ms`.40+41+| Event | When | Attributes |42+|---|---|---|43+| `start` | the log opens | `version` (VCS revision), `go`, `os`, `arch`, `cpus`, `pid` |44+| `recalc` | every recalculation | `full` (after loading), `evaluated` (dirty cells recomputed), `cells` (stored), `volatile`, `circular` |45+| `frames` | once a second while frames are drawn | `frames`, `render_p50_ms`, `render_p95_ms`, `render_max_ms` (View), `keys`, `key_p50_ms`, `key_p95_ms`, `key_max_ms` (key press to the end of its frame), `heap_bytes`, and gauges: `cells`, `jev_in_flight`, `jev_queued` |46+| `frame` | every frame, at debug level | `key_ms` |47+| `command` | every registered command | `id`, e.g. `data.sort` |48+| `sort`, `filter`, `find`, `replace`, `fill` | the operation itself | `rows`, `cols`, `keys`; `hidden`; `matches`; `replaced`; `cells` |49+| `import`, `export` | file transfers (`internal/fileio`) | `format`, `bytes`, `rows`, `cells`, `notes` |50+| `save`, `open` | the native `.012` file | `cells`, `bytes` (save times serializing, on the UI goroutine) |51+| `jev` | each question sent | `kind`, `queued` (waiting when sent), `outcome` (`ok` or `failed`) |52+| `chart.image` | each kitty-graphics chart image | `type`, `w`, `h`, `bytes` |53+54+Failures are logged at `WARN` with an `error` message.55+56+The engine doesn't log: `sheet.OnRecalc` hands each recalculation's57+counts to `cmd/012`, which logs them. The call sites use58+`internal/telemetry` (`Start`/`End` spans, `Event`, `Frame`, `Set`), so a59+different backend can replace the JSON file without touching them.60+61+## Querying logs with DuckDB62+63+Nothing to install beyond the `duckdb` CLI:64+65+```sql66+-- Time per event type67+SELECT msg AS event, count(*) AS n,68+ round(quantile_cont(dur_ms, 0.5), 2) AS p50_ms,69+ round(quantile_cont(dur_ms, 0.95), 2) AS p95_ms,70+ round(max(dur_ms), 2) AS max_ms71+FROM read_json_auto('events.jsonl') WHERE msg <> 'frames'72+GROUP BY ALL ORDER BY n DESC;73+```74+75+From `make stress-e2e` (an 8192x26 import, 200 SUMs over a column, then76+arrows and entries):77+78+```79+┌─────────┬───────┬────────┬────────┬────────┐80+│ event │ n │ p50_ms │ p95_ms │ max_ms │81+├─────────┼───────┼────────┼────────┼────────┤82+│ recalc │ 442 │ 1.0 │ 49.77 │ 56.94 │83+│ command │ 6 │ 0.0 │ 0.0 │ 0.0 │84+│ import │ 2 │ 96.4 │ 97.55 │ 97.68 │85+│ start │ 2 │ │ │ │86+└─────────┴───────┴────────┴────────┴────────┘87+```88+89+```sql90+-- The seconds with the slowest key presses, with memory and size91+SELECT time, frames, render_p95_ms, key_p95_ms, key_max_ms,92+ round(heap_bytes / 1048576) AS heap_mb, cells93+FROM read_json_auto('events.jsonl') WHERE msg = 'frames'94+ORDER BY key_max_ms DESC LIMIT 5;95+96+-- Slowest recalculations and how much they touched97+SELECT time, dur_ms, evaluated, cells, full98+FROM read_json_auto('events.jsonl') WHERE msg = 'recalc'99+ORDER BY dur_ms DESC LIMIT 10;100+101+-- JEV latency percentiles and failures102+SELECT count(*) AS asked, count(*) FILTER (WHERE outcome = 'failed') AS failed,103+ quantile_cont(dur_ms, [0.5, 0.95, 0.99]) AS p50_p95_p99_ms104+FROM read_json_auto('events.jsonl') WHERE msg = 'jev';105+```106+107+## Stress runs108+109+`make stress` appends one JSON line per benchmark to110+`.deps/stress/results/runs.jsonl` (gitignored): `run_id`, `ts`,111+`git_sha`, `git_dirty`, `go_version`, `goos`, `goarch`, `cpu`, `host`,112+`pkg`, `name`, `procs`, `n`, `ns_op`, `b_op`, `allocs_op`, `mb_s`, and113+`metrics`, a map of custom units such as `B/cell`, `cells/s`,114+`MB-file` or `us/answer`. `make stress-report` loads them with115+`scripts/stress/load.sql` and runs `scripts/stress/report.sql`: the116+latest run against the previous one on the same CPU and against a117+baseline (`BASELINE=run_id`, default the oldest), flagging changes past118+`THRESHOLD` (0.10) and `MIN_DELTA_NS` (2000), and the headline119+benchmarks over the last eight runs. `FAIL_ON_REGRESSION=1` makes it120+exit 1 on a regression, for CI. The same records load into ClickHouse121+(`stress.results`) with the same columns.122+123+## The stack124+125+```sh126+make obs-up # start, and print the environment for 012127+make obs-status # containers, ClickHouse row counts, events of the last hour128+make stress-load # load recorded stress runs into ClickHouse129+make obs-down # stop; data stays in the named volumes130+```131+132+`deploy/observability/docker-compose.yml`, all ports on 127.0.0.1:133+134+| Service | Image | Role |135+|---|---|---|136+| `otel-collector` | `otel/opentelemetry-collector-contrib:0.161.0` | OTLP gRPC (4317) and HTTP (4318) receivers; `file_log` receiver tailing `.deps/obs/logs/*.jsonl` (checkpointed, so restarts don't re-read); `memory_limiter` and `batch`; `clickhouse` exporter for logs, traces and metrics |137+| `clickhouse` | `clickhouse/clickhouse-server:26.3.34.136` | OLAP store: `otel.otel_logs` (and the traces and metrics tables the exporter creates), `stress.results`, `stress.latest_vs_previous`. 3 GB container limit, 75% of it for queries. User `o12`, password `o12` |138+| `grafana` | `grafana/grafana:13.2.2` with `grafana-clickhouse-datasource` 4.21.3 | http://localhost:3000, anonymous viewing; dashboards `012 runtime` and `012 stress runs` |139+140+To send a session to it, log into the directory the collector tails:141+142+```sh143+make obs-up144+O12_LOG=$PWD/.deps/obs/logs/012.jsonl ./bin/012 big.csv145+make obs-status146+```147+148+The collector parses each line: `time` becomes the timestamp, `level`149+the severity, `msg` the body, `service` the `service.name` resource150+attribute, and everything else a log attribute (as strings in151+ClickHouse's `LogAttributes` map). For example:152+153+```sql154+-- ClickHouse: recalc and import percentiles for 012155+SELECT LogAttributes['event'] AS event, count() AS n,156+ round(quantile(0.5)(toFloat64OrZero(LogAttributes['dur_ms'])), 2) AS p50_ms,157+ round(quantile(0.95)(toFloat64OrZero(LogAttributes['dur_ms'])), 2) AS p95_ms158+FROM otel.otel_logs159+WHERE ServiceName = '012' AND event NOT IN ('frames', 'start')160+GROUP BY event ORDER BY n DESC;161+162+-- Stress trend of one benchmark163+SELECT ts, git_sha, round(ns_op / 1e6, 2) AS ms164+FROM stress.results FINAL WHERE name = 'Edit/fanin-1000xSUM8192' ORDER BY ts;165+166+-- What regressed since the previous run167+SELECT * FROM stress.latest_vs_previous WHERE change > 0.1 ORDER BY change DESC;168+```169+170+The `012 runtime` dashboard shows recalculation time (p50, p95, max),171+frame and key-to-frame p95 per second, heap and cells, JEV answer time172+and queue, imports and exports with throughput, the slowest operations,173+and events per minute. `012 stress runs` shows edit latency per174+topology, frame and keystroke times, full recalc and file operations,175+memory per cell (log scales where the spread is wide), the latest run176+against the previous one, and the runs.177+178+The stack starts from scratch with `make obs-up` after179+`docker compose -f deploy/observability/docker-compose.yml down -v`.180+`stress.results` is created on first start by181+`clickhouse/init.sql`, and again (idempotently) by every182+`make stress-load`.183+184+## Not yet: native OTLP from 012185+186+012 doesn't link the OpenTelemetry Go SDK: its events reach OTLP187+through the collector's `file_log` receiver, as logs. Emitting traces188+and metrics directly (OTLP/HTTP, configured by the standard189+`OTEL_EXPORTER_OTLP_*` variables) would add the SDK as a dependency;190+the backend would sit behind `internal/telemetry`, whose spans and191+frame summaries map onto OTLP spans and histograms, with no call site192+changes. The collector's OTLP receivers are already running for it.