v2 architecture — decoupled JSON API + React SPA
v2 architecture — decoupled JSON API + React SPA
Why
The v1 dashboard is a Livewire application: every page is a Livewire card,
navigation is wire:navigate, and the whole thing ships behind a single ~1 MB
ECharts bundle. It works, and the correlation model behind it is strong — but
the presentation layer fights us where a telemetry product needs to be sharp:
- Interaction. Cross-card time-brushing, virtualised tables over thousands
of rows, live-tail, flame graphs, a topology you can drag — these are
client-side concerns. Livewire round-trips the server for state that should
never leave the browser, and
wire:navigateinserts view transitions and a progress bar on every click that read as jank, not speed. - Coupling. View, state, and query live in one PHP class. There is no clean seam to build a second consumer against (a mobile view, an embedded panel, a status page) short of the read-only MCP server.
- Delivery. One un-split bundle that the host's web server must ship whole;
under
php artisan serveit truncates under load. A real asset pipeline (code-splitting, hashed chunks) is not available to a Blade+Livewire UI.
Decision: a big-bang v2 that removes Livewire entirely. The package becomes a versioned JSON API plus a compiled React single-page app it serves as static assets. UI and API are decoupled — they talk only over HTTP — so each can change independently and a future non-web client is just another API consumer.
This is the mainstream shape for this kind of product: Grafana, Datadog and Sentry are all a JSON API with a separate JS app in front of it.
What we keep (≈80% of the code)
The valuable part of v1 is already framework-agnostic and is carried over unchanged:
Contracts/—MetricsSource,TracesSource,LogsSource,AggregatesSpans.Connectors/— Prometheus/Mimir, Tempo, Loki drivers,ApiClient,ConnectionManager, the ClickHouse store.Queries/Ir/— the backend-neutral query IR (MetricQuery,TraceQuery,LogQuery) and its per-driver compilers.Queries/Results/— the readonly result DTOs (TimeSeries,Trace,Span, …).Analysis/—SignalContext(trace↔metrics baselines),RequestReport,ErrorGroupReport(grouping + suspect-deploy),TraceView,TraceLogs,TraceProfile,MetricSummary.Support/—Annotations,Format,ScopeLock,MetricScope,ExceptionFingerprint, schema detection.Mcp/— the read-only MCP server (already a headless consumer of the above, and proof the core stands on its own).
What is replaced
Cards/(Livewire) andresources/views/(Blade) are removed. Each card today does two things: build a data array, and pick a Blade view. In v2 the data-building becomes an API endpoint and the view becomes a React component. The queries and analysis a card leans on do not move.resources/js/telemetry-ui.js(the ECharts + Alpine glue) and the hand-writtenpublic/telemetry-ui.cssare replaced by the SPA's own build.
Shape: one package, internal decoupling
Stay one composer package (cboxdk/laravel-telemetry-ui). A host does
composer require and gets, mounted under the configured path:
GET {path}/api/v2/*— the JSON API.GET {path}/{any}— a catch-all returning the SPA shell (index.html), whose hashed JS/CSS chunks are served by the existing asset controller frompublic/.
The SPA and API are decoupled internally (the SPA only ever calls the API over
HTTP), which buys the architectural benefits — independent iteration, a stable
contract, the option to host the SPA elsewhere later — without forcing the
host to deploy two apps. Inertia is deliberately not used: it is an app-level
adapter, awkward to ship inside a mountable package, and it re-couples routing
to the server. Plain fetch against a versioned API keeps the seam clean.
Optional, later: split the core out as
cboxdk/laravel-telemetry-query(connectors + IR + analysis) withlaravel-telemetry-uidepending on it for the API + SPA. Worth doing once a second consumer exists; not a blocker for starting v2.
The API
- Versioned under
/api/v2. One endpoint per view unit (roughly one per v1 card): it returns the same data array the card built, as a typed JSON resource. Endpoint shapes are the contract the SPA is written against. - Auth is the existing
viewTelemetryUigate, moved to route middleware. The SPA authenticates with the host session + CSRF (same-origin), so no token plumbing for the common case. - Scope & tenancy (
ScopeLock,MetricScope, the service/env/period selection) move from Livewire component state into a per-request scope resolved from query params + the tenancy resolver — the fail-closed semantics are unchanged, just relocated. - Live data (log/request live-tail,
wire:stream-style updates) becomes an SSE endpoint the SPA subscribes to; polling is the fallback. - Errors are typed (backend-down vs empty vs forbidden) so the SPA renders the right state instead of a blank card.
Extraction discipline: pull each card's data-building into a plain service or
action returning a DTO, then wrap it in a thin controller. Many analyses already
are such services (SignalContext::for(...), RequestReport, ErrorGroupReport)
and become endpoints almost directly.
The SPA
- Stack: React + Vite + TypeScript. TanStack Query for data (caching,
background refresh, the global period/scope as query keys), TanStack Router
for client-side navigation (instant, no
wire:navigatetransitions), ECharts for charts (reused knowledge), a virtualiser (TanStack Virtual) for big tables. - Base path: the package mounts under a host-configured prefix; the SPA reads
it from a bootstrap
<meta>/global so the router and asset URLs are correct. - State that stays client-side: time-brush selections, table sort/filter, open drawers, the command palette — none of it round-trips. URL remains the source of truth for shareable state (period, scope, open trace).
- Delivery: Vite build → hashed chunks in
public/, code-split per route, so the 1 MB-whole-bundle problem disappears. Assets are committed (astelemetry-ui.jsis today) so hosts need no Node toolchain.
Dimensions, filters & drill-down (the core new capability)
The biggest product gap in v1 is that a span is a raw attribute dump — a
key/value table with no way to filter, group or drill by those keys. A request
carrying billing.customer_id, billing.campaign_id, user.id, client.address
should let you ask "everything for customer 8655", "group errors by campaign",
"which customers hit this 422" — the questions Datadog facets, Honeycomb
dimensions, Sentry tags and New Relic FACET all answer. v2 makes dimensions a
first-class primitive.
-
Every attribute is a facet. In any list or detail, each attribute value is a control: click to filter to it, ⌥-click to exclude, "group by" to break the view down, "top values" for its distribution. This is the drill-down missing on customer / user / IP / route / query / view today.
-
Config-declared custom dimensions. A host registers the attributes that matter and how to present them:
TelemetryUi::dimension('billing.customer_id', label: 'Customer', group: 'Billing', link: fn ($id) => route('customers.show', $id)); // optional link OUT TelemetryUi::dimension('billing.campaign_id', label: 'Campaign', group: 'Billing');Declared dimensions get a label, a facet-panel group, optional formatting and an optional link back into the host app. They then appear everywhere — the facet sidebar, group-by menus, the filter bar, and as clickable chips on every trace/request — with no UI code. Undeclared attributes stay filterable (raw key), just not promoted.
-
Facet panel + filter bar (Datadog/Honeycomb): a left panel of dimensions with top values and counts; a top bar showing the active query as removable chips; the URL is the query, so every view is shareable and back/forward works.
-
Feasible on today's core. Filtering by any attribute is native TraceQL (
{ span.billing.customer_id = "8655" }) through the existing IR; group-by / top-values is theAggregatesSpanscapability (exact on the ClickHouse store / telemetryd, read-side sample on plain Tempo). No new query engine — aDimensionsregistry + facet endpoints on top of what exists.
Screens: an Explore surface + entity pages, not a card grid
v1 is fixed cards on fixed pages. v2 has two shapes:
- Explore — one surface over traces / requests / logs / errors: filter by any dimension, group-by, a distribution/heatmap on top, a virtualised result list below, click any row → the trace story. Where ad-hoc questions get answered (Datadog APM/Log Explorer, Honeycomb, Sentry Discover).
- Entity pages — a route, query, view, host, customer or job is a real page that tells a story: RED headline + trend, top contributing dimensions, slowest/failing example traces, correlated errors and deploys — not a raw attribute table. Raw lives behind a "Raw" toggle, last, as the trace waterfall already does. Clicking a query, view or route opens this, never a dead sidebar or a raw dump.
Entity pages are generated, not hand-built per type: an entity is "a dimension
value" (route = http.route, query = normalised statement, customer =
billing.customer_id), so one page template serves all of them, scoped by one facet.
What v1 gets wrong (explicitly removed in v2)
Symptoms hit in production, and the v2 answer:
- Raw attribute dumps as "detail." A span/route/view detail is a key/value table. → Entity pages lead with the story; raw behind a toggle.
- No drill-down / dimension filters (customer, user, IP, campaign). → Facets + filter bar + config dimensions above.
- Destructive toggles. The request-log toggle replaces the routes table on the Requests page. → Explore is its own surface; routes and log are distinct, not a mode-swap that hides one.
- Dead detail panes. Clicking a route shows a sidebar with useless content; queries/views aren't clickable at all. → Every row opens a real entity page.
These are consequences of the fixed-card, drawer-first Livewire model — which is why v2 is a rewrite, not a patch.
Migration mechanic
For each v1 card:
- Extract its
render()data-building into a service returning a DTO (leave the queries/analysis it calls untouched). - Expose the DTO as a JSON resource on a
/api/v2route. - Write the React component that fetches it and renders it.
The hard, already-solved parts — the IR, the connectors, the correlation
(Analysis/*) — are consumed as-is. This is a systematic transform of the view
layer, not a re-think of the product.
Phased plan
Big-bang in outcome, de-risked in sequence — the API ships and is proven before the SPA exists.
- Extract & define. Move card data-logic into framework-free services/DTOs;
nail down the
/api/v2resource shapes. No behaviour change yet. - API layer. Ship the versioned JSON API + gate middleware + scope context + the SSE endpoint, running alongside the v1 Livewire UI, so it is validated against real backends (the MCP server already exercises the core the same way).
- SPA. Build the React app page by page against the live API — start with Traces / a cross-signal Explore view (the most interaction-heavy, where the new stack pays off first).
- Cut over. Delete
Cards/,resources/views/, Livewire, the old JS/CSS. Tag v2.0. Keep a maintained1.xbranch for hosts that cannot move yet.
Where the "Datadog/Sentry replacement" work goes
Richer grouping, log parsing, aggregation, SLO/monitors, exemplars (metric→trace) are backend features. They belong in the query/analysis core and reach every consumer (SPA, MCP, future clients) without frontend churn. The frontend rewrite does not, by itself, deliver them — plan them as core work, in parallel.
Risks & mitigations
- Long no-ship window (the classic big-bang failure). Mitigate: keep
1.xreleasable throughout; ship the API in phase 2 so value lands before the SPA is done; build the SPA route-by-route, not all-at-once. - Losing mature features. v1 carries a lot that is easy to under-estimate: the stacked, deep-linkable correlation drawer; the command palette; deploy annotations on every chart; suspect-deploy on error groups; the request "story"; the service graph; whole-row drill-down. Write an explicit feature-parity checklist and treat it as the cut-over gate — do not delete Livewire until it is green.
- SPA-in-a-package edges. Base-path routing, same-origin auth/CSRF, and CSP for the served assets are all solvable but must be designed up front, not discovered during cut-over.
Feature-parity checklist (cut-over gate)
Status on feat/v2-spa (2026-09-22). "Live" = verified in the browser against
telemetryd via the demo; "tests" = covered by Pest (API) and/or Vitest (SPA).
- Every v1 page has an endpoint + component at parity — all ~120 cards are
Panels\Builtin\*behind/api/v2/panels/{id}, rendered by the SPA's kind renderers; PagesSmokeTest hits every page and panel. Live: dashboard, jobs, traces, exceptions, route/customer entities. Detail pages became the entity pages' Metrics tab. - Global scope (service/env/period, custom range, tenancy lock) + URL state —
RequestScope+ScopeLock(ScopeLockTest); custom range popover and brush-to-zoom writefrom/to; remembered viaPOST /view-state. Live. - Stacked, deep-linkable trace/issue/exception drawer —
?drawer=error:…~trace:…, back/crumbs/Esc/full-page. Live. - Correlation: trace → surrounding metrics (baselines/outliers), trace → logs,
trace → profile, error group → trace/release/host + suspect deploy —
/traces/{id}and/errors/{group}(TraceDrawerTest); trace Story/Context/ Logs/Profile tabs; entity stories add correlated error groups + deploys. Live. - Deploy/change annotations on charts — marker lines on every panel chart and entity trends; Deploys panel. Live (annotate command).
- Command palette (⌘K), whole-row drill-down, copy-link/deep-links — palette
(pages, scope, trace id, error group,
key=valuefilters, entity jump);_linkrows; copy-link button. Live + tests. - Live-tail (logs, request log) via SSE —
/stream/{logs|requests}withLast-Event-IDresume and polling fallback (StreamTest); panel "Tail" polls. Live (logs). - Schema-detection driven nav + host-registered nav links — bootstrap nav is detection- and gate-filtered (SchemaDetectionTest, NavLinkTest); links in the rail foot.
- Read-only MCP server unaffected (it consumes the core, not the UI) — McpToolsTest unchanged and green.
New in v2 beyond parity: dimensions registry, facet panel, filter bar (URL is the query), group-by, drill-down on every value, generated entity pages with a story and Raw tab last.
Open decisions
- Same-origin session auth only, or also a token mode for an externally-hosted SPA?
- Split the query core into its own package now, or after v2 ships?
- SSE only, or WebSockets where a host runs Reverb?