Views
A view defines named tables, columns, and metrics for the blocks in a frame. Blocks query names such as ErrorRate, DurationP95, and IsError instead of repeating SQL. A view can also declare the time column and the values used to scope queries.
Define ErrorRate once in the view. Blocks that select it use the same expression. Queries against a view inherit the current time range, active filters, and frame parameters.
Here is a whole small view, in services/index.frame.json:
{ "view": { "tables": { "traces": { "table": { "from": "otel_traces", "timestamp": "Timestamp", "with": { "IsError": "lower(StatusCode) = 'error'", "RequestCount": "count()", "ErrorRate": "countIf(IsError) / nullif(count(), 0)" } } } } }}The page beside it, in services/index.page.json, reads the ErrorRate metric:
{ "title": "Services", "page": { "@block/stat": { "title": "Error rate", "value": { "@expr/query": "SELECT ErrorRate FROM traces" }, "format": "percent" } }}The view field schema is in the frame schema reference.
Tables and imports: build on what’s already there
A view has two keyed records: tables, keyed by the CTE aliases blocks reference, and imports, keyed by frame name. Each table entry uses table for rows or timeseries for samples of series. Its from field names a database table or another alias:
{ "view": { "tables": { "traces": { "table": { "from": "otel_traces", "timestamp": "Timestamp" } } } }}from defaults to the alias key. For example, { "otel_traces": { "table": {} } } reads the physical otel_traces table. Prefer a distinct semantic alias, such as traces. A physical table name then always refers to the raw table in both structured from fields and SQL.
Integrations install base views that name and shape telemetry. Import a base view instead of starting from a raw table:
{ "view": { "imports": { "@opentelemetry/views/combined": {} }, "tables": {} } }An import provides the source view’s scalars and scoping, including names such as DurationP95 and IsError. A view can import several sources. A query that reads one table sees that table’s scalars. If a query joins tables with the same scalar name, the definitions must match; otherwise, use distinct names.
timestamp names the column a @expr/timeseries_query buckets 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 from 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": { "from": "logs", "where": "ScopeName = 'com.anthropic.claude_code.events'" } }, "api_requests": { "table": { "from": "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.
from 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 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.
Derived tables are also cheap: because their rows are always a subset of the 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 rather than a dozen.
Scalars: name your data once
The with map is where a view names its data — a set of named expressions every query can use. There are two kinds, depending on 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, so a view reads like a small vocabulary rather than a wall of SQL:
{ "view": { "tables": { "traces": { "table": { "from": "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 — wrap it in value instead of writing a bare string:
{ "with": { "DurationP95": { "value": { "expression": "quantileOrNull(0.95)(DurationMs)", "title": "p95 latency", "format": "duration" } } }}value is one kind of with entry. The others — counter, level, rate — declare a
metric reading rather than SQL.
Most formats have a bare shorthand — "percent", "bytes", "duration", "rate", "number", "date", "relative", "day" (an ISO day-of-week number as a localized weekday name) — and an object form for tuning. Currency is object-only: { "currency": "USD" }. 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, in UTC — the timezone queries align their buckets to, so a formatted value reads the same as the chart axis beside it. 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 a block, format also takes an expression — on @block/text and @block/stat, on any of a plot’s scales (axis, colour, facet) and a chart’s value / color, and per column on @block/table and @block/kv — 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 plot axis’s label takes an expression for the same reason, so a chart whose measure is chosen at runtime names it — "scales": { "x": { "label": { "@expr/context_state": "measure" } } }. The label is also what a bar’s hover readout falls back to when the bar is coloured by its own band, so pairing it with an expressible format makes the whole readout follow the chosen measure.
A scale’s domain is expressible too, which matters because the domain is what orders a categorical scale — band order, stack layering, and legend order all follow it. A chart whose breakdown is chosen at runtime has a different vocabulary per choice, so the domain has to follow the choice:
{ "scales": { "color": { "domain": { "@expr/case": { "breakdown": { "By effort": ["low", "medium", "high", "xhigh", "max"] } } } } }}A branch that names no domain is simply omitted — the scale falls back to the natural order Plot derives from the data. Note that an explicit domain reserves a slot for every value it lists, so a level with no rows in the window renders as an empty band rather than closing up; for an ordinal ladder that is usually what you want, since the chart then reads the same across windows.
A block that displays a column with a declared title or format uses them without being told: @block/table heads the column and formats its cells, @block/kv labels and formats the row, a plot formats the axis ticks, the hover readout, and the colour legend of the channel bound to it, and @block/waffle, @block/treemap, @block/arc and @block/waterfall format the measure they encode as colour or size. Anything the block authors wins — a column’s own title and format, a scale’s label and format. On a chart the default reaches the scales that carry magnitudes: a category axis keeps its labels, a categorical colour encoding keeps its series names, and a time axis keeps the ticks Plot picks per granularity. Where several marks share one scale, a mark that declares nothing abstains and the marks that do declare must agree, since one axis cannot read as both a duration and a count.
The match is on the name the query returned the column under, so "select": ["DurationP95"] carries the declaration and "select": [{ "Latency": "DurationP95" }] does not — an alias is a new column, and the block sets its presentation itself.
A view scalar’s format stays a literal. It is the declaration that makes a value read the same in every block that asks for it by name, so it shouldn’t depend on what any one page is doing; a block that needs something else overrides it on its own column.
Scope: carve out what the frame is about
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
One frame can serve many entities. For example, services/{ServiceName} can
render the checkout or cart service. List each route param in
scope.params by its {Name} path segment. Noemata adds a comparison to every
view table: ServiceName = value. To compare the param to a differently named
column, map the segment name to the column, as in { "Database": "database" }.
{ "scope": { "params": ["ServiceName"] }, "view": { "imports": { "@opentelemetry/views/combined": {} }, "tables": {} }}Ancestor route params also scope the view. A frame at
services/{ServiceName}/operations/{Operation} is filtered by both
ServiceName and Operation.
Use scope.where to filter a route and its descendants. For example, a
services/ frame can limit its service pages to one namespace:
{ "scope": { "where": "ServiceNamespace = 'shop'" }, "view": { "imports": { "@opentelemetry/views/combined": {} }, "tables": {} }}scope.where applies to this route and its descendants, including child frames
with their own views. A {Name:String} placeholder binds the route param
Name. When scope.where restates a param comparison to improve pruning, make
the filter true for every row that comparison selects. Otherwise the filter
removes valid rows. view.where filters only this frame’s view. See
Scope.
The URL supplies each route param value. A parent that embeds the frame can
also provide the value. To import a parameterized frame, pass its values in the
import’s params. Each imported table receives the imported frame’s scope:
param comparisons and scope.where.
{ "view": { "imports": { "@opentelemetry/services/{ServiceName}": { "params": { "ServiceName": "checkout" } } }, "tables": {} }}A table without the columns used by the scope should set
disable_auto_scope: true. The opt-out removes its param comparisons and all
scope.where filters. Add the needed filter to that table’s own where.
A table can have a param column that is always blank. The generated comparison
then matches no rows, and the panel appears empty. Set disable_auto_scope for
that table when its param column is always blank.
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 queried window’s bounds as epoch milliseconds so you can bound it yourself. In the app, noemata run and noemata validate --online, the queried window is the selected range expanded to the bucket grid (start rounded down, end rounded up; the step is 10 seconds for a 15-minute range and 15 minutes for a 24-hour range). The app queries the exact range after the user runs the Disable time range rounding command, and the CLI does so with --no-round-timerange. _time_end and _time_range end at the selected end, so a rounded window adds no future time to them. Workflow runs query their occurrence window unrounded:
{ "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. The view names each value and says how it reads; the blocks arrange it, overriding the presentation only where a page needs something else.
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.