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 | shWhat 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.
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;
| minute | p50_s | p99_s |
|---|---|---|
| 2026-11-06 14:00:00 | 0.15 | 0.42 |
| 2026-11-06 14:01:00 | 0.15 | 0.42 |
| 2026-11-06 14:02:00 | 1 | 4.93 |
| 2026-11-06 14:03:00 | 1 | 4.93 |
| 2026-11-06 14:04:00 | 0.16 | 0.85 |
| 2026-11-06 14:05:00 | 0.15 | 0.42 |
otel_increase turns the cumulative bucket counts into what happened in each 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;
| minute | in_use | pool_limit |
|---|---|---|
| 2026-11-06 14:00:00 | 8 | 20 |
| 2026-11-06 14:01:00 | 12 | 20 |
| 2026-11-06 14:02:00 | 20 | 20 |
| 2026-11-06 14:03:00 | 20 | 20 |
| 2026-11-06 14:04:00 | 14 | 40 |
| 2026-11-06 14:05:00 | 8 | 40 |
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;
| service_name | spans | p99_ms | self_ms | errors |
|---|---|---|---|---|
| payments | 12 | 2809 | 10696 | 3 |
| checkout | 12 | 3002 | 1169 | 3 |
| frontend | 12 | 3012 | 256 | 3 |
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;
| minute | requests | errors | slowest_ms |
|---|---|---|---|
| 2026-11-06 14:00:00 | 2 | 0 | 142 |
| 2026-11-06 14:01:00 | 2 | 0 | 156 |
| 2026-11-06 14:02:00 | 2 | 2 | 3012 |
| 2026-11-06 14:03:00 | 2 | 1 | 3011 |
| 2026-11-06 14:04:00 | 2 | 0 | 149 |
| 2026-11-06 14:05:00 | 2 | 0 | 133 |
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";
| t | ms | severity_text | body |
|---|---|---|---|
| 14:02:05.01 | 2806 | ERROR | payments-db - Connection is not available, request timed out after 2800ms. |
| 14:02:35.01 | 2803 | ERROR | payments-db - Connection is not available, request timed out after 2800ms. |
| 14:03:05.01 | 1871 | WARN | payments-db - connection acquired after 1842ms (pool 20/20 in use) |
| 14:03:35.01 | 2809 | ERROR | payments-db - Connection is not available, request timed out after 2800ms. |
trace_id and span_id, returns the connection pool's timeout on every failure and a 1842 ms wait on the slow success.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.
| Need | How SereneDB does it | Looks like |
|---|---|---|
| Collect every signal | An 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 text | Log 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 spans | Every 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 histograms | Rates, increases and histogram quantiles over cumulative OpenTelemetry metrics, per series. Docs → | otel_histogram_quantile(0.99, buckets, explicit_bounds) |
| Correlate signals | trace_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 |
| Dashboards | Grafana's built-in PostgreSQL data source runs raw SQL against SereneDB. Docs → | time_bucket(INTERVAL '1 minute', "timestamp") AS "time" |
| Retention | One scheduled job per table deletes the rows older than its period. Docs → | CREATE JOB otel_retention EVERY 1 HOUR AS $$ … $$ |
| Keep history as files | COPY 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' |
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.
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, {}.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
What three signals need from one engine
otel_*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, …}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'Rates, increases, histogram quantiles and fractions, and otel_predict_linear for gauges, as aggregate functions.
otel_rate · otel_increase · otel_histogram_quantileSearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.
otel_logs_1b · 26.09.1Tutorial: 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.
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'
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'
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;
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;
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'); $$;
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.
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
| time | service_name | p99_ms |
|---|---|---|
| 1793973720 | checkout | 3001 |
| 1793973720 | frontend | 3012 |
| 1793973720 | payments | 2806 |
| 1793973780 | checkout | 2992 |
| 1793973780 | frontend | 3001 |
| 1793973780 | payments | 2800 |
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.
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;
| minute | under_500ms | slo_99 |
|---|---|---|
| 2026-11-06 14:00:00 | 1 | met |
| 2026-11-06 14:01:00 | 1 | met |
| 2026-11-06 14:02:00 | 0.467 | missed |
| 2026-11-06 14:03:00 | 0.467 | missed |
| 2026-11-06 14:04:00 | 0.967 | missed |
| 2026-11-06 14:05:00 | 1 | met |
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.
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;
| model | calls | input_tokens | output_tokens | p95_ms |
|---|---|---|---|---|
| gpt-4.1-mini | 3 | 7297 | 956 | 2187 |
| llama3.1:8b | 3 | 5578 | 643 | 728 |
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 with SereneDB
Start the OTLP listener, export your traces, metrics and logs, and query them in SQL from Grafana or any PostgreSQL client.