DDTrace.Integrations.Ecto (dd_trace_ex v0.1.0)

Copy Markdown View Source

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)
end

Each 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 the db.system appended, so a storefront service'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. :query is 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, an update_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)
end

A 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

setup(opts \\ [])

@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.