SereneDB

Observability platform: logs, traces and metrics in one SQL query

An observability platform collects the logs, traces and metrics a system emits and answers questions about them. SereneDB is its storage and query layer: it receives OpenTelemetry exports directly, keeps each signal in an indexed table, and answers in SQL, from log search to latency percentiles to joins across signals.

curl https://install.serenedb.com | sh
metrics · http.server.request.durationslowquery
An animation in three scenes over one checkout incident on 2026-11-06 between 14:00 and 14:06 UTC. First, 72 metric points arrive as an OTLP/HTTP export; otel_increase and otel_histogram_quantile turn the http.server.request.duration histogram into p99 per minute: 14:00 420 ms, 14:01 420 ms, 14:02 4.9 s, 14:03 4.9 s, 14:04 850 ms, 14:05 420 ms, while the payments connection pool sits at its limit during the slow minutes. Second, 36 spans arrive; the failed checkout's trace shows frontend, checkout and payments spans of about three seconds, and quantile_cont per service puts payments first with a p99 of 10696 ms and 3 errors. Third, 14 log records arrive, and joining the slow payments spans to the log lines on trace_id and span_id returns 4 lines: payments-db - Connection is not available, request timed out after 2800ms.; payments-db - connection acquired after 1842ms (pool 20/20 in use).
What is an observability platform

What does an observability platform do? Five questions across metrics, traces and logs

An observability platform collects the metrics, traces and logs a system emits and answers questions about them: what is slow, where, and why. Below is one checkout incident recorded as all three OpenTelemetry signals. Pick a question to run it; each is one SQL statement.

signal lab · checkout incident
2026-11-06 · 14:00–14:06 UTC12 traced requests · 14 log lines · 72 metric points
14:0014:0114:0214:0314:0414:0514:06p99 · metricspool · metricsrequests · traceslogs420 ms420 ms4.9 s4.9 s850 ms420 msfrontendcheckoutpayments
Latency
sql
SELECT minute,
       round(otel_histogram_quantile(0.5, buckets, explicit_bounds), 2) AS p50_s,
       round(otel_histogram_quantile(0.99, buckets, explicit_bounds), 2) AS p99_s
FROM (
  SELECT date_trunc('minute', "timestamp") AS minute,
         resource_attributes, attributes, explicit_bounds,
         otel_increase(bucket_counts, "timestamp") AS buckets
  FROM otel_metrics_histogram
  WHERE metric_name = 'http.server.request.duration'
  GROUP BY ALL
)
GROUP BY minute ORDER BY minute;
result · 6 rows
minutep50_sp99_s
2026-11-06 14:00:000.150.42
2026-11-06 14:01:000.150.42
2026-11-06 14:02:0014.93
2026-11-06 14:03:0014.93
2026-11-06 14:04:000.160.85
2026-11-06 14:05:000.150.42
The metric says something is wrong: p99 of the checkout route jumps from 0.42 s to 4.93 s at 14:02 and 14:03, and is back under a second at 14:04. otel_increase turns the cumulative bucket counts into what happened in each minute.
Saturation
sql
SELECT date_trunc('minute', "timestamp") AS minute,
       max(value) FILTER (WHERE metric_name = 'db.client.connection.count') AS in_use,
       max(value) FILTER (WHERE metric_name = 'db.client.connection.limit') AS pool_limit
FROM otel_metrics_sum
WHERE service_name = 'payments'
GROUP BY 1 ORDER BY 1;
result · 6 rows
minutein_usepool_limit
2026-11-06 14:00:00820
2026-11-06 14:01:001220
2026-11-06 14:02:002020
2026-11-06 14:03:002020
2026-11-06 14:04:001440
2026-11-06 14:05:00840
payments-db, the payments service's connection pool, is at its limit for the same two minutes: 20 of 20 connections in use. At 14:04 the limit is 40 and use falls to 14, then 8.
Slow service
sql
SELECT s.service_name, count(*) AS spans,
       round(quantile_cont(s.duration_ns, 0.99) / 1e6) AS p99_ms,
       round(sum(s.duration_ns - coalesce(c.child_ns, 0)) / 1e6) AS self_ms,
       count(*) FILTER (WHERE s.status_code = 'Error') AS errors
FROM otel_traces s
LEFT JOIN (SELECT parent_span_id, sum(duration_ns) AS child_ns
           FROM otel_traces GROUP BY parent_span_id) c
       ON c.parent_span_id = s.span_id
WHERE s.span_kind @@ 'Server'
GROUP BY s.service_name ORDER BY self_ms DESC;
result · 3 rows
service_namespansp99_msself_mserrors
payments122809106963
checkout12300211693
frontend1230122563
Every service shows a p99 near three seconds and the same three errors, because a failure travels up the call. Self time, a span's duration minus its children's from the same table, puts payments first: 10,696 ms of its own.
Errors
sql
SELECT date_trunc('minute', "timestamp") AS minute, count(*) AS requests,
       count(*) FILTER (WHERE status_code = 'Error') AS errors,
       round(max(duration_ns) / 1e6) AS slowest_ms
FROM otel_traces
WHERE service_name = 'frontend'
GROUP BY 1 ORDER BY 1;
result · 6 rows
minuterequestserrorsslowest_ms
2026-11-06 14:00:0020142
2026-11-06 14:01:0020156
2026-11-06 14:02:00223012
2026-11-06 14:03:00213011
2026-11-06 14:04:0020149
2026-11-06 14:05:0020133
The frontend serves two requests a second and traces one in sixty, so two a minute are here. Three of the four traced between 14:02 and 14:03 fail after three seconds; the fourth succeeds in 1984 ms.
Root cause
sql
SELECT t."timestamp"::TIME AS t, round(t.duration_ns / 1e6) AS ms,
       l.severity_text, l.body
FROM otel_traces t
JOIN otel_logs l ON l.trace_id = t.trace_id AND l.span_id = t.span_id
WHERE t.service_name = 'payments' AND t.duration_ns > 1000000000
ORDER BY t."timestamp";
result · 4 rows
tmsseverity_textbody
14:02:05.012806ERRORpayments-db - Connection is not available, request timed out after 2800ms.
14:02:35.012803ERRORpayments-db - Connection is not available, request timed out after 2800ms.
14:03:05.011871WARNpayments-db - connection acquired after 1842ms (pool 20/20 in use)
14:03:35.012809ERRORpayments-db - Connection is not available, request timed out after 2800ms.
The logs say why. Joining the slow payments spans to the log lines of the same span, on trace_id and span_id, returns the connection pool's timeout on every failure and a 1842 ms wait on the slow success.

Checklist

What an observability platform needs from its database, and how SereneDB does it in SQL

The database under an observability platform has to take in logs, traces and metrics as they are emitted, search log text, turn spans and histograms into latency, connect the signals of one request, feed dashboards and expire old data. Lists of observability platforms compare products; these are the jobs, each with the SereneDB feature that does it and its docs.

NeedHow SereneDB does itLooks like
Collect every signalAn OTLP/HTTP listener in the server takes logs, traces and metrics, in protobuf or JSON, one transaction per export. Docs →POST /v1/traces · /v1/metrics · /v1/logs
Search log textLog bodies are full-text indexed with no stemming, so phrases, IDs and addresses match as written. Docs →body @@ websearch_to_tsquery('"not available"')
Latency from spansEvery span is a row with its duration, service, kind and status: percentiles and error rates are aggregates. Docs →quantile_cont(duration_ns, 0.99)
Counters and histogramsRates, increases and histogram quantiles over cumulative OpenTelemetry metrics, per series. Docs →otel_histogram_quantile(0.99, buckets, explicit_bounds)
Correlate signalstrace_id and span_id are columns in otel_logs and otel_traces, so a log line joins to its span. Docs →JOIN otel_logs l ON l.trace_id = t.trace_id AND l.span_id = t.span_id
DashboardsGrafana's built-in PostgreSQL data source runs raw SQL against SereneDB. Docs →time_bucket(INTERVAL '1 minute', "timestamp") AS "time"
RetentionOne scheduled job per table deletes the rows older than its period. Docs →CREATE JOB otel_retention EVERY 1 HOUR AS $$ … $$
Keep history as filesCOPY writes old telemetry to Parquet, locally or to S3, and read_parquet queries it in place. Docs →COPY (SELECT * FROM otel_traces WHERE …) TO '….parquet'

Architecture

Observability platform architecture: OTLP in, seven indexed tables, SQL out

Traces, metrics and logs arrive as OTLP/HTTP exports and land in search tables whose inverted indexes cover the ids, the names, the timestamp and the log text. Every question, from a percentile to a join across signals, is a SELECT, and Grafana reads it over the PostgreSQL protocol. Hover a stage, or a lane inside the index.

01 · ingest
POST /v1/traces
otel listener :4318
01 · ingest
POST /v1/metrics
otel listener :4318
01 · ingest
POST /v1/logs
otel listener :4318
02 · store
otel_traces
one row per span
02 · store
otel_metrics_*
five tables, one per metric shape
02 · store
otel_logs
one row per record
search tables, created at startup
03 · inverted indexes
one per table
trace_id, span_idexact
service_name, metric_nameexact
body otel_body_dicttext
"timestamp"range
04 · traces
quantile_cont
latency · error rate
04 · metrics
otel_rate
otel_histogram_quantile
04 · logs
@@
phrase · exception events
05 · correlate
JOIN
ON trace_id, span_id
06 · dashboards
Grafana
PostgreSQL data source :7890
07 · retention
CREATE JOB
DELETE · COPY TO parquet
> ingest · traces

OTLP/HTTP, straight into the server

Start SereneDB with an otel listener and point an OTel SDK or a collector's otlphttp exporter at it. It serves /v1/traces, /v1/metrics and /v1/logs in protobuf or JSON, writes each export in one transaction, and answers with an empty response, {}.

shell
curl -u postgres:<password> -H 'Content-Type: application/json' \
--data @traces.json http://localhost:4318/v1/traces
# {}
> ingest · metrics

Every metric shape

Gauges, sums, histograms, exponential histograms and summaries arrive in one export and land in five tables, all or none: a retried export cannot leave half its points behind.

shell
curl -u postgres:<password> -H 'Content-Type: application/json' \
--data @metrics.json http://localhost:4318/v1/metrics
# {}
> ingest · logs

Log records with their trace context

A log record keeps the trace_id and span_id the SDK attached, so every line can be joined to the span it was written in.

shell
curl -u postgres:<password> -H 'Content-Type: application/json' \
--data @logs.json http://localhost:4318/v1/logs
# {}
> stage 02 · otel_traces

A span is a row

Kind and status are stored as text, the duration is computed from the span's start and end, and attributes are JSON columns. Events and links stay in the row, with event_names beside them for index-side lookups.

sql
SELECT service_name, span_name, span_kind, status_code,
duration_ns / 1000000 AS ms
FROM otel_traces
WHERE trace_id = '2710f955fcc460b318e2e4eb2f60514a'
ORDER BY "timestamp";
-- frontend | POST /api/checkout | Server | Error | 3012
-- checkout | CheckoutService/PlaceOrder | Server | Error | 3001
-- payments | PaymentService/Charge | Server | Error | 2806
> stage 02 · otel_metrics_*

Five tables, one per metric shape

A gauge carries one value, a histogram carries bucket arrays, a summary carries quantile pairs, so each shape has its own table. Values are stored as double precision, and attributes as JSON.

sql
SELECT 'otel_metrics_histogram' AS t, metric_name, count(*) AS points
FROM otel_metrics_histogram GROUP BY ALL
UNION ALL
SELECT 'otel_metrics_sum', metric_name, count(*)
FROM otel_metrics_sum GROUP BY ALL
ORDER BY 1, 2;
-- otel_metrics_histogram | http.server.request.duration | 24
-- otel_metrics_sum | db.client.connection.count | 24
-- otel_metrics_sum | db.client.connection.limit | 24
> stage 02 · otel_logs

Log records, searchable at the next refresh

Each table is a search table: a row becomes visible to every query at the next refresh, every second by default. The real-time analytics page covers the timing.

sql
VACUUM (REFRESH_TABLE) otel_logs;
SELECT severity_text, count(*) AS records
FROM otel_logs
GROUP BY 1 ORDER BY 2 DESC;
-- ERROR 6 · INFO 4 · WARN 4
> stage 03 · inverted indexes

What dashboards filter on is indexed

Each table is created with one inverted index over what dashboards filter, group and facet on: ids, names, the timestamp and, for logs, the analyzed body. Facets come straight from the term dictionary.

sql · hover a lane
SELECT unnest(ts_dict_agg(service_name)) AS service,
unnest(ts_dict_count(service_name)) AS spans
FROM otel_traces;
-- checkout 12 · frontend 12 · payments 12 · support-assistant 6, in no fixed order
> stage 04 · traces

Latency and errors from spans

Percentiles per service are quantile_cont over duration_ns, and the error rate is a count(*) FILTER on status_code. This is step 3.

sql
SELECT s.service_name, count(*) AS spans,
round(quantile_cont(s.duration_ns, 0.99) / 1e6) AS p99_ms,
round(sum(s.duration_ns - coalesce(c.child_ns, 0)) / 1e6) AS self_ms,
count(*) FILTER (WHERE s.status_code = 'Error') AS errors
FROM otel_traces s
LEFT JOIN (SELECT parent_span_id, sum(duration_ns) AS child_ns
FROM otel_traces GROUP BY parent_span_id) c
ON c.parent_span_id = s.span_id
WHERE s.span_kind @@ 'Server'
GROUP BY s.service_name ORDER BY self_ms DESC;
> stage 04 · metrics

Rates and quantiles over cumulative metrics

SDKs export running totals. otel_rate, otel_increase and otel_histogram_quantile read them per series, the way a Prometheus query would, and the outer query adds the series up.

sql
SELECT minute, round(sum(per_second), 2) AS requests_per_second
FROM (
SELECT date_trunc('minute', "timestamp") AS minute,
resource_attributes, attributes,
otel_rate("count", "timestamp") AS per_second
FROM otel_metrics_histogram
WHERE metric_name = 'http.server.request.duration'
GROUP BY ALL
)
GROUP BY minute ORDER BY minute;
-- 2 in every minute from 14:00 to 14:05
> stage 04 · logs

Search the log text and the exception events

body is analyzed with otel_body_dict: words split on boundaries, no stemming, so an identifier matches as written. Span events are searchable through event_names. The log analysis page goes deeper into log search.

sql
SELECT "timestamp"::TIME AS t, service_name, body
FROM otel_logs
WHERE body @@ websearch_to_tsquery('"not available"')
ORDER BY "timestamp";
-- 14:02:07.815, 14:02:37.812, 14:03:37.818 · payments · payments-db - Connection is not available, …
SELECT "timestamp"::TIME AS t, span_name
FROM otel_traces
WHERE event_names @@ 'exception'
ORDER BY "timestamp";
-- 14:02:05.01, 14:02:35.01, 14:03:35.01 · PaymentService/Charge
> stage 05 · correlate

One JOIN across signals

The signals share ids and time, so correlation is SQL: the slow spans of one service joined to the log lines written in them. This is step 5.

sql
SELECT t."timestamp"::TIME AS t, round(t.duration_ns / 1e6) AS ms,
l.severity_text, l.body
FROM otel_traces t
JOIN otel_logs l ON l.trace_id = t.trace_id AND l.span_id = t.span_id
WHERE t.service_name = 'payments' AND t.duration_ns > 1000000000
ORDER BY t."timestamp";
> stage 06 · dashboards

Grafana panels in SQL

Add SereneDB as a PostgreSQL data source on port 7890 and write panels in raw SQL. Grafana replaces its time macros before sending; the statement below is what reached SereneDB.

sql
-- the panel, as written in Grafana:
-- SELECT $__timeGroupAlias("timestamp", '1m'), count(*) …
-- FROM otel_traces WHERE $__timeFilter("timestamp") AND …
-- what SereneDB receives for the range 14:00–14:06:
SELECT floor(extract(epoch from "timestamp")/60)*60 AS "time",
count(*) AS requests,
round(count(*) FILTER (WHERE status_code = 'Error')::DOUBLE / count(*), 2) AS error_rate
FROM otel_traces
WHERE "timestamp" BETWEEN '2026-11-06T14:00:00Z' AND '2026-11-06T14:06:00Z'
AND service_name = 'frontend'
GROUP BY 1 ORDER BY 1;
-- one row a minute; time is epoch seconds, 1793973600 = 14:00 UTC
-- 14:00 2 0 · 14:01 2 0 · 14:02 2 1 · 14:03 2 0.5 · 14:04 2 0 · 14:05 2 0
> stage 07 · retention

Retention and archive as jobs

A CREATE JOB per table deletes what is past its period, in the form the OpenTelemetry docs give. Before that, COPY … TO can keep the rows as Parquet, and read_parquet queries them later.

sql
CREATE JOB otel_retention EVERY 1 HOUR AS $$
DELETE FROM otel_logs
WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '30 days');
DELETE FROM otel_traces
WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '30 days');
DELETE FROM otel_metrics_histogram
WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '13 months');
DELETE FROM otel_metrics_sum
WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '13 months');
$$;
EXECUTE JOB otel_retention;
SELECT job_name, last_status FROM sdb_jobs();
-- otel_retention | success
COPY (SELECT * FROM otel_traces
WHERE "timestamp" @@ ts_lt(TIMESTAMP_NS '2026-11-06 14:03:00'))
TO '/tmp/otel_traces_2026-11-06.parquet' (FORMAT parquet);
SELECT count(*) FROM read_parquet('/tmp/otel_traces_2026-11-06.parquet');
-- COPY 18, then 18
A diagram of seven numbered stages. Traces, metrics and logs arrive as OTLP/HTTP exports to the otel listener on port 4318, at /v1/traces, /v1/metrics and /v1/logs. They land in search tables created at startup: otel_traces with one row per span, five otel_metrics tables with one per metric shape, and otel_logs with one row per log record. Each table has one inverted index: trace_id and span_id as exact values, service, span and metric names as exact values, the log body through the otel_body_dict dictionary as text, and the timestamp as a range. Queries compute span latency with quantile_cont, metric rates and histogram quantiles with otel_rate and otel_histogram_quantile, search logs with @@, and join logs to spans on trace_id and span_id. Grafana reads the results through its PostgreSQL data source on port 7890, and CREATE JOB deletes rows past their retention after COPY has kept them as Parquet.

What three signals need from one engine

otel_*
tables
Seven tables, made at startup

The listener creates every OTLP table and index in its database; a fresh server takes exports at once.

otel_logs · otel_traces · otel_metrics_{gauge, sum, histogram, …}
ids
Ids are exact terms

trace_id and span_id are indexed whole in logs and traces, so a trace is an index lookup in either table.

trace_id = '2710f955fcc460b318e2e4eb2f60514a'
math
Counter math in SQL

Rates, increases, histogram quantiles and fractions, and otel_predict_linear for gauges, as aggregate functions.

otel_rate · otel_increase · otel_histogram_quantile
searchbench
A billion log rows

SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.

otel_logs_1b · 26.09.1
SearchBench on otel_logs_1b →

Build it

Tutorial: query OpenTelemetry traces, metrics and logs in SQL, in five steps

From an empty server to the root cause of an incident: start the OTLP listener, export one incident as all three signals, then find the slow service, read its metrics and join its logs.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 -p 4318:4318 \
  -e POSTGRES_PASSWORD=<password> \
  --entrypoint serened serenedb/serenedb:26.11.2 /var/lib/serenedb \
  --listen='postgres://0.0.0.0:7890,http://0.0.0.0:4318?api=otel'

# on Linux, the serened binary instead:
# serened ./data --listen 'postgres://0.0.0.0:7890,http://0.0.0.0:4318?api=otel'
The log lists the seven otel_* tables it created and both listeners.
step 02 / 05 · shell
for signal in traces metrics logs; do
  curl -u postgres:<password> -H 'Content-Type: application/json' \
    --data @$signal.json http://localhost:4318/v1/$signal; echo
done
# {}
# {}
# {}

psql -h localhost -p 7890 -U postgres \
  -c 'VACUUM (REFRESH_TABLE) otel_traces' \
  -c 'VACUUM (REFRESH_TABLE) otel_metrics_histogram' \
  -c 'VACUUM (REFRESH_TABLE) otel_metrics_sum' \
  -c 'VACUUM (REFRESH_TABLE) otel_logs' \
  -c 'SELECT (SELECT count(*) FROM otel_traces) AS spans,
             (SELECT count(*) FROM otel_metrics_histogram)
           + (SELECT count(*) FROM otel_metrics_sum) AS metric_points,
             (SELECT count(*) FROM otel_logs) AS log_records'
{} for each export, then 36, 72 and 14. Each export is one transaction; rows become visible at the next refresh, and VACUUM (REFRESH_TABLE) publishes them now, one table per statement.
step 03 / 05 · sql
SELECT s.service_name, count(*) AS spans,
       round(quantile_cont(s.duration_ns, 0.99) / 1e6) AS p99_ms,
       round(sum(s.duration_ns - coalesce(c.child_ns, 0)) / 1e6) AS self_ms,
       count(*) FILTER (WHERE s.status_code = 'Error') AS errors
FROM otel_traces s
LEFT JOIN (SELECT parent_span_id, sum(duration_ns) AS child_ns
           FROM otel_traces GROUP BY parent_span_id) c
       ON c.parent_span_id = s.span_id
WHERE s.span_kind @@ 'Server'
GROUP BY s.service_name ORDER BY self_ms DESC;
Every service shows a p99 near three seconds and the same three errors, because a failure travels up the call. Self time, a span's duration minus its children's, puts payments first: 10,696 ms, against 1,169 ms for checkout.
step 04 / 05 · sql
SELECT minute,
       round(otel_histogram_quantile(0.5, buckets, explicit_bounds), 2) AS p50_s,
       round(otel_histogram_quantile(0.99, buckets, explicit_bounds), 2) AS p99_s
FROM (
  SELECT date_trunc('minute', "timestamp") AS minute,
         resource_attributes, attributes, explicit_bounds,
         otel_increase(bucket_counts, "timestamp") AS buckets
  FROM otel_metrics_histogram
  WHERE metric_name = 'http.server.request.duration'
  GROUP BY ALL
)
GROUP BY minute ORDER BY minute;

SELECT date_trunc('minute', "timestamp") AS minute,
       max(value) FILTER (WHERE metric_name = 'db.client.connection.count') AS in_use,
       max(value) FILTER (WHERE metric_name = 'db.client.connection.limit') AS pool_limit
FROM otel_metrics_sum
WHERE service_name = 'payments'
GROUP BY 1 ORDER BY 1;
p99 jumps from 0.42 s to 4.93 s at 14:02 and 14:03, the two minutes the pool sits at 20 of 20. At 14:04 the limit is 40 and p99 is back under a second.
step 05 / 05 · sql
SELECT t."timestamp"::TIME AS t, round(t.duration_ns / 1e6) AS ms,
       l.severity_text, l.body
FROM otel_traces t
JOIN otel_logs l ON l.trace_id = t.trace_id AND l.span_id = t.span_id
WHERE t.service_name = 'payments' AND t.duration_ns > 1000000000
ORDER BY t."timestamp";

CREATE JOB otel_retention EVERY 1 HOUR AS $$
  DELETE FROM otel_logs
  WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '30 days');
  DELETE FROM otel_traces
  WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '30 days');
  DELETE FROM otel_metrics_histogram
  WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '13 months');
  DELETE FROM otel_metrics_sum
  WHERE "timestamp" @@ ts_lt(now()::TIMESTAMP_NS - INTERVAL '13 months');
$$;
Four rows: the pool's timeout on the three failures and a 1842 ms wait on the slow success. The job runs at the top of every hour; EXECUTE JOB runs it once now.

Use cases

What teams build on SereneDB for observability

Grafana dashboards over all three signals

Grafana's built-in PostgreSQL data source connects to SereneDB on port 7890, and each panel is a SQL query: request rates and p99 from the metric tables, error rates from spans, log counts from otel_logs. Grafana turns its time macros, such as $__timeFilter, into plain SQL before the query is sent.

built with
PostgreSQL data source$__timeGroupAlias$__timeFilter
Real-time analytics →
example · a time series panel
SELECT $__timeGroupAlias("timestamp", '1m'), service_name,
       round(quantile_cont(duration_ns, 0.99) / 1e6) AS p99_ms
FROM otel_traces
WHERE $__timeFilter("timestamp") AND span_kind @@ 'Server'
GROUP BY 1, 2 ORDER BY 1, 2
result
timeservice_namep99_ms
1793973720checkout3001
1793973720frontend3012
1793973720payments2806
1793973780checkout2992
1793973780frontend3001
1793973780payments2800
The rows SereneDB returned for 14:02 to 14:04, after Grafana expanded the two macros into floor(extract(epoch from "timestamp")/60)*60 and a BETWEEN. time is in epoch seconds: 1793973720 is 14:02 UTC.

Latency SLOs from the histogram buckets

An SLO such as "99% of checkouts under 500 ms" is the share of requests in the buckets below the target. otel_histogram_fraction computes it per minute from the same per-series buckets as a quantile, and the minutes below target are the error budget spent.

built with
otel_histogram_fractionotel_increaseFILTER
OLAP database →
example · minutes under target
SELECT minute,
       round(otel_histogram_fraction('-inf'::DOUBLE, 0.5, buckets, explicit_bounds), 3) AS under_500ms,
       CASE WHEN otel_histogram_fraction('-inf'::DOUBLE, 0.5, buckets, explicit_bounds) >= 0.99
            THEN 'met' ELSE 'missed' END AS slo_99
FROM (
  SELECT date_trunc('minute', "timestamp") AS minute,
         resource_attributes, attributes, explicit_bounds,
         otel_increase(bucket_counts, "timestamp") AS buckets
  FROM otel_metrics_histogram
  WHERE metric_name = 'http.server.request.duration'
  GROUP BY ALL
)
GROUP BY minute ORDER BY minute;
result
minuteunder_500msslo_99
2026-11-06 14:00:001met
2026-11-06 14:01:001met
2026-11-06 14:02:000.467missed
2026-11-06 14:03:000.467missed
2026-11-06 14:04:000.967missed
2026-11-06 14:05:001met
Over the incident: fewer than half the checkouts finish under 500 ms at 14:02 and 14:03, and 14:04 still misses while the queue drains. Three minutes of error budget spent.

Token use and latency of LLM calls

LLM calls instrumented with OpenTelemetry's GenAI semantic conventions, still in development, are client spans like any other, with the model and token counts as gen_ai.* attributes. Token use and latency per model are a GROUP BY on the span attributes, next to the traces of the services that made the calls.

built with
gen_ai.request.model->>quantile_cont
Database for AI agents →
example · tokens and p95 per model
SELECT span_attributes ->> 'gen_ai.request.model' AS model, count(*) AS calls,
       sum((span_attributes ->> 'gen_ai.usage.input_tokens')::INT) AS input_tokens,
       sum((span_attributes ->> 'gen_ai.usage.output_tokens')::INT) AS output_tokens,
       round(quantile_cont(duration_ns, 0.95) / 1e6) AS p95_ms
FROM otel_traces
WHERE span_attributes ->> 'gen_ai.operation.name' = 'chat'
GROUP BY model ORDER BY input_tokens DESC;
result
modelcallsinput_tokensoutput_tokensp95_ms
gpt-4.1-mini372979562187
llama3.1:8b35578643728
Six chat spans from a support assistant, three to a hosted model and three to a local one, exported over OTLP/HTTP like the incident.

FAQ

Frequently asked questions

Go to docs →

An observability platform collects the telemetry a system emits, its metrics, traces and logs, stores it, and lets engineers ask questions of it: what is slow, which service fails, and why. Its parts are instrumentation and collection (OpenTelemetry), storage and query, and dashboards and alerts. SereneDB is the storage and query part, in SQL.

Services emit telemetry through OpenTelemetry SDKs; a collector batches it and exports it over OTLP; a database stores each signal and answers queries; dashboards and alert rules read from it. With SereneDB the export goes straight to its otel listener, the signals are seven indexed tables, and the architecture above follows one export from ingest to a Grafana panel.

Monitoring watches known signals against thresholds and tells you that something is wrong. Observability is the ability to ask new questions of the telemetry to find out why, often questions nobody planned a dashboard for. Both read the same data; observability needs it stored in a form you can query freely, such as SQL tables.

Grafana is the dashboard and alerting layer of many observability stacks: it visualizes data that lives in other systems. It reads SereneDB through its built-in PostgreSQL data source, with panels written in SQL.

Yes. SereneDB is open source under the Apache-2.0 license, with its source on GitHub. With OpenTelemetry for collection and Grafana for dashboards, every part of the stack is open source, and its Docker image runs the walkthrough above with one command.

Start the server with an otel listener and point an OTel SDK or a collector's otlphttp exporter at it, port 4318 in the examples. Logs, traces and metrics land in their tables, one transaction per export, in protobuf or JSON. The docs recommend a collector's batch processor in front, so that exports arrive as few large requests.

Through the ids and the time they share. A log record keeps the trace_id and span_id of the span it was written in, so a JOIN connects a slow span to its log lines; metrics and spans line up on service_name and the minute. Step 5 of the walkthrough joins them.

By what you will ask of them. Check that each signal can be sent over OTLP, that log text is searchable, that span and histogram latencies can be computed and joined, which query language your team will write, where dashboards and alert rules run, and what keeping a year of data takes. The checklist above maps each of these to SereneDB.

One that also records the calls an application makes to language models: the model, the tokens in and out, the latency and the errors. OpenTelemetry's GenAI conventions put them on spans, so they land in otel_traces and are queried in SQL beside every other span, as in the AI example above.

As long as you choose: the tables keep every row until it is deleted. A scheduled job per table enforces a retention period of its own, for example 30 days for logs and longer for metrics, and COPY … TO can write older rows to Parquet files, on disk or in S3, that read_parquet still queries.

Shippers that post NDJSON to Elasticsearch's _bulk endpoint can post to SereneDB's es listener, and SQL reads those indexes as tables beside the OpenTelemetry ones. SereneDB vs Elasticsearch maps Query DSL to SQL.

> get started

Get started with SereneDB

Start the OTLP listener, export your traces, metrics and logs, and query them in SQL from Grafana or any PostgreSQL client.