Views
A view is the semantic half of a frame. It’s where you say what your data means — once — so that every block can ask for things by name (ErrorRate, DurationP95, IsError) instead of repeating raw SQL. It’s close to a database view, but it carries extra meaning: how a value should be formatted, which column is time, and how the data should be scoped to the thing the frame is about.
The payoff is leverage. Define ErrorRate in the view and every stat, plot, and table can select it; change how it’s computed in one place and they all follow. And because blocks query the view rather than the table, the current time range, active filters, and the frame’s parameters fold into every query automatically — you write the metric, not the plumbing.
Here is a whole small frame — a view plus a page that reads from it:
{ "title": "Services", "view": { "tables": { "traces": { "table": "otel_traces", "timestamp": "Timestamp", "with": { "IsError": "lower(StatusCode) = 'error'", "RequestCount": "count()", "ErrorRate": "countIf(IsError) / nullif(count(), 0)" } } } }, "page": { "@block/stat": { "title": "Error rate", "value": { "@expr/query": "SELECT ErrorRate FROM traces" }, "format": "percent" } }}The page never computed an error rate — it just asked for ErrorRate. The full view schema is in the frame schema reference; this page is about how to think in views.
Tables and imports: build on what’s already there
A view has two keyed records: tables (the tables it exposes, keyed by the CTE alias blocks reference) and imports (other frames’ views spliced in, keyed by frame name). A table’s source is its table field — a raw database table, or another alias to derive from:
{ "view": { "tables": { "traces": { "table": "otel_traces", "timestamp": "Timestamp" } } } }table defaults to the alias key, so { "otel_traces": {} } is legal — but give the alias its own semantic name. The shipped packs all do (the view is traces, the physical table is otel_traces), so a physical name like otel_traces always means the raw table — in structured from: references and raw SQL alike — and can never collide with a view alias.
…but most of the time you shouldn’t start from raw tables at all. Integrations install base views that already name and shape your telemetry — reach for those through imports:
{ "view": { "imports": { "@opentelemetry/views/combined": {} }, "tables": {} } }An import inherits every scalar and the scoping of the view you point at, so your frame starts with DurationP95, IsError, and the rest already defined. A view can pull in several sources — traces and logs into one frame, or a couple of base views layered together — and the blocks see them all. A query that reads one table sees only that table’s scalars, so same-named scalars on different tables only clash when a single query JOINs both — keep a name unique across tables you’ll join together unless its definition is identical.
timestamp names the column used for time bucketing, so a @expr/timeseries_query knows what to bucket on. You set it on base tables; frames built on imports inherit it.
Derived tables: split one stream into sub-views
One physical table often carries several kinds of row that behave like separate datasets. otel_logs holds every Claude Code event — API requests, tool decisions, hook runs — each with its own fields. Repeating WHERE EventName = 'api_request' in every panel is noise, and the vocabulary blurs: DurationMs means something different for an API request than for a hook.
Point a table’s table at another alias and it derives from it — same rows, narrowed by one more predicate, plus its own scalars:
{ "view": { "imports": { "@opentelemetry/views/logs": { "where": "ServiceName = 'claude-code'" } }, "tables": { "events": { "table": "logs", "where": "ScopeName = 'com.anthropic.claude_code.events'" }, "api_requests": { "table": "events", "where": "EventName = 'api_request'", "with": { "DurationMs": "toFloat64OrNull(LogAttributes['duration_ms'])", "Requests": "count()" } } } }}Now a panel just says "from": "api_requests" and selects Requests — the scope is in the view where it belongs. api_requests inherits everything above it: the logs alias’s timestamp, the service filter, and every scalar the base view defines, on top of which it adds its own. Chains go as deep as you like, and a derived table can redefine an inherited scalar — that is the point, when DurationMs genuinely differs per event type.
table resolves alias-first: a name matching another alias in the same view is a derivation, anything else is a physical table. So give aliases semantic names distinct from physical ones (the shipped packs do) and the two never get confused.
Derivations resolve inside the frame that authors them, before anything imports it. A frame importing yours can therefore pick tables: ["api_requests"] and get a complete table — the service filter, the scope predicate, the inherited scalars, the timestamp — without logs or events being exposed at all. It can also define its own alias called logs for something unrelated without disturbing yours.
Two rules make derived tables pay off:
Scope the outer levels on a plain column. A derived table’s where is what lets the database skip data, and every sub-view below it inherits that predicate — so the highest level of a chain is where a cheap filter pays off most. A predicate on a plain column (ScopeName) prunes; one on a scalar that wraps its source in coalesce/nullif/if cannot, and a map lookup (LogAttributes['event.name']) is usually worse still, because telemetry tables carry projections that column predicates hit and map predicates miss. Split on the cheap column first, then refine with whatever the leaf needs.
A derived table is also the only place you can redefine an inherited scalar — doing it on an import is a conflict, because two definitions of one name would reach the same table. That lets a pack bind a discriminator to the fast plain column for its own sub-views while the base view keeps the portable fallback for everyone else. Check first that the fallback is really dead for your data: if the base view coalesces two sources because older rows only carry one of them, an override silently under-counts until those rows age out.
Don’t name an alias after a physical table that something else reads. If a otel_logs alias carries its own where or scalars, a sibling reading otel_logs gets that alias’s definition folded in a second time — on top of what the derivation already inherited. The compiler refuses such a query rather than return wrong (usually zero) rows, and noemata validate warns about the pair up front. Semantic alias names make the situation impossible.
Cheapness comes free: because a derived table’s rows are always a subset of its parent’s, filter suggestions, search, and the command palette query the shallowest table of a chain instead of every branch. Splitting a view into a dozen sub-views costs one lookup, not a dozen.
Scalars: name your data once
The with map is the heart of a view — a set of named expressions every query can use. They come in two kinds, and the difference is just whether they aggregate:
- a derived column transforms a row —
"DurationMs": "Duration / 1e6","IsError": "lower(StatusCode) = 'error'" - a metric aggregates —
"RequestCount": "count()","ErrorCount": "countIf(IsError)"
Scalars can build on earlier scalars, which is what lets a view read like a small vocabulary rather than a wall of SQL:
{ "view": { "tables": { "traces": { "table": "otel_traces", "timestamp": "Timestamp", "with": { "DurationMs": "Duration / 1e6", "IsError": "lower(StatusCode) = 'error' OR HttpStatusCode >= 500", "RequestCount": "count()", "ErrorCount": "countIf(IsError)", "ErrorRate": "ErrorCount / nullif(RequestCount, 0)", "DurationP95": "quantileOrNull(0.95)(DurationMs)" } } } }}When a value needs presentation metadata — a display title, or a format so every block renders it consistently — use the object form instead of a bare string:
{ "with": { "DurationP95": { "expression": "quantileOrNull(0.95)(DurationMs)", "title": "p95 latency", "format": "duration" } }}Most formats have a bare shorthand — "percent", "bytes", "duration", "rate", "number", "date", "relative" — and an object form for tuning. A duration reads milliseconds and a rate reads per-second by default, so name a unit only when the value differs, and name it by direction: input is what the value is in, output what to render it as.
{ "format": { "duration": { "input": "seconds" } } }{ "format": { "rate": { "output": "minutes" } } }A date renders absolutely by default. Its object form takes a mode — "absolute" or "relative" — or tunes one: absolute names a date-fns pattern, relative reads small deltas as human text (“a minute ago”) and falls back to absolute past a threshold.
{ "format": { "date": "relative" } }{ "format": { "date": { "absolute": "yyyy-MM-dd" } } }{ "format": { "date": { "relative": { "threshold": "hours" } } } }On @block/text and @block/stat, format also takes an expression, so how a value reads can follow the page’s state rather than being fixed when the frame is written:
{ "@block/stat": { "title": "Spend", "value": "Cost", "format": { "@expr/case": { "currency": { "usd": { "currency": "USD" }, "eur": { "currency": "EUR" } } } } }}A view scalar’s format is always a literal — a view has no runtime to resolve an expression in.
Scope: define the entity, not the filter
A view usually represents a subset of a table — database calls, server spans, one service’s traffic. where is how you carve that out, and it applies to every query in the frame so individual blocks don’t each repeat it:
{ "view": { "imports": { "@opentelemetry/views/traces": {} }, "tables": {}, "where": "IsDbCall" }}You can scope a single table the same way (its where is combined with the view-level one), which — together with distinct aliases over one physical table — is how a multi-table view filters each source differently.
Parameters: scope a view to one entity
Parameters are how one frame definition serves many entities — a single services/{ServiceName} frame that renders for checkout, cart, or any other. Declare them at the frame level: each names a column, and Noemata automatically adds column = value to every query, so the whole view narrows to that entity without you writing a single filter.
{ "title": "{{ServiceName}}", "params": [{ "type": "string", "column": "ServiceName", "required": true }], "view": { "imports": { "@opentelemetry/views/combined": {} }, "tables": {} }, "page": null}The parameter value arrives from the URL (it’s the query key too) or from a parent that embeds the frame. When you import a parameterized view, you pass the value through the import’s params:
{ "view": { "imports": { "@opentelemetry/services/{ServiceName}": { "params": { "ServiceName": "checkout" } } }, "tables": {} }}If a table doesn’t carry the parameter’s column — so auto-scoping would produce a broken filter — opt it out with disable_auto_scope: true and handle the scoping yourself.
Scoping it yourself often means a subquery — the rows you want don’t carry the column, so you reach them through rows that do. The time range folds into the table you’re filtering but can’t reach inside that subquery, so left unbounded it scans your whole retention window on every query of the view. _time_start and _time_end carry the selected window’s bounds as epoch milliseconds so you can bound it yourself:
{ "where": "SessionId IN (SELECT DISTINCT SessionId FROM otel_logs WHERE UserEmail = {UserEmail:String} AND Timestamp >= fromUnixTimestamp64Milli(_time_start) - INTERVAL 12 HOUR AND Timestamp < fromUnixTimestamp64Milli(_time_end))"}Widen that bound rather than matching the window exactly: a row inside the window can belong to something defined before it — a session resumed from yesterday — and an exact bound won’t find that definition, so the row silently loses its scope.
How a block uses the view
Blocks don’t see raw tables; they see the view. A query selects the scalars by name, and the view’s scope, parameters, and the page’s time range fold in automatically:
{ "@expr/query": { "from": "traces", "select": ["RequestCount", "ErrorRate", "DurationP95"], "where": "IsServerSpan" }}That query never mentions a time range, the service it’s scoped to, or how ErrorRate is computed — the view supplied all three. That separation is the point: the view holds the meaning, the blocks hold the presentation, and neither repeats the other.
Next
- Blocks and Expressions — what queries the view, and how.
- Pages & templates — composing the page that renders over a view.
- Frame schema reference — every view, table, and parameter field.