Wiring xqlite telemetry
View Sourcexqlite emits :telemetry events for every observable operation
(query / execute / transaction / stream / backup / wal_checkpoint /
serialize / pragma / extension / cancellation) plus an opt-in bridge
that turns multi-subscriber hook deliveries (commit / rollback /
update / wal / progress / log) into telemetry. Everything is
compile-time opt-in — when telemetry is disabled (the default),
no :telemetry calls exist in the bytecode at all.
Enable telemetry
In your application's config/config.exs:
config :xqlite, :telemetry_enabled, trueRebuild the xqlite dep so the flag takes effect:
mix deps.compile xqlite --force
To verify at runtime:
iex> Xqlite.Telemetry.enabled?()
trueWhen false, every emission site in xqlite compiles to a no-op —
including :telemetry.execute/3 calls that would otherwise be a few
hundred nanoseconds each. Designed for resource-constrained
environments (Nerves, embedded, hot loops where every nanosecond
counts).
Conventions
- Time units: every time-valued measurement —
monotonic_time,system_time,duration,total_duration,elapsed— is an integer nanosecond count. Convert at handler time (/1_000for µs,/1_000_000for ms). Counts such asrows_returned,pagesandretriesride in the same map and are not times. - Time source:
System.monotonic_time(:nanosecond). Stable across NTP drift; consumers convert to wall-clock at handler time if needed. - Identifiers: raw refs (
reference()) for connections, tokens, streams. No abstraction layer — map at attach time if you need stable IDs. - Cancellation: an operation that was cancelled fires
:stopwithmetadata.error_reason == :operation_cancelled— NOT:exception. A separate[:xqlite, :cancel, :honored]event also fires.
Event surface — operation events (always-on)
Xqlite.Telemetry.events/0 returns this list as data, and the
Xqlite.Telemetry moduledoc carries the complete schema with every
measurement and metadata key. A :* below stands for the span's
:start, :stop and :exception events.
| Event | Trigger | Key metadata |
|---|---|---|
[:xqlite, :open, :*] | Xqlite.open/2 and the other open_* functions | :path, :mode |
[:xqlite, :close, :*] | Xqlite.close/1 | :conn, :path |
[:xqlite, :query, :*] | Xqlite.query/4, Xqlite.query_cancellable/4 | :sql, :cancellable?, :num_rows (on stop) |
[:xqlite, :execute, :*] | Xqlite.execute/4 and cancellable variant | :sql, :affected_rows (on stop) |
[:xqlite, :execute_batch, :*] | Xqlite.execute_batch/2 and cancellable variant | :sql_batch_size_bytes |
[:xqlite, :query_with_changes, :*] | Xqlite.query_with_changes_cancellable/4 | :sql, :num_rows, :changes (on stop) |
[:xqlite, :explain_analyze, :*] | Xqlite.explain_analyze/3 | :wall_time_ns, :rows_produced, :scan_count |
[:xqlite, :transaction, :begin / :commit / :rollback] | Xqlite.begin/2, commit/1, rollback/1 | :mode (begin), :reason (rollback) |
[:xqlite, :savepoint, :create / :release / :rollback_to] | Xqlite.savepoint/2 etc. | :name |
[:xqlite, :stream, :open, :*] | Xqlite.stream/4 opens a NIF stream | :batch_size |
[:xqlite, :stream, :fetch] | every batch (potentially thousands per stream) | :stream_handle, :done? |
[:xqlite, :stream, :close] | stream consumed / dropped | :stream_handle, :reason (:drained / :halted / :errored), :close_error (only when the close itself failed) |
[:xqlite, :backup, :*] | Xqlite.backup/3 | :dest_path, :byte_size |
[:xqlite, :restore, :*] | Xqlite.restore/3 | :src_path |
[:xqlite, :wal_checkpoint, :*] | Xqlite.wal_checkpoint/3 | :mode, :log_pages, :checkpointed_pages, :busy? |
[:xqlite, :serialize, :*] | Xqlite.serialize/2 | :byte_size |
[:xqlite, :deserialize, :*] | Xqlite.deserialize/4 | :read_only?, :byte_size |
[:xqlite, :extension, :load, :*] | Xqlite.load_extension/3 | :path, :entry_point |
[:xqlite, :extension, :enable] | Xqlite.enable_load_extension/2 | :enabled |
[:xqlite, :pragma, :get / :set] | Xqlite.get_pragma/2, Xqlite.set_pragma/3 | :name, :value (on set) |
[:xqlite, :cancel, :token_created] | Xqlite.create_cancel_token/0 | :token |
[:xqlite, :cancel, :signalled] | Xqlite.cancel_operation/1 | :token |
[:xqlite, :cancel, :honored] | a cancellable operation observed cancellation | :conn, :operation, :tokens |
Xqlite.backup_with_progress/6 is not in the list: it reports its
progress to a pid and emits no telemetry.
Event surface — hook bridge events (opt-in)
The hook bridge turns the multi-subscriber hook fan-out (commit,
rollback, update, wal, progress, busy) into telemetry events. NOT
attached automatically — call Xqlite.Telemetry.bridge/2:
{:ok, bridge} =
Xqlite.Telemetry.bridge(conn,
hooks: [:wal, :commit, :rollback, :update, :progress, :busy],
tag: :my_app_replica_a
)
# The hook events below now fire with `tag: :my_app_replica_a` in
# their metadata.
:ok = Xqlite.Telemetry.unbridge(bridge)Pass hooks: :all for the full set. For the global SQLite log hook,
use Xqlite.Telemetry.bridge_log/1 — it is process-wide, not
per-connection, so it takes no conn.
| Event | Fires when | Key metadata |
|---|---|---|
[:xqlite, :hook, :commit] | a transaction commits on the bridged connection | :conn, :tag |
[:xqlite, :hook, :rollback] | a transaction rolls back | :conn, :tag |
[:xqlite, :hook, :update] | a row is inserted, updated or deleted | :action, :db_name, :table, :rowid |
[:xqlite, :hook, :wal] | a commit appends frames to the WAL | :db_name; measurement pages |
[:xqlite, :hook, :progress] | every nth SQLite VM step | :hook_tag; measurements count, elapsed |
[:xqlite, :hook, :busy] | the connection meets a lock another one holds | :conn, :tag; measurements retries, elapsed |
[:xqlite, :hook, :log] | SQLite writes a diagnostic (global) | :code, :base_code, :message |
Only the observer half of busy handling is bridged. The retry
policy stays a single slot per connection and is set with
Xqlite.set_busy_policy/2.
Sample handlers
Datadog / StatsD
:telemetry.attach_many(
"xqlite-statsd",
[
[:xqlite, :query, :stop],
[:xqlite, :execute, :stop]
],
fn _name, %{duration: ns}, %{result_class: class}, _ ->
duration_ms = ns / 1_000_000
StatsD.histogram("xqlite.query.duration_ms", duration_ms, tags: [class])
end,
nil
)Properly-named database attributes (semantic conventions)
Database-aware backend features (Datadog DB monitoring, latency-by-
statement views) key off OpenTelemetry's stable database
semantic-convention names
— db.system.name, db.query.text, db.operation.name,
db.namespace, error.type. Xqlite.Telemetry.OpenTelemetry is the
pure mapping from xqlite's events to exactly that vocabulary — no
OpenTelemetry dependency; you call it from your own handler:
def handle_event([:xqlite | _] = event, measurements, metadata, _cfg) do
attrs = Xqlite.Telemetry.OpenTelemetry.attributes(event, measurements, metadata)
# set `attrs` on the span you create; span_name/2 suggests the span name
endEvery mapped name is cited to its spec page in that module's docs.
Honeycomb / OpenTelemetry
xqlite does NOT depend on :opentelemetry — that's a downstream
concern. What it ships is the mapping: Xqlite.Telemetry.OpenTelemetry
turns an event into the stable OpenTelemetry database attributes (the
handle_event above), and its span events carry a stable
telemetry_span_context in their metadata (see Xqlite.Telemetry).
To turn those into OTel spans, write a handler that calls the
opentelemetry_telemetry package's :otel_telemetry.start_telemetry_span/4
on :start and :otel_telemetry.end_telemetry_span/2 on :stop and
:exception. That package is a toolkit for handlers you write; it has
no attach call that subscribes on its own.
Logger
require Logger
:telemetry.attach(
"xqlite-log",
[:xqlite, :cancel, :honored],
fn _, _measurements, %{operation: op, tokens: tokens}, _ ->
Logger.warning("xqlite #{op} cancelled, tokens: #{inspect(tokens)}")
end,
nil
)Prometheus
Use :telemetry_metrics and :telemetry_metrics_prometheus_core —
the standard pipeline. Define metrics declaratively:
# In your supervisor:
def metrics do
[
Telemetry.Metrics.distribution("xqlite.query.duration",
event_name: [:xqlite, :query, :stop],
measurement: :duration,
unit: {:native, :millisecond}
),
Telemetry.Metrics.counter("xqlite.cancel.honored",
event_name: [:xqlite, :cancel, :honored]
)
]
endComposing with xqlite_ecto3
The Ecto adapter emits its own [:xqlite_ecto3, :*] events at the
DBConnection callback layer. Both layers fire — pick the layer
that matches your observability needs:
[:my_app, :repo, :query](Ecto's own) — the high-level Repo event, ideal for "how long did this Repo.all/insert take?"[:xqlite_ecto3, :handle_execute, :*]— adapter-internal, pre-DBConnection-pool wrap.[:xqlite, :query, :*]— xqlite-internal, raw NIF timing.
Together they give a layered view: pool → adapter → driver.
Performance
Per-event cost when no handler is attached: ~hundreds of nanoseconds
(:telemetry.execute/3 fast-path). When handlers are attached, the
cost depends on the handler. xqlite emits aggressively (every stream
fetch, every cancel signal) — a heavy handler attached to a hot
event will measurably slow queries down. If you need fine-grained
instrumentation in production, consider:
- Sampling at the handler (
if :rand.uniform() < 0.01, do: ...) - Buffering measurements and flushing in batches
- Using
:telemetry_metrics(already does buffered aggregation)
Verifying disabled-mode
If you've set :telemetry_enabled, false:
iex> Xqlite.Telemetry.enabled?()
falseIn this mode, :telemetry.execute/3 is never called. Verify by
attaching a global handler and running queries:
iex> :telemetry.attach("debug", [:xqlite, :query, :stop], fn _, _, _, _ ->
...> IO.puts("event fired")
...> end, nil)
iex> {:ok, conn} = Xqlite.open_in_memory()
iex> Xqlite.query(conn, "SELECT 1", [])
# (no "event fired" output)