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 event 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) to count log events, with analogous columns for other event 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 type

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

Event type 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 or 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 count across all event types. sum(logs_count) simply scopes it to log events.

Don't filter by event type 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 matching sum(<X>_count) for the event 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, combining events and extracted metrics.

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.