The $raw Column
How Berserk resolves column names using $raw in permissive and strict mode
Berserk KQL resolves names differently than Microsoft's Kusto. In Microsoft Kusto, tables always have a fixed schema and unrecognized column names produce errors. Berserk introduces permissive mode and the special $raw column to support semi-structured data common in observability workloads.
What is $raw?
Every table (source table) in Berserk has exactly one column: $raw, a dynamic property bag containing the full original record. When data is ingested (e.g. from OpenTelemetry), the entire JSON object is stored in $raw. See OTEL Field Mapping for exactly how OpenTelemetry data is encoded as JSON.
Permissive vs Strict Mode
Berserk has two query modes that control how unknown field names are resolved:
Permissive Mode (default)
In permissive mode, bare identifiers that don't match a declared column are automatically resolved as property paths into $raw. This is called auto-projection.
default | where level == "INFO"If level is not a declared column but the table has $raw, this is equivalent to:
default | where $raw.level == "INFO"The auto-projected field has type dynamic, since its actual type is unknown at query time. The editor shows an informational hint on auto-projected fields to indicate they are being resolved from $raw.
Strict Mode
In strict mode, every column reference must match a declared column in the schema. Unknown fields produce an error:
-- Error: unknown column 'level'
Fruit | where level == "INFO"This is closer to Microsoft Kusto's behavior. Use strict mode when working with well-defined schemas where typos should be caught early.
Explicit $raw Access
You can always access $raw directly using dotted path syntax:
default | where $raw.level == "INFO"This works in both permissive and strict mode. Nested paths are supported:
default | where $raw.resource["service.name"] == "api-gateway"Array indexing is also supported:
default | where $raw.items[0].name == "first"Type Annotations
Since auto-projected fields are dynamic, arithmetic and comparison operations may need type hints. Use the annotate operator to declare expected types:
default
| annotate response_time:real, status_code:int
| where status_code >= 400
| summarize avg(response_time) by bin(timestamp, 5m)Without annotation, response_time and status_code stay dynamic. Annotate
them rather than reaching for toreal() / tolong(): a cast wraps the column,
which stops the planner reasoning about it and disables chunk pruning, so the
query reads data it could have skipped. A declaration costs nothing.
Durations and timestamps
Annotate is not optional for temporal fields — it is the only thing that works.
A numeric aggregate over a dynamic field extracts a number from it, and a
timespan is not a number, so it extracts to null and the aggregate has nothing
to average:
prod | summarize p95 = percentile(duration, 95)
// p95:real = NaNCasting does not rescue it, and costs you twice. toreal() and tolong()
succeed, but hand back the raw 100-nanosecond tick count and throw the type
away — you get 49528.8 instead of a duration — and wrapping the column stops
the planner pruning chunks by it, so the query scans more than it needs.
Annotate the field instead. Every aggregate over it returns a timespan, and the column stays prunable:
prod
| annotate duration:timespan
| summarize p95 = percentile(duration, 95), avg_d = avg(duration)
// p95:timespan = 00:00:00.0049528, avg_d:timespan = 00:00:00.0702141The same applies to datetime fields. annotate declares a type without
converting anything, which is why it preserves the value where a cast cannot —
see nulls and coercion for the extract
versus convert distinction.
Comparison with Microsoft Kusto
| Behavior | Microsoft Kusto | Berserk (Permissive) | Berserk (Strict) |
|---|---|---|---|
| Unknown column | Error | Auto-project from $raw | Error |
$raw column | No equivalent | Built-in dynamic column | Built-in dynamic column |
| Type of auto-projected fields | N/A | dynamic | N/A |