Schema migration Feb ’26

We’re unifying how we store user-defined metrics extracted from logs and spans alongside Prometheus metrics.

Going forward, there’s just one way to query your metrics.

Schema overview

Our new row-based schema stores all data in a unified format:

  • Metrics stored as named values with labels in a tags map
  • Original event counts available per row type as sum(logs_count), sum(spans_count), sum(exceptions_count), sum(replays_count), sum(web_events_count), sum(metrics_count), and sum(extracted_metrics_count)
  • Aggregated metric functions for efficient querying
Old schema New schema
response_status label('response_status')
buckets bucket_bounds + bucket_counts
p50, p90, p95, p99 histogramQuantile(0.5), histogramQuantile(0.9), ...
_row_type = 'logs' (or 'log') sum(logs_count) for log-row counts; analogous columns for other row types

Accessing tags and labels

All non-aggregated columns that were previously accessed directly must now use the label() function. Labels are always strings, so toString(label(...)) is redundant.

Accessing labels
-- Before:
-- SELECT response_status, request_method, user_agent
-- FROM {{source}}
-- WHERE response_status >= 500

-- After:
SELECT label('response_status'), label('request_method'), label('user_agent')
FROM {{source}}
WHERE toUInt16OrNull(label('response_status')) >= 500

Standard columns

The following columns are part of the schema itself and don't need the label() wrapper.

 
dt, name, series_id, tags, events_count, 
logs_count, spans_count, exceptions_count, replays_count, 
web_events_count, metrics_count, extracted_metrics_count, 
value_avg, value_min, value_max, rate_avg, 
bucket_bounds, bucket_counts, bucket_sum, bucket_count, 
bucket_min, bucket_max, bucket_quantiles

Counting events by category

Each ingested event creates multiple rows — one for the original event and several for any metrics derived from it. Use the per-row-type count columns to scope a count to a single category without writing an explicit predicate:

Category Count column
Original log event logs_count
Original span event spans_count
Exception exceptions_count
Replay replays_count
Web event web_events_count
Metric (sent directly) metrics_count
Metric (extracted from logs/spans) extracted_metrics_count

<X>_count is events_count on rows of that kind and 0 everywhere else, so sum(<X>_count) filters and counts in a single expression.

Count log events

Counting log events
SELECT {{time}} AS time, sum(logs_count) AS value
FROM {{source}}
WHERE dt BETWEEN {{start_time}} AND {{end_time}}
GROUP BY time

(countMerge(events_count) still works for a generic across-row-type count — sum(logs_count) simply scopes it.)

Don't add a row-type predicate when querying named metrics

Querying named metrics
-- WRONG: name = 'duration' is its own row; counting logs_count here would return 0.
-- SELECT histogramQuantile(0.95) AS p95
-- FROM {{source}}
-- WHERE dt BETWEEN {{start_time}} AND {{end_time}}
--   AND logs_count > 0
--   AND name = 'duration'

-- CORRECT: filter by metric name only
SELECT histogramQuantile(0.95) AS p95
FROM {{source}}
WHERE dt BETWEEN {{start_time}} AND {{end_time}}
  AND name = 'duration'
GROUP BY time

Type handling for labels

Since labels are always strings, you must handle type conversions explicitly when comparing to numbers.

Numeric comparison

Numeric comparison on labels
-- Range check
WHERE toUInt16OrNull(label('response_status')) BETWEEN 200 AND 299

-- Inequality
WHERE toFloat64OrNull(label('response_status')) >= 500
AND toFloat64OrNull(label('response_status')) <= 599

Histograms and buckets

The new schema uses bucket_bounds and bucket_counts, with the counts storing deltas. sumForEachMerge() is used to sum these deltas.

Accessing bucket data
-- Old pattern:
-- arrayJoin(anyLastMerge(buckets)) AS bucket

-- New pattern:
SELECT
  time,
  arrayJoin(arrayZip(bounds, counts)) AS bucket,
  bucket.1 AS bound,
  bucket.2 AS count
FROM
(
  SELECT
    {{time}} AS time,
    anyLastMerge(bucket_bounds) AS bounds,
    sumForEachMerge(bucket_counts) AS counts
  FROM {{source}}
  WHERE ...
  GROUP BY time
)

Quantiles and percentiles Use the histogramQuantile(p) helper function for single metrics, or quantilePrometheusHistogramArrayMergeIf(p)(bucket_quantiles, condition) for multiple metrics.

Calculating quantiles (single metric)
SELECT
  {{time}} AS time,
  histogramQuantile(0.95) AS p95
FROM {{source}}
WHERE dt BETWEEN {{start_time}} AND {{end_time}}
  AND name = 'http_request_duration_seconds'
GROUP BY time

Event counting

A single event can be represented as multiple metric rows, so countMerge(events_count) on a source that emits extracted metrics will overcount. Use sum(logs_count) (or the appropriate sum(<X>_count) for the row type you want) to get just the original events.

Counting original log events
SELECT
  {{time}} AS time,
  sum(logs_count) AS value
FROM {{source}}
WHERE dt BETWEEN {{start_time}} AND {{end_time}}
GROUP BY time

countMerge(events_count) remains valid if you genuinely want the total row count (events + extracted metrics combined).

This change will go in effect for existing organizations during February ’26.

Let us know at hello@betterstack.com if you have any feedback. We hope this change will make the product more straightforward and intuitive going forward.