Compared to Microsoft KQL
Differences between Berserk's KQL and Microsoft's Kusto Query Language
Berserk implements the Kusto Query Language (KQL) as used in Azure Data Explorer, Azure Monitor, and Microsoft Sentinel. For the most part, queries that work in Azure Data Explorer will also work in Berserk. This page lists the places where Berserk diverges — either to better fit its internals and performance model, or to add features specific to observability workloads.
Coming from Microsoft Kusto
If you already write KQL for Azure Data Explorer, most of your queries work unchanged. A handful of habits are worth adjusting:
-
Don't declare schema, and don't cast just to read a field. Every field — including nested ones — resolves automatically from the raw record, so query it directly; there's no column list to maintain. Use bracket notation for keys that contain dots:
resource['service.name'], notresource.service.name. -
Filter on bare fields and let Berserk coerce. Write
where status == 500orwhere level == "error"straight on a dynamic field — Berserk compares by native type and keeps the indexes engaged. Passing a field to a typed function coerces it automatically via theasXXXfamily — but only when the stored value is already the type the function asks for. The injected extractor matches the parameter:bin(timestamp, 5m)getsasdatetimeand works becausetimestampholds a datetime;avg(value)getsasnumeric, which yields the value forint,longandrealand null for everything else.So it covers exactly the case where the field is stored as what you are asking for, and no manual
tolong()/todouble()is needed there. When it is not —avg(duration)on atimespan,avg(x)on adatetime— the extractor nulls every value and the aggregate returnsNaN. Declare the type withannotaterather than casting: a cast discards the type and disables pruning. -
Cast only to cross types, and only in a projection. Reach for
to*()when a value is stored as the wrong type (a number kept as a string), and put it inextend/project, never in awhere— a cast inside a filter forces a per-row scan and disables pruning. -
Search text with
search,* hasor$raw.* has, never$raw has. ADX matcheshasagainst adynamic's JSON text, so there$raw has "timeout"reads like a full-text search. Berserk never stringifies a property bag implicitly, so here it isfalseon every row. See String Inputs Are No Exception. -
=~is idiomatic here. Case-insensitive=~is index-friendly in Berserk (unlike ADX, where it's discouraged for performance).level =~ "error"andtolower(level) == "error"run the same way, so write whichever reads better.hasandcontainsperform the same — pick by meaning, not speed. -
Every query is time-bounded. There is always an effective range — from the time picker,
--since/--until,--ingest-since/--ingest-until, or an explicitwhere timestamp …/where ingest_time …— so you never scan all of history by accident. The upper bound is "now" by default, which filters out no events at that end; neithertimestampnoringest_timecan be semantically after now. Failure to bound the scan results in an error.
The sections below detail each of these differences.
Schema and Field Resolution
The biggest difference from Microsoft KQL is how Berserk handles schema.
Microsoft Kusto requires fixed schemas — every column must be defined in advance, and referencing an unknown column is an error.
Berserk stores the full original record in a special $raw column (a dynamic value). Unknown column names are automatically resolved from $raw, so you can query nested fields like resource['service.name'] without declaring them first. This is called permissive mode, and every query runs in it: an unknown column name is a field read from $raw, never an error.
String Coercion and the asXXX Family
Because permissive mode resolves fields from $raw as dynamic, Berserk needs a rule for how a dynamic value becomes a typed one. It uses two regimes that behave differently on purpose.
Comparisons and scan predicates are compared by native type — never coerced. A bare where field == "x" works directly on a dynamic field and keeps the indexes engaged (bloom / shard / range). A value that can't match is simply not equal — a numeric field == "5" is false, not coerced. Don't wrap a scan predicate in tostring() / tolong(); that forces per-row evaluation and disables pruning.
Typed function arguments are auto-coerced via the asXXX family. When a dynamic field is passed to a function or operator that expects a concrete type, Berserk injects the matching extractor — asstring, aslong, asint, asdouble, asbool, asdatetime, astimespan, or asnumeric — so observability data feeds typed functions with no explicit cast:
summarize avg(value) by bin(timestamp, 5m) // value auto-coerces to numeric (asnumeric)
extend host = toupper(resource['host.name']) // asstring extracts the string, then upper-casesasT extracts the value when it is already that type (or a dynamic carrying it), and otherwise yields null. It never converts across types — that is the key difference from to*():
| Input | asT() — extract-or-null | to*() — convert |
|---|---|---|
a dynamic carrying a T | the value | the value |
| a value of a different type | null | parsed/converted if possible |
string "42" into a long | aslong → null | tolong → 42 |
Use an explicit to*() only to cross types — a number or datetime stored as a string — and only in project/extend, never in a filter:
extend t = todatetime(attributes.event_time) // event_time is a STRING → parse it (asdatetime would be null)
extend n = tolong(attributes.count_str) // numeric stored as a string → parse itCompared to Microsoft Kusto: Microsoft KQL has only the converting to*() functions and requires an explicit cast to feed a dynamic into a typed context. Berserk adds the non-reifying asXXX family and applies it automatically for typed arguments, so data that arrives entirely as dynamic works without manual casts — while scan predicates stay bare and index-friendly. If a typed function returns unexpected nulls, the stored value isn't the type you assumed: check gettype(field) and add an explicit to*() in a projection.
String Inputs Are No Exception
String operators and functions — contains, has, startswith, endswith, hasprefix, hassuffix, their _cs variants, matches regex, substring, strlen, and the rest — are typed arguments like any other. A parameter documented as string_or_dynamic gets the same asstring treatment avg() gives a numeric one: you cannot apply a string function to a non-string value, so asstring extracts the string if the stored value really is one, and otherwise yields null.
where body contains "cron" // body stores a string -> matched
where body contains "cron" // body stores a bag -> asstring -> null -> false
where body.MESSAGE contains "cron" // the leaf is a string -> matched
where body.* contains "cron" // any string inside body, at any depth -> matchedcol.* searches the strings inside a value. body.* contains "cron" matches when body is a string that contains cron, or an object or array with such a string anywhere inside it, through nested objects and arrays. Keys and numbers never match, and an empty object or array is false. It is what * contains does across a row, scoped to one value, and it uses the index the same way. .* takes contains, has, hasprefix and hassuffix, their _cs and ! forms, and a string literal; anything else is an error, because startswith, endswith and == compare one whole value.
asstring and tostring are opposites, and only one of them is ever implicit. asstring extracts bytes that are already in storage. tostring synthesizes a representation that exists nowhere — for a bag, a whole JSON text including keys, braces, quotes, and stringified numbers. Berserk injects the first and never the second:
asstring(bag) — implicit | bag.* | tostring(bag) — explicit only | |
|---|---|---|---|
| Searches | null | "6", "cron" | {"PRIORITY":"6","_COMM":"cron"} |
| Matches a value | yes, when the field is a string | yes, at any depth | yes |
| Matches a key | no | no | yes |
Matches punctuation ({, ":") | no | no | yes |
| Uses the bloom index | yes | yes | no — reads every row |
The practical consequence: a bag's keys are not searchable, only its values. A journald record stored as {"PRIORITY":"6","_COMM":"cron"} is found by body.* has "cron" and search "cron" — a value — but not by search "PRIORITY", which exists only as a key. Keys are addressable, not searchable:
where body["PRIORITY"] == "6" // address the key
extend keys = bag_keys(body) // enumerate keys
where body.tags[*] contains "needle" // any element of an addressed array
where tostring(body) contains "PRIORITY" // explicit; matches keys, but scans every rowTo search text anywhere in a row, use the full-text forms — search "term" or * contains "term". Those are operators over the row's fields rather than over one field's value, and they stay index-backed.
$raw is a bag like any other, so $raw has "term" is false on every row. $raw.* has "term" searches the whole record, the same as * has "term".
Compared to Microsoft Kusto: given a dynamic where a string is expected, ADX implicitly does the equivalent of tostring — it serializes to JSON and matches that — so there body contains "PRIORITY" is true and even body contains "{" matches. Berserk implicitly does asstring instead. This is a deliberate choice, not an omission: extracting only real stored bytes is what lets string search on dynamic fields remain an index probe rather than a full scan. The cost is that an ADX query relying on implicit stringification returns false here. Write body.* contains "…" to search the values inside, or tostring(...) when matching the JSON text, keys included, is genuinely what you want.
Time-Bounded Queries
Microsoft Kusto does not require a time filter — queries can scan entire tables.
Berserk is a time-series database, and every query must bound the scan on an intrinsic time field — timestamp (event time) or ingest_time (ingestion time). Because event ≤ ingest, a lower bound on either axis bounds the scan. When you select a range in the Time Picker or pass --since/--until (or --ingest-since/--ingest-until) to the CLI, Berserk injects the matching where timestamp between (<START> .. <END>) clause behind the scenes. If your query already includes an explicit bound (e.g. | where timestamp > ago(1h) or | where ingest_time > ago(8d)), that takes precedence. A table scan with no bound on either axis is an error — there is no implicit default window.
Range Size
Microsoft Kusto streams the range operator, so a range can hold billions of values.
Berserk builds a range's values when the query is compiled, so a range is limited to 1,000,000 values; a larger one is an error that names the count. Otherwise range behaves as in Microsoft Kusto: a negative step counts down, and a null bound, a zero step, or a step that points away from the stop produces no rows. One deliberate difference: near the largest long, Microsoft Kusto's values overflow and wrap around, while Berserk stops at the bound.
Implicit Result Limit
Microsoft Kusto returns up to 500,000 records by default (configurable via set truncationmaxrecords).
Berserk applies an implicit | tail 100 to queries that have no operator limiting result size, returning the newest rows by timestamp. This keeps queries responsive by default. To retrieve more rows, add an explicit limit — for example | take 10000, | tail 1000, or any aggregation like | summarize ... that naturally bounds the output. On a source with no timestamp column, such as datatable or range, the implicit limit is an unordered | take 100 instead.
Null Strings
Berserk matches Microsoft Kusto here: a string is never null, so isnull("") returns false — use isempty() to test for an empty-or-missing string. In string equality, a null or absent value counts as the empty string (attr == "" matches absent rows, attr != "" does not), again matching Microsoft Kusto. Non-string comparisons are unaffected — null == 0 is false.
string has no distinct null representation today (an absent string reads as ""); see the string type documentation for details.
Null Comparisons
The full treatment — including three-valued logic in filters and how dynamic values compare against typed values — lives on Nulls, Dynamics, and Coercion. The summary: Berserk matches Microsoft Kusto's null-comparison semantics for non-string types, which are deliberately uneven:
- Null against a concrete value is two-valued:
int(null) == 4isfalse, andint(null) != 4istrue— sowhere i != 5keeps rows whereiis null. - Null against null is null:
int(null) == int(null)is the null bool, nottrue. - Ordering against null is null:
int(null) < 4is null.
A null predicate result filters the row out, and not() / and / or follow three-valued (Kleene) logic: not(null) is null, null and false is false, null or true is true. The practical consequence: where not(x > 5) drops rows where x is null — the negation of an unknown is still unknown. Test for null explicitly with isnull() / isnotnull(); x == int(null) is not a null check (it yields null, never true).
Comparing Values of Different Types
Microsoft Kusto treats the two kinds of comparison differently: asking which of two incomparable values is greater is an error, while asking whether they are equal returns false.
Berserk rejects both, at query-compile time:
| expression | Microsoft Kusto | Berserk |
|---|---|---|
5 < datetime(2024-01-01) | error | error |
5 == datetime(2024-01-01) | false | error |
5 != datetime(2024-01-01) | true | error |
error: TYPE ERROR
|
1 | print v = (5 == datetime(2024-01-01))
| ^^^^^^^^^^^^^^^^^^^^^^^^^ Long == Datetime
|
note: cannot apply '==' to Long and Datetime
help: try: tolong(5) or tolong(datetime(2024-01-01))This is an intentional divergence, not a bug. Comparing a number to a datetime is almost always a mistake, and Microsoft Kusto's false makes it a silent one — the query runs and returns nothing, with no indication why. Berserk names both types and points at the fix, at the moment you write it.
The rule applies to statically typed operands. A dynamic value has no type until the row is read, so it cannot be checked this way and follows the runtime rule instead — dynamic(5) == datetime(2024-01-01) is false, matching Microsoft Kusto. If you want that behaviour for a typed value, convert explicitly.
Dynamic values compare within their own type
A dynamic unwraps to whatever it actually stores, and the comparison then proceeds inside that type — exactly, with no conversion:
dynamic(10s) > 5s // true
dynamic(datetime(2024-01-01)) > datetime(2023-01-01) // trueAgainst a different type it is unordered, and yields the null bool — so a filter drops the row:
dynamic(10s) > 5 // null, not trueMicrosoft Kusto answers true there, by converting the timespan to its tick count. Berserk does not convert, for consistency with equality, which does not convert either — dynamic(10s) == 10000000000 is false even though that is the exact tick count the timespan holds. Coercing one operator but not the other would put them in contradiction: 10s would be neither greater than, less than, nor equal to its own tick count.
When you do want the numeric reading, ask for it and the comparison works:
tolong(dynamic(10s)) > 5 // trueNote that tolong() reads the whole value rather than consulting the chunk index, so a filter written this way scans more than one written against the stored type. That cost is the reason the conversion is explicit rather than implicit — a query never loses index pruning without saying so.
Temporal Arithmetic on Dynamic Values
A dynamic in arithmetic is resolved by what it actually stores, so a duration or an instant read out of a bag behaves like one:
duration / 1ms // 7200000.0 — duration in milliseconds
duration + 30m // a timespan
attrs.started + 1h // a datetime
attrs.started - datetime(2024-01-01) // a timespanThe first of those is the point: Microsoft Kusto rejects duration / 1ms when the operand is a dynamic, and requires an explicit conversion first. Berserk accepts it, because the value is a duration and the idiom is the standard way to express what the user means.
The full comparison:
| expression | Microsoft Kusto | Berserk |
|---|---|---|
dyn / 1ms | rejected | 7200000.0 |
dyn + 30m (holding a duration) | a timespan | same |
dyn + 1h (holding an instant) | 738885.01:00:00 | a datetime |
dyn - datetime(…) | a timespan | same |
1h * dyn (holding a number) | rejected | a timespan |
dyn + 1h (holding a number) | 01:00:00.0000002 | null |
The third row is where Microsoft Kusto is outright wrong: 738885.01:00:00 is the instant's internal tick count read back as a duration — about 2024 years — not an answer anyone wants. Microsoft Kusto keeps a packed duration a timespan (gettype(pack('t', 1h)['t']) is "timespan"), but a dynamic(2h) literal holds a plain tick count there, so gettype() reports long; Berserk keeps both a timespan, which is what makes the first row work.
Note that with a typed timespan column Microsoft Kusto does give d / 1ms → 7200000.0. Berserk extends that to the dynamic case, which is where durations actually live in OTel data — duration is read out of the row's property bag, not declared in a schema.
The last row is the one place Berserk is stricter: a bare number has no unit, so adding it to a duration is null rather than being read as ticks. Convert explicitly — totimespan(n) + 1h — if that is what you meant.
Result type: the declared type of such an expression is dynamic, since the operand's type is not known until the row is read. Where the whole expression is constant the optimizer folds it and reports the concrete type instead, so print dynamic(2h) + 30m shows timespan while the same arithmetic over a column shows dynamic. The values are identical either way.
Numeric Arithmetic on Dynamic Values
Two whole numbers divide as whole numbers in both, truncating toward zero: 7 / 2 is 3 and -7 / 2 is -3. Write 7.0 / 2, or todouble(a) / b, for 3.5.
A dynamic against a number differs:
| expression | Microsoft Kusto | Berserk |
|---|---|---|
dyn / 2 (holding 8) | 4, a long | 4.0, a real |
dyn / 2 (holding 7) | 3, a long | 3.0, a real |
dyn / 2 (holding 8.5) | 4, a long | 4.25, a real |
dyn - 9007199254740992 (holding 9007199254740993) | 1 | 1.0 |
dyn / 2.0, dyn + 1.5 | a real | same |
Microsoft Kusto converts the dynamic to the other operand's type before computing, so a stored 8.5 becomes 8 and the fraction is lost. Berserk does not convert a value it reads: it computes in the type the value is stored as, so whole numbers compute as whole numbers, exactly, and a stored real keeps its fraction. Because the stored type can differ from row to row, the result is typed real. A long beyond 2^53 is exact in the arithmetic, but not once the result is widened to real; use tolong(dyn) first to keep such a value a long.
String Search Performance
Microsoft Kusto recommends has over contains because has uses a term index and is significantly faster.
Berserk uses bloom filters and columnar indexing to accelerate all string search operators. While case-sensitive variants (has_cs, contains_cs, ==) are still fastest, the performance gap between has and contains is much smaller than in Microsoft Kusto.
Term boundaries are not a divergence: in both engines a term is a run of alphanumeric characters, so _, -, and . are delimiters — "job_name" has "job" is true. See Term boundaries.
Case-Insensitive Matching of Non-ASCII Text
Berserk's case-insensitive operators (=~, has, contains, startswith, endswith, …) fold ASCII case exactly like Microsoft Kusto.
For non-ASCII text, Berserk folds each character on its own, by its simple lowercase form, and two texts match when their folded forms are equal. Identical text always matches, and so do ordinary letter pairs such as Ä/ä or Σ/σ. A few pairs that full Unicode case mapping would treat as equal do not match:
| Pair | Why |
|---|---|
ß / ss | ß is one character; folding never turns it into two |
İ / i | İ folds to i followed by a combining dot |
ς / σ (final sigma) | Σ folds to σ; a final ς stays ς |
µ / Μ (micro sign and Greek mu) | They fold to different characters |
So name =~ "ΟΔΟΣ" matches ΟΔΟΣ and οδοσ, while name =~ "οδος" matches only οδος. The rule is the same for every case-insensitive operator, and the same whether or not the index is used, so a query's answer never depends on how the data happens to be stored. This is an intentional divergence: the per-character fold is what keeps case-insensitive search an index probe, and these characters rarely carry meaning in log and trace data.
toupper() and tolower() are not affected: they use full Unicode case mapping, so toupper("Straße") is "STRASSE", and toupper(city) == "STRASSE" matches a stored Straße.
When you need exact matching of non-ASCII text, use the case-sensitive variants (==, has_cs, contains_cs).
Berserk-Specific Functions and Operators
These functions and operators are Berserk extensions that do not exist in Microsoft KQL. This table is generated from the YAML function definitions — add custom: true to a function's YAML to include it here.
| Name | Kind | Description |
|---|---|---|
annotate | operator | Adds type annotations to dynamic columns, enabling forward-flow type inference |
current_table | scalar | Returns the table name for the current row. Used internally by the search operator. |
deriv | aggregate | Computes the derivative (rate of change) for a gauge metric. Unlike rate(), |
extract_log_template | scalar | Normalizes a string into a structural template by replacing variable tokens (numbers, UUIDs, IPs, hex values, quoted strings) with typed placeholders. Useful for grouping log messages by structure. |
fieldstats | operator | Analyzes dynamic column values to discover field paths and their statistics, |
join-with-time | operator | Pairs the rows of two tables whose join keys match and whose timestamps lie within a duration of each other. It is an inner join: a pair needs a key both sides have, and the two rows' `timestamp`s at most `within` apart. |
log_template_hash | scalar | Computes a hash of the structural log template, for grouping similar logs without allocating the template string. Equivalent to hashing the output of extract_log_template, but with zero heap allocations, except that key=value pairs separated only by whitespace hash alike in any order: `a=1 b=2` and `b=7 a=9` share a hash, while pairs on either side of other text do not trade places. |
log_template_id | scalar | Returns a stable 16-character hex string identifying the structural log template of the input line. Equivalent to formatting the output of log_template_hash as zero-padded lowercase hex, so it shares that function's indifference to the order of key=value pairs — small enough to store on rows as an indexed attribute, large enough to make per-template groupings collision-free for log volumes encountered in practice. |
log_template_matches | scalar | Returns true when a value has the same structural log template as a sample line: `log_template_hash(tostring(field)) == log_template_hash(sample)`. Use it to select every row of a pattern found with `log_template_hash` or `extract_log_template`, whatever its variable values (numbers, IDs, IPs, quoted strings). The sample must be a constant string. The comparison is exact up to 64-bit hash collisions. A null or empty field matches an empty sample, as `tostring` turns null into an empty string. The sample's literal words are also required as `has_cs` terms, so the query skips chunks whose bloom filter shows they lack them. |
make_graph | aggregate | Folds parent-linked rows into a `dynamic` `{nodes, edges}` graph: one node per |
make_graph_by | aggregate | Builds a **collapsed** `{nodes, edges}` graph directly from parent-linked |
merge_graphs | aggregate | Unions canonical `{nodes, edges}` graphs (produced by `make_graph` or |
otel-log-stats | operator | Single-pass OTEL log exploration: discovers attributes and computes |
otel_delta | aggregate | Signed change of an OpenTelemetry metric over the group: last − first, with no |
otel_histogram_percentile | aggregate | Aggregate that merges OpenTelemetry histogram data points and extracts one |
otel_histogram_rate | aggregate | Per-second rate of observation count for an OpenTelemetry histogram metric. |
otel_increase | aggregate | Total increase of an OpenTelemetry type=sum counter over the group, in the |
otel_rate | aggregate | Computes the per-second rate from an OpenTelemetry type=sum metric. |
otel_sample_interval | aggregate | How far apart a single series' samples arrive, as a timespan — the emission |
rate | aggregate | Computes the per-second rate of change for a counter metric, handling counter |
trace-find | operator | Finds traces by structural span relationships (ancestor/descendant/sibling) and correlated logs. Evaluates parent-child relationships between spans within each trace and returns matching traces. Optional output clauses (`summarize`, `where`) control what data is extracted from each matching trace. Predicates inside `{ }` blocks use standard KQL where-clause syntax. |
Datetime and Timespan
Microsoft Kusto holds datetime and timespan as 100-nanosecond ticks, and counts a datetime from 0001-01-01, so it reaches from the year 1 to 9999.
Berserk holds both as 64-bit nanoseconds, and counts a datetime from 1970-01-01, so it reaches from 1677-09-21 to 2262-04-11 and a timespan about 292 years either way. A literal outside the range is an error; a value from data outside it is null, with a warning. tolong() of either type gives Microsoft's ticks, as there, but totimespan() of a datetime and todatetime() of a timespan are null: Microsoft counts both from 0001-01-01, which Berserk cannot hold. See datetime and timespan for each case and what to write instead.
Unsupported Features
The following Microsoft KQL features are not yet available in Berserk:
- Control commands — only
.show tables,.show databases, and.show table <name> schema as jsonare supported - Materialized views
- External tables
- Stored functions (user-defined functions via
.create function) - Cross-cluster and cross-database queries
- Workbooks integration
Not Yet Implemented Functions
These are standard Microsoft KQL functions that Berserk recognizes but has not yet implemented. Using them produces a helpful error message. This list is generated from the engine source code.
| Category | Functions |
|---|---|
| Aggregation | buildschema |
| Conversion | todecimal |
| Flow control | assert |
| Geospatial | geo_angle, geo_azimuth, geo_closest_point_on_line, geo_closest_point_on_polygon, geo_distance_2points, geo_distance_point_to_line, geo_distance_point_to_polygon, geo_from_wkt, geo_geohash_neighbors, geo_geohash_to_central_point, geo_geohash_to_polygon, geo_h3cell_children, geo_h3cell_level, geo_h3cell_neighbors, geo_h3cell_parent, geo_h3cell_rings, geo_h3cell_to_central_point, geo_h3cell_to_polygon, geo_intersection_2lines, geo_intersection_2polygons, geo_intersection_line_with_polygon, geo_intersects_2lines, geo_intersects_2polygons, geo_intersects_line_with_polygon, geo_line_buffer, geo_line_centroid, geo_line_densify, geo_line_interpolate_point, geo_line_length, geo_line_locate_point, geo_line_simplify, geo_line_to_s2cells, geo_point_buffer, geo_point_in_circle, geo_point_in_polygon, geo_point_to_geohash, geo_point_to_h3cell, geo_point_to_s2cell, geo_polygon_area, geo_polygon_buffer, geo_polygon_centroid, geo_polygon_densify, geo_polygon_perimeter, geo_polygon_simplify, geo_polygon_to_h3cells, geo_polygon_to_s2cells, geo_s2cell_neighbors, geo_s2cell_to_central_point, geo_s2cell_to_polygon, geo_simplify_polygons_array, geo_union_lines_array, geo_union_polygons_array |
| IPv4/IPv6 | geo_info_from_ip_address |
| Metadata | column_names_of, current_cluster_endpoint, current_database, current_principal, current_principal_details, current_principal_is_member_of, cursor_after, cursor_before_or_at, cursor_current, extent_id, extent_tags |
| String | parse_csv, parse_path, parse_user_agent, punycode_domain_from_string, punycode_domain_to_string |
| Units conversion | convert_angle, convert_energy, convert_force, convert_length, convert_mass, convert_speed, convert_temperature, convert_volume |