Skip to content

Transforms

Transforms reshape a table after it comes back from a query — adding columns, filtering rows, parsing, filling gaps. As a rule, do as much as you can in SQL (it’s pushed down to the database); reach for a transform when the reshape is awkward in SQL, needs to react to client-side state, or operates on data the database returned opaquely (a JSON column, an array).

They chain. Standalone, each takes a [source, options] tuple; the natural way to apply several is an @expr/pipeline, which feeds each step’s result into the next. The per-operator options are in the expression reference.

@expr/derive and @expr/map

Add or rewrite columns. Use @expr/derive to add computed columns — a CEL expression per new column, seeing the whole row. Use @expr/map to replace existing columns in place (for example, parse a column through another operator). @expr/derive is the one you’ll reach for most; it’s the client-side equivalent of adding a SELECT … AS you couldn’t push into the query.

{
"@expr/derive": [
{ "@expr/query": "SELECT Duration FROM spans" },
{ "duration_ms": "Duration / 1000000" }
]
}

@expr/filter

Keeps rows matching a CEL predicate. Prefer a SQL WHERE when you can; use this when the condition depends on something only available client-side (a piece of state, a derived column).

{
"@expr/filter": [{ "@expr/query": "SELECT StatusCode FROM spans" }, { "where": "StatusCode > 0" }]
}

@expr/cel

Evaluates a CEL expression per row against the table’s columns plus context. It’s the building block under derive / filter; reach for it directly when you need a single computed column expressed in CEL rather than SQL.

{
"@expr/cel": [
{ "@expr/query": "SELECT StatusCode FROM spans" },
{ "expression": "StatusCode >= 400" }
]
}

An identifier in a CEL expression names a column, falling back to a context key. When the name isn’t spellable as an identifier — it holds spaces, dots, or dashes — write it as id("…"): id("http.status_code") >= 400 reads the column of that name, where a bare http.status_code would parse as a field access on http. The argument must be a string literal (the name is resolved before any row is evaluated); everything else about it is identical to writing the identifier inline. This applies wherever CEL does, derive and filter included.

@expr/json_parse

Parses a string column as JSON — the way to crack open a log body or an attributes blob the database handed back as text, so downstream steps can read its fields.

{
"@expr/json_parse": [{ "@expr/query": "SELECT Body FROM logs" }, {}]
}

@expr/unnest

Expands an array column into one row per element — turn a list of tags or a repeated attribute into rows you can group or chart. On a nested column produced by @expr/nest it unpacks instead: each group’s rows come back as flat columns, restoring the pre-nest shape.

{
"@expr/unnest": [{ "@expr/query": "SELECT Tags FROM events" }, { "column": "Tags" }]
}

@expr/nest

The inverse of @expr/unnest: groups by key column(s) and folds each group’s remaining rows into the sub-table-valued into column, leaving one row per distinct key. Each into cell reads as a table in its own right, everywhere the column is consumed. The main use is per-row mini charts: a @block/table defer entry can load one timeseries query for every row, @expr/nest it by the join key, and on attaches each row’s sub-table — which a block-column plot then reads by column name, with no per-row queries.

A nested column is opaque to value-rewriting transforms — @expr/densify, @expr/derive/@expr/map over it, and CEL expressions referencing it refuse with a clear error rather than corrupting the sub-tables. Reshape the flat series before nesting (densify, then nest), or @expr/unnest the column to get the flat rows back.

{
"@expr/nest": [
{
"@expr/timeseries_query": {
"from": "events",
"select": { "Events": "count()" },
"group_by": "Sid"
}
},
{ "by": "Sid", "into": "series" }
]
}

@expr/densify

Fills the gaps in a time series so a chart doesn’t draw misleading straight lines across missing buckets. It emits the missing buckets along a time axis, grouped by series, with a fill value (usually 0). Reach for it after a @expr/timeseries_query when a series can have empty buckets.

{
"@expr/densify": [
{ "@expr/query": "SELECT bucket, service, count FROM metrics" },
{ "along": "bucket", "by": ["service"], "fill": 0 }
]
}

@expr/handlebars

Renders a Handlebars template against context — the simple way to build a string out of state and values (a title, a label, a URL). Not a table transform, but it lives here as the everyday string-templating tool.

{ "@expr/handlebars": { "template": "{{method}} {{path}}" } }