Traces every Ecto query, from one call per repo at startup.
# lib/my_app/application.ex
def start(_type, _args) do
DDTrace.Integrations.Ecto.setup(repo: MyApp.Repo)
children = [MyApp.Repo, MyAppWeb.Endpoint, ...]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
endEach query becomes one "postgresql.query" span — the adapter names it —
with the SQL as its resource, span.type "sql", the connection's
db.* tags, and Ecto's own queue/query/decode breakdown as numeric
metrics. The span joins whatever trace the query ran inside: a Phoenix
request, a DDTrace.trace/2 block, a decorated function. A query that
runs outside any trace roots one of its own.
setup/1 derives everything else from the repo — the telemetry event,
the adapter, the connection tags — so there is nothing to configure and
nothing to change in the repo module or at any call site. It may be
called before the repo starts.
The resource is the raw SQL
The span's resource is the statement Ecto sent, placeholders intact:
SELECT u0."id", u0."email" FROM "users" AS u0 WHERE (u0."id" = ?)The Datadog agent obfuscates and quantizes SQL resources itself, which is how every official tracer feeds it, and how queries group into one resource in the UI rather than one per parameter value. Bind parameters are never attached to a span, by any tag or metric: row values do not leave the database.
What lands on the span
db.system names the adapter ("postgresql", "mysql", "sqlite",
"mssql", or "other_sql"), and the span's name and default service
follow it: "postgresql.query", served by "myapp-postgresql".
db.name, db.user and out.host come from the repo's own
configuration, read once at setup, and the port comes from it as the
numeric metric network.destination.port. A key the configuration does
not have is one that is not written, so a sqlite repo gets a db.name
and no host or port.
The timing breakdown arrives as metrics, in milliseconds:
ecto.queue_time_ms is how long the query waited for a connection,
ecto.query_time_ms how long the database took, and
ecto.decode_time_ms how long decoding the result took. Pool
starvation, slow SQL and expensive decoding are three different
problems and this is what tells them apart. db.result.rows_affected
carries the row count. A measurement Ecto did not take is a metric that
is not written.
Options
:repo— the repo module. Required; everything derives from it.:service— the service name for query spans. Defaults to the tracer's service with thedb.systemappended, so astorefrontservice's Postgres queries are"storefront-postgresql", and the database shows up in the service map as its own node.:filter— a one-argument function taking the event's metadata map and returning whether to trace the query. True means trace. A query it rejects produces no span at all. Migrations, health checks and pollers are what this is for. Defaults to tracing everything.:hooks— customization points, as a keyword list.:queryis the only one: a one-argument function receiving the same metadata map, run with the query span current, after every standard tag. It customizes through the ambient mutators, and its return value is discarded. Because the resource is already set when the hook runs, anupdate_span(resource: ...)in it wins.
DDTrace.Integrations.Ecto.setup(
repo: MyApp.Repo,
filter: &(&1.source != "schema_migrations"),
hooks: [query: &MyApp.Tracing.customize/1]
)
def customize(metadata) do
DDTrace.set_tag("db.table", metadata.source)
endA filter or hook that raises is logged and survived: the query is unaffected and the integration keeps tracing. A raising filter traces the query rather than dropping it.
Queries Ecto runs in parallel
Preloading two or more associations at once makes Ecto run their
queries in Tasks, which have no trace of their own. Those spans are
attached anyway, by looking one level up the process that spawned them
— so a preload-heavy endpoint shows its database time instead of
silently losing it.
Nested preloads flatten: every preload query is a sibling under the span that started the preload, however deep the association tree goes. Ecto's telemetry carries no nesting for the spans to follow.
Two repos, and repos that move
Two repos are two setup/1 calls, each with its own filter and hooks.
Calling setup/1 twice for the same repo is a quiet :ok, so a
supervision tree that restarts never crashes on it.
Dynamic repos — the same module started more than once under different
names — are all traced by the same handler, and all carry the
connection tags of the configuration setup/1 read. A dynamic instance
pointed at a different host is therefore tagged with the host from
config/0, not its own.
Transactions and migrations
begin, commit and rollback arrive as ordinary query events and
are traced as ordinary spans, resourced by that statement. So are
migrations: filter: is what excludes them.
Summary
Functions
Attaches the handler that traces this repo's queries. Call once per repo, at startup.
Functions
@spec setup(keyword()) :: :ok
Attaches the handler that traces this repo's queries. Call once per repo, at startup.
Returns :ok, including when the repo is already attached and when the
options make no sense — an integration that refused to start would take
the application down with it, and the point of tracing is that it
cannot.
See the module documentation for the options.