Lotus emits :telemetry events for query
execution, cache operations, and schema introspection. These events integrate
with monitoring tools like Phoenix LiveDashboard, AppSignal, Datadog, and others.
Events
Query Execution
| Event | Measurements | Metadata |
|---|---|---|
[:lotus, :query, :start] | system_time | source, statement, context |
[:lotus, :query, :stop] | duration, row_count | source, statement, context, row_count, result |
[:lotus, :query, :exception] | duration | source, statement, context, kind, reason, stacktrace |
Duration is measured in native time units. Use System.convert_time_unit/3 to
convert to milliseconds or microseconds. On :stop, row_count appears in both
the measurements and the metadata — it is the same value.
The source field is the data source name ("main", "warehouse"), not a
repo module. The statement field is a %Lotus.Query.Statement{}: read
statement.body for the adapter-native payload (SQL text for Ecto-backed
sources, a JSON object or AST for others) and statement.params for the bound
values. Pre-v1 :repo, :sql and :params metadata keys are gone.
The context field carries whatever value the caller passed as the :context
option to Lotus.run_statement/3 or Lotus.run_query/2. It defaults to nil when
not provided. Typical uses include request IDs, controller names, or
OpenTelemetry span contexts for trace correlation. The :scope option is not
in the metadata — it is caller identity used for cache keys and visibility, not
instrumentation.
The events bracket statement execution — sanitization, preflight,
:before_execute and the query itself — so :start fires before any of those
run. Any failure among them — a denied table, a halted :before_execute plug, a
driver error — ends the run at :exception, not :stop.
Because Lotus turns those failures into {:error, reason} rather than letting
them raise, the :exception metadata is uniform: kind is always :error,
reason is the {:error, reason} tuple the caller receives, and stacktrace
is []. Match on reason rather than expecting an exception struct.
Query telemetry brackets what a result cache hit skips — authorizing the
statement and executing it — so a query served from cache emits no
[:lotus, :query, *] events at all; [:lotus, :cache, :hit] is the event to
count for those. On a miss the span includes the cache write, which stores only
the result and the relations. The :before_query and :after_query
middleware run outside that phase, on every call: a plug that halts there yields
{:error, reason} to the caller without emitting query events, because no
statement ran. For the same reason :stop carries the result as the source
returned it — an :after_query plug that redacts rows has not run yet, so a
handler that logs metadata.result logs the unredacted rows.
Cache Operations
| Event | Measurements | Metadata |
|---|---|---|
[:lotus, :cache, :hit] | count | key |
[:lotus, :cache, :miss] | count | key |
[:lotus, :cache, :put] | count | key, ttl_ms |
Schema Introspection
| Event | Measurements | Metadata |
|---|---|---|
[:lotus, :schema, :introspection, :start] | system_time | operation, repo |
[:lotus, :schema, :introspection, :stop] | duration | operation, repo, result |
The operation field is one of: :list_schemas, :list_tables,
:describe_table, :get_table_stats, or :list_relations.
The result field is :ok or :error.
Note: these two events kept the metadata key
:repo, unlike the query events which renamed it to:source. The value is the same thing in both — the data source name ("main"), not an Ecto repo module.
Content Changes
| Event | Measurements | Metadata |
|---|---|---|
[:lotus, :content, :change, :start] | system_time | op, resource, record, context |
[:lotus, :content, :change, :stop] | duration | op, resource, record, changes, context |
[:lotus, :content, :change, :exception] | duration | op, resource, record, context, kind, reason, stacktrace |
A content change is one create, update or delete of a query, visualization,
dashboard, dashboard card, dashboard filter or filter mapping, including enabling
or disabling public sharing and moving a card in a reorder. The events bracket
every change, refused or not, and always carry the :context option the caller
passed, or nil.
op is :create, :update, :delete, :enable_sharing or :disable_sharing.
On :start, record is the struct as stored (nil on a create); on :stop it
is the struct as written, and the metadata is the :after_content_change
middleware payload. changes holds each changed field with its written value;
it is %{} on a delete and on an update that wrote nothing, which still emits
:stop.
:exception is emitted when a :before_content_change plug refused the change,
the changeset was invalid, or the write raised, threw or exited. reason is the
error the caller receives — {:halted, reason} for a refusal, the changeset for
a failed validation — and stacktrace is present only when the call raised,
threw or exited. A reorder emits one span per card it moves, and every card
shares the outcome of the call.
Setup
Attach handlers in your application's start/2 callback:
# lib/my_app/application.ex
def start(_type, _args) do
:telemetry.attach_many(
"lotus-telemetry",
[
[:lotus, :query, :stop],
[:lotus, :query, :exception],
[:lotus, :cache, :hit],
[:lotus, :cache, :miss]
],
&MyApp.LotusInstrumentation.handle_event/4,
nil
)
children = [
# ...
]
Supervisor.start_link(children, strategy: :one_for_one)
endExample Handler
defmodule MyApp.LotusInstrumentation do
require Logger
def handle_event([:lotus, :query, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Logger.info(
"Lotus query completed",
duration_ms: duration_ms,
row_count: measurements.row_count,
source: metadata.source
)
end
def handle_event([:lotus, :query, :exception], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Logger.error(
"Lotus query failed",
duration_ms: duration_ms,
reason: inspect(metadata.reason),
source: metadata.source,
statement: inspect(metadata.statement.body)
)
end
def handle_event([:lotus, :cache, :hit], _measurements, metadata, _config) do
Logger.debug("Lotus cache hit", key: metadata.key)
end
def handle_event([:lotus, :cache, :miss], _measurements, metadata, _config) do
Logger.debug("Lotus cache miss", key: metadata.key)
end
endPhoenix LiveDashboard Integration
If you use Phoenix LiveDashboard, you can add Lotus metrics to your telemetry supervisor:
# lib/my_app_web/telemetry.ex
defp metrics do
[
# Lotus query metrics
summary("lotus.query.stop.duration",
unit: {:native, :millisecond},
description: "Lotus query execution time"
),
counter("lotus.query.stop.duration",
description: "Total Lotus queries executed"
),
counter("lotus.query.exception.duration",
description: "Total Lotus query failures"
),
# Cache metrics
counter("lotus.cache.hit.count",
description: "Lotus cache hits"
),
counter("lotus.cache.miss.count",
description: "Lotus cache misses"
)
]
endEvent Reference
For the complete list of events, measurements, and metadata fields, see
Lotus.Telemetry.