d718c5a372
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
426 lines
16 KiB
Markdown
426 lines
16 KiB
Markdown
# Distributed Tracing and Logs for SQLPage with OpenTelemetry and Grafana
|
|
|
|
SQLPage has built-in support for [OpenTelemetry](https://opentelemetry.io/) (OTel),
|
|
an open standard for collecting traces, metrics, and logs from your applications.
|
|
When enabled, every HTTP request to SQLPage produces a **trace** — a timeline of
|
|
everything that happened to serve that request, from receiving it to querying the
|
|
database and rendering the response. SQLPage also emits structured request-aware
|
|
logs, which this example forwards to Grafana Loki so you can inspect logs and traces
|
|
side by side.
|
|
|
|
This is useful for:
|
|
|
|
- **Debugging slow pages**: see exactly which SQL query is taking the longest.
|
|
- **Diagnosing connection pool exhaustion**: see how long requests wait for a database connection.
|
|
- **End-to-end visibility**: follow a single user request from your reverse proxy (nginx, Caddy, etc.)
|
|
through SQLPage and into PostgreSQL.
|
|
|
|
## Quick start (this example)
|
|
|
|
This directory contains a ready-to-run Docker Compose stack that demonstrates
|
|
the full tracing, logging, and PostgreSQL metrics pipeline. No prior
|
|
OpenTelemetry experience is needed.
|
|
|
|
### Prerequisites
|
|
|
|
- [Docker](https://docs.docker.com/get-docker/) and
|
|
[Docker Compose](https://docs.docker.com/compose/install/) installed on your machine.
|
|
|
|
### Run
|
|
|
|
```bash
|
|
cd examples/telemetry
|
|
docker compose up --build
|
|
```
|
|
|
|
This starts eight services:
|
|
|
|
| Service | Role | Port |
|
|
|------------------|-----------------------------------------------------------|---------------|
|
|
| **nginx** | Reverse proxy, creates the root trace span | `localhost:80` |
|
|
| **SQLPage** | Your application, sends traces to the collector | (internal 8080) |
|
|
| **PostgreSQL** | Database | (internal 5432) |
|
|
| **Prometheus** | Stores PostgreSQL metrics scraped from the OTel Collector | (internal 9090) |
|
|
| **Tempo** | Trace storage backend | (internal 3200) |
|
|
| **Loki** | Log storage backend | (internal 3100) |
|
|
| **OTel Collector** | Receives traces, PostgreSQL metrics, and SQLPage logs | `localhost:4318`, `localhost:1514` |
|
|
| **Grafana** | Web UI to explore traces and logs | `localhost:3000` |
|
|
|
|
### Explore traces and logs
|
|
|
|
1. Open the todo app at [http://localhost](http://localhost) — add a few items, click to toggle them.
|
|
2. Open Grafana at [http://localhost:3000](http://localhost:3000).
|
|
3. The default home dashboard now shows recent traces, recent SQLPage logs, and PostgreSQL metrics.
|
|
4. Click any trace ID in the trace table to see the full span waterfall.
|
|
5. In the logs panel, click a `trace_id` derived field to jump straight to the matching trace.
|
|
6. The PostgreSQL metrics panels are populated by the collector's `postgresqlreceiver`.
|
|
7. In the left sidebar, click **Explore** (compass icon) if you want to search manually.
|
|
8. Select **Tempo** to search traces, **Loki** to search logs, or **Prometheus** to query metrics.
|
|
|
|
### What you will see in a trace
|
|
|
|
Each HTTP request produces a tree of **spans** (timed operations):
|
|
|
|
```
|
|
[nginx] GET /todos ← root span (created by nginx)
|
|
└─ [sqlpage] GET /todos ← HTTP request span
|
|
└─ [sqlpage] SQL website/todos.sql ← SQL file execution
|
|
├─ db.pool.acquire ← time waiting for a DB connection
|
|
└─ db.query ← the actual SQL query
|
|
db.query.text = "SELECT title, ..."
|
|
db.system.name = "postgresql"
|
|
```
|
|
|
|
Key attributes on each span:
|
|
|
|
| Span | Key attributes |
|
|
|---------------------|--------------------------------------------------------------|
|
|
| HTTP request | `http.request.method`, `http.route`, `http.response.status_code`, `user_agent.original` |
|
|
| SQL file execution | `code.file.path` — which `.sql` file was executed |
|
|
| `db.pool.acquire` | `db.client.connection.pool.name`; `sqlpage.db.pool.size` — current pool size when acquiring |
|
|
| `db.query` | `db.query.text` — the full SQL text; `db.system.name` — database type |
|
|
|
|
### What you will see in the logs
|
|
|
|
SQLPage writes one structured log line per event, for example:
|
|
|
|
```text
|
|
ts=2026-03-08T20:56:15.000Z level=info target=sqlpage::access msg="200 OK" http.request.method=GET url.path=/ trace_id=4f2d...
|
|
```
|
|
|
|
Request-completion access logs use the target `sqlpage::access` and are written to stdout.
|
|
Diagnostic logs, warnings, and internal errors are written to stderr. Docker and most
|
|
container log drivers collect both streams by default, but custom log pipelines that read
|
|
only stderr need to collect stdout as well to keep access logs.
|
|
|
|
The OpenTelemetry Collector receives these SQLPage container logs through Docker's syslog
|
|
logging driver and forwards them to Loki.
|
|
The homepage dashboard filters to the `sqlpage` service so you can see request logs update
|
|
live while you use the sample app.
|
|
|
|
### PostgreSQL correlation and explain plans
|
|
|
|
SQLPage automatically sets the
|
|
[`application_name`](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-APPLICATION-NAME)
|
|
on each database connection to include the W3C
|
|
[traceparent](https://www.w3.org/TR/trace-context/#traceparent-header).
|
|
This means you can:
|
|
|
|
- See trace IDs in `pg_stat_activity` when monitoring live queries:
|
|
```sql
|
|
SELECT application_name, query, state FROM pg_stat_activity;
|
|
-- application_name: sqlpage 00-abc123...-def456...-01
|
|
```
|
|
- Include trace IDs in PostgreSQL logs by adding `%a` to
|
|
[`log_line_prefix`](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-LINE-PREFIX).
|
|
|
|
This example also enables PostgreSQL's
|
|
[`auto_explain`](https://www.postgresql.org/docs/current/auto-explain.html)
|
|
extension for queries slower than 25 ms. The plans are logged in JSON and keep
|
|
the SQLPage trace context in the `app=[...]` prefix, so Grafana's Loki
|
|
`trace_id` derived field links each slow-query plan back to the originating
|
|
SQLPage trace.
|
|
|
|
### Testing pool pressure
|
|
|
|
To simulate database connection pool exhaustion (a common production issue),
|
|
reduce the pool size to 1 in `sqlpage/sqlpage.json`:
|
|
|
|
```json
|
|
{
|
|
"listen_on": "0.0.0.0:8080",
|
|
"max_database_pool_connections": 1
|
|
}
|
|
```
|
|
|
|
Restart (`docker compose restart sqlpage`), then open several browser tabs
|
|
to `http://localhost` simultaneously. In Grafana, you will see `db.pool.acquire`
|
|
spans with longer durations as requests queue up waiting for the single connection.
|
|
|
|
---
|
|
|
|
## How it works
|
|
|
|
### Enabling tracing in SQLPage
|
|
|
|
Tracing is **built into SQLPage** — there is nothing to install or compile.
|
|
It activates automatically when you set the `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
environment variable. When this variable is not set, SQLPage behaves exactly
|
|
as before (plain text logs, no tracing overhead).
|
|
|
|
**Minimal setup — just two environment variables:**
|
|
|
|
```bash
|
|
# Where to send traces (an OTLP-compatible endpoint)
|
|
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
|
|
|
|
# A name to identify this service in traces
|
|
export OTEL_SERVICE_NAME="sqlpage"
|
|
|
|
# Now start SQLPage as usual
|
|
sqlpage
|
|
```
|
|
|
|
These are [standard OpenTelemetry environment variables](https://opentelemetry.io/docs/specs/otel/protocol/exporter/)
|
|
understood by all OTel-compatible tools. SQLPage reads them directly — no
|
|
`sqlpage.json` configuration is needed for tracing.
|
|
|
|
### The role of each component
|
|
|
|
**OpenTelemetry** is a standard, not a product. It defines a protocol (OTLP) for
|
|
sending trace data. Here is how the pieces fit together:
|
|
|
|
```
|
|
Traces: SQLPage -> OTel Collector -> Tempo -> Grafana
|
|
Logs: SQLPage -> Docker syslog logging driver -> OTel Collector -> Loki -> Grafana
|
|
Metrics: PostgreSQL -> OTel Collector postgresqlreceiver -> Prometheus -> Grafana
|
|
```
|
|
|
|
- **SQLPage** generates trace data and sends it via the OTLP HTTP protocol.
|
|
- A **collector** (optional) receives traces and forwards them to one or more backends.
|
|
Useful for buffering, sampling, or fanning out to multiple destinations.
|
|
You can skip the collector and send directly from SQLPage to most backends.
|
|
- The **OTel Collector** also receives SQLPage container logs and forwards them to Loki.
|
|
- **Tempo** stores traces, **Loki** stores logs, and **Grafana** lets you search both.
|
|
|
|
### Trace context propagation
|
|
|
|
When a reverse proxy (like nginx) sits in front of SQLPage, you want the trace
|
|
to start at nginx and continue into SQLPage as a single, connected trace.
|
|
This works via the
|
|
[W3C Trace Context](https://www.w3.org/TR/trace-context/) standard:
|
|
nginx adds a `traceparent` HTTP header to the request it forwards to SQLPage,
|
|
and SQLPage reads it to continue the same trace.
|
|
|
|
Most modern reverse proxies and load balancers support this.
|
|
For nginx specifically, use the [`ngx_otel_module`](https://nginx.org/en/docs/ngx_otel_module.html)
|
|
(included in the `nginx:otel` Docker image).
|
|
|
|
---
|
|
|
|
## Setup guides by deployment scenario
|
|
|
|
### Self-hosted with Grafana Tempo and Loki
|
|
|
|
This is what the Docker Compose example in this directory uses.
|
|
[Grafana Tempo](https://grafana.com/oss/tempo/) is a free, open-source trace backend, and
|
|
[Grafana Loki](https://grafana.com/oss/loki/) is the corresponding log backend.
|
|
|
|
**Components:**
|
|
- [Grafana Tempo](https://grafana.com/docs/tempo/latest/) stores the traces.
|
|
- [Grafana Loki](https://grafana.com/docs/loki/latest/) stores the logs.
|
|
- [Grafana](https://grafana.com/docs/grafana/latest/) provides the web UI.
|
|
- An [OTel Collector](https://opentelemetry.io/docs/collector/) receives SQLPage traces,
|
|
SQLPage logs, and PostgreSQL metrics in this example.
|
|
|
|
**SQLPage environment variables:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://<collector-or-tempo-host>:4318
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
**Links:**
|
|
- [Tempo installation guide](https://grafana.com/docs/tempo/latest/setup/)
|
|
- [OTel Collector installation](https://opentelemetry.io/docs/collector/installation/)
|
|
|
|
### Self-hosted with Jaeger
|
|
|
|
[Jaeger](https://www.jaegertracing.io/) is another popular open-source tracing
|
|
backend. Version 2+ natively accepts OTLP — no collector needed.
|
|
|
|
**Start Jaeger with one command:**
|
|
|
|
```bash
|
|
docker run -d --name jaeger \
|
|
-p 16686:16686 \
|
|
-p 4317:4317 \
|
|
-p 4318:4318 \
|
|
jaegertracing/jaeger:latest
|
|
```
|
|
|
|
**SQLPage environment variables:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
Open the Jaeger UI at [http://localhost:16686](http://localhost:16686) to explore traces.
|
|
|
|
**Links:**
|
|
- [Jaeger getting started](https://www.jaegertracing.io/docs/latest/getting-started/)
|
|
|
|
### Grafana Cloud
|
|
|
|
[Grafana Cloud](https://grafana.com/products/cloud/) has a free tier that
|
|
includes trace storage. SQLPage can send traces directly — no collector needed.
|
|
|
|
**SQLPage environment variables:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-<region>.grafana.net/otlp
|
|
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64-of-instance_id:api_token>"
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
Replace:
|
|
- `<region>` with your Grafana Cloud region (e.g., `us-east-0`, `eu-west-2`).
|
|
Find it in your Grafana Cloud portal under **My Account** > **Tempo**.
|
|
- `<base64-of-instance_id:api_token>` with the Base64 encoding of
|
|
`<instance-id>:<cloud-api-token>`. Generate a token in your Grafana Cloud
|
|
portal under **My Account** > **API Keys**.
|
|
|
|
On macOS/Linux, generate the Base64 value with:
|
|
```bash
|
|
echo -n "123456:glc_your_token_here" | base64
|
|
```
|
|
|
|
**Links:**
|
|
- [Send data via OTLP to Grafana Cloud](https://grafana.com/docs/grafana-cloud/send-data/otlp/send-data-otlp/)
|
|
|
|
### Datadog
|
|
|
|
[Datadog](https://www.datadoghq.com/) supports OTLP ingestion through the
|
|
Datadog Agent.
|
|
|
|
**1. Run the Datadog Agent** with OTLP ingest enabled:
|
|
|
|
```bash
|
|
docker run -d --name datadog-agent \
|
|
-e DD_API_KEY=<your-datadog-api-key> \
|
|
-e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT=0.0.0.0:4318 \
|
|
-e DD_SITE=datadoghq.com \
|
|
-p 4318:4318 \
|
|
gcr.io/datadoghq/agent:latest
|
|
```
|
|
|
|
**2. Point SQLPage to the Agent:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
Traces appear in the Datadog **APM > Traces** section.
|
|
|
|
**Links:**
|
|
- [OTLP ingestion in the Datadog Agent](https://docs.datadoghq.com/opentelemetry/setup/otlp_ingest_in_the_agent/)
|
|
|
|
### Honeycomb
|
|
|
|
[Honeycomb](https://www.honeycomb.io/) accepts OTLP directly — no collector needed.
|
|
|
|
**SQLPage environment variables:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
|
|
OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=<your-api-key>"
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
For the EU region, use `https://api.eu1.honeycomb.io` instead.
|
|
|
|
**Links:**
|
|
- [Send data with OpenTelemetry — Honeycomb docs](https://docs.honeycomb.io/send-data/opentelemetry/)
|
|
|
|
### New Relic
|
|
|
|
[New Relic](https://newrelic.com/) accepts OTLP directly.
|
|
|
|
**SQLPage environment variables:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net
|
|
OTEL_EXPORTER_OTLP_HEADERS="api-key=<your-newrelic-license-key>"
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
For the EU region, use `https://otlp.eu01.nr-data.net` instead.
|
|
|
|
Find your Ingest License Key in the New Relic UI under
|
|
**API Keys** (type: `INGEST - LICENSE`).
|
|
|
|
**Links:**
|
|
- [New Relic OTLP endpoint configuration](https://docs.newrelic.com/docs/opentelemetry/best-practices/opentelemetry-otlp/)
|
|
|
|
### Axiom
|
|
|
|
[Axiom](https://axiom.co/) accepts OTLP directly.
|
|
|
|
**SQLPage environment variables:**
|
|
|
|
```bash
|
|
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.axiom.co
|
|
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <your-api-token>,X-Axiom-Dataset=<your-dataset>"
|
|
OTEL_SERVICE_NAME=sqlpage
|
|
```
|
|
|
|
**Links:**
|
|
- [Send OpenTelemetry data to Axiom](https://axiom.co/docs/send-data/opentelemetry)
|
|
|
|
---
|
|
|
|
## Environment variable reference
|
|
|
|
These are [standard OpenTelemetry variables](https://opentelemetry.io/docs/specs/otel/protocol/exporter/),
|
|
not specific to SQLPage.
|
|
|
|
| Variable | Required? | Description | Example |
|
|
|-----------------------------------|-----------|------------------------------------------------|--------------------------------------|
|
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Yes | Base URL of the OTLP receiver | `http://localhost:4318` |
|
|
| `OTEL_SERVICE_NAME` | No | Service name shown in traces (default: `unknown_service`) | `sqlpage` |
|
|
| `OTEL_EXPORTER_OTLP_HEADERS` | No | Comma-separated `key=value` pairs for auth headers | `api-key=abc123` |
|
|
| `OTEL_EXPORTER_OTLP_PROTOCOL` | No | Protocol (default: `http/protobuf`) | `http/protobuf` |
|
|
| `RUST_LOG` or `LOG_LEVEL` | No | Filter which spans/logs are emitted | `sqlpage=debug,tracing_actix_web=info` |
|
|
|
|
When `OTEL_EXPORTER_OTLP_ENDPOINT` is **not set**, SQLPage uses plain text
|
|
logging only (same behavior as versions before tracing support was added).
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### No traces appear
|
|
|
|
1. **Check that SQLPage sees the endpoint.** Look for this line in the startup logs:
|
|
```
|
|
OpenTelemetry tracing enabled (OTEL_EXPORTER_OTLP_ENDPOINT is set)
|
|
```
|
|
If you don't see it, the environment variable is not reaching SQLPage.
|
|
|
|
2. **Check that the collector/backend is reachable.** From the SQLPage host, try:
|
|
```bash
|
|
curl -v http://<endpoint>:4318/v1/traces
|
|
```
|
|
You should get a response (even if it's an error like "no data"), not a connection refused.
|
|
|
|
3. **Check the collector logs** for export errors (e.g., authentication failures).
|
|
|
|
### Traces are disconnected (nginx and SQLPage show as separate traces)
|
|
|
|
This means the `traceparent` header is not being propagated. Check that:
|
|
|
|
- Your reverse proxy is configured to inject/propagate the `traceparent` header.
|
|
- For nginx, you need the `ngx_otel_module` with `otel_trace_context propagate`
|
|
in the location block. Setting `otel_span_name "$request_method $uri"` also keeps
|
|
the nginx span name aligned with the actual request path. See the `nginx/nginx.conf`
|
|
in this example.
|
|
|
|
### Spans are missing (e.g., no `db.query` spans)
|
|
|
|
The `RUST_LOG` filter might be too restrictive.
|
|
SQLPage emits spans at the `INFO` level by default. Make sure your filter
|
|
includes `sqlpage=info`:
|
|
|
|
```bash
|
|
RUST_LOG="sqlpage=info,actix_web=info,tracing_actix_web=info"
|
|
```
|
|
|
|
If you filter individual targets instead of the broader `sqlpage` target, include
|
|
the access-log target too:
|
|
|
|
```bash
|
|
RUST_LOG="sqlpage::access=info,sqlpage::webserver::http=info,actix_web=info,tracing_actix_web=info"
|
|
```
|