Pawrly can emit traces, metrics, and an activity log so you can see what queries run, how long they take, where the time goes, and what failed. It speaks OpenTelemetry (OTLP) and Prometheus, and exposes its own activity as a queryable SQL table.
Everything here is off by default — with no configuration, Pawrly logs to stderr as before and exports nothing. You turn pieces on with CLI flags for ad-hoc runs, or the observability: block in pawrly.yaml for a persistent setup. Flags override config.
Quick start
# JSON logs instead of text
pawrly --log-format json sql "SELECT 1"
# Export traces + metrics + logs to a local OpenTelemetry collector
pawrly --otel-endpoint http://localhost:4317 serve
# Scrape metrics with Prometheus (no collector needed)
pawrly --prometheus-listen 127.0.0.1:9090 serve &
curl -s localhost:9090/metrics | grep pawrly_Or persist it in pawrly.yaml (see Configuration):
observability:
otel:
enabled: true
endpoint: http://localhost:4317
prometheus: { enabled: true, listen: 127.0.0.1:9090 }
activity:
enabled: true
sinks: [tracing, table]
redact_sql: literals
store: ~/.pawrly/activityLogging
Logs go to stderr in text (default) or json form. The level is an EnvFilter directive; RUST_LOG always wins.
| Setting | Flag / env | Config |
|---|---|---|
| Level | --log-level / PAWRLY_LOG / RUST_LOG |
tracing.level |
| Format | --log-format / PAWRLY_LOG_FORMAT |
tracing.format |
The subscriber is unified across the CLI, the daemon, and the MCP server, so all three log the same way.
Tracing
When OTLP export is on, Pawrly produces a span tree per operation, named pawrly.<subsystem>.<op>, exported over OTLP:
| Span | Covers |
|---|---|
pawrly.engine.query / pawrly.engine.semantic_query |
query execution |
pawrly.engine.explain / pawrly.engine.materialize |
explain / materialize |
pawrly.semantic.compile |
semantic-model → SQL compilation |
pawrly.cache.refresh |
cache write-through |
pawrly.source.http.request |
an outbound REST/GraphQL request |
pawrly.server.query |
the gRPC transport hop |
pawrly.mcp.tool |
an MCP tool call |
Trace context is propagated as W3C traceparent across the gRPC (CLI → daemon) and MCP HTTP boundaries, so a request that crosses processes is a single trace. SQL text and parameter values are never put on spans (cardinality + secrets); they live in the activity log, subject to redaction.
Configure under otel: — endpoint, protocol (grpc | http), service_name (the OTel resource name, default pawrly), sample_ratio (parent-based), and the traces / logs toggles.
Metrics
Metrics export over OTLP push (otel.metrics) and/or a Prometheus pull endpoint (otel.prometheus) — independently; enable either, both, or neither. The instruments:
| Instrument | Type | Key attributes |
|---|---|---|
pawrly.query.total |
counter | status, error_code |
pawrly.query.duration |
histogram (ms) | status |
pawrly.query.rows_returned |
histogram | |
pawrly.query.active |
up/down counter | |
pawrly.semantic.compile.duration |
histogram (ms) | |
pawrly.cache.refresh.duration |
histogram (ms) | source, status |
pawrly.source.request.total / .duration |
counter / histogram | kind, status, http.response.status_code |
pawrly.activity.dropped |
counter | |
pawrly.activity.redaction_failed |
counter |
active_queries is also surfaced by pawrly status and the daemon health check.
Activity log
The activity log records one structured record per query — both raw SQL (query) and semantic (semantic_query) — capturing who ran it, how long it took, how many rows it returned, and whether it failed. Enable it under activity: and choose one or more sinks:
tracing— emits each record as a structuredtracingevent (targetpawrly.activity), so it flows to your logs and, with OTLP, to your log pipeline.table— exposes the records as thesystem.activitySQL table, so you query your own history with SQL:SELECT interface, status, count(*) AS n, avg(duration_ms) AS avg_ms FROM system.activity WHERE at > now() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY n DESC;
system.activity columns
| Column | Notes |
|---|---|
id |
operation id |
at |
completion time (UTC) |
interface |
how it entered: cli, grpc, mcp, flight, in_process |
principal |
authenticated identity, when known |
operation |
query / semantic_query |
sql |
redacted per redact_sql |
param_keys |
parameter keys only — never values |
status |
ok / error |
error_code |
stable error code on failures |
duration_ms, rows_returned, bytes |
|
trace_id |
OTel trace id, to cross-reference a trace |
SQL redaction
redact_sql controls how much of the query text is stored:
| Mode | Stored |
|---|---|
false |
the SQL verbatim |
literals |
the SQL with literal values replaced by $REDACTED (shape kept) |
true |
only the statement kind and referenced tables |
Parameter values are never stored under any mode. Redaction is leak-safe: if a statement can't be parsed it degrades (literals → tables → the bare leading keyword like SELECT) and never falls back to raw text. Redaction parses with the same grammar the engine runs.
Durability
Without store, system.activity is a bounded in-memory ring of the most recent ring_capacity records, lost on restart. Set store to persist records as date/hour-partitioned Parquet (dt=YYYY-MM-DD/hr=HH/…), so the table survives restarts — it unions the on-disk history with the not-yet-flushed buffer. Records flush on a flush_threshold, a flush_interval timer, and on clean shutdown. retention prunes files older than its window; omit it to keep everything.
activity:
enabled: true
sinks: [table]
store: ~/.pawrly/activity
partition_hours: 4 # hr= bucket width
flush_threshold: 1000 # records buffered before a file is written
flush_interval: 60s # or this, whichever comes first
retention: 30d # prune older files; omit to keep all historyConfiguration reference
The full observability: block — every field and default — is documented in Configuration → Observability. A runnable config lives at examples/observability.yaml.