❮ Back to Blog

Tracking Ecto Queries and HTTP Calls in Scout APM for Elixir

If you’ve spent any time debugging slow endpoints in a Phoenix application, you know the pattern: something is slow, you open your APM, you see a big chunk of time attributed to “Ecto” or maybe just “Controller,” and then you go make coffee while you grep through logs trying to figure out what actually happened. With v2.0 of the Scout APM Elixir agent, we’re closing that gap. Ecto instrumentation now captures query command types and row counts, and we’ve added first-class support for tracking outbound HTTP calls through Finch, Req, and Tesla.

Both of these features share a theme: visibility into the things your application calls out to. Your code is the middle layer in a sandwich between users and dependencies. The bread is usually where the problems are.

Richer Ecto Query Metrics

Previously, our Ecto integration told you that a query happened and how long it took. That’s like a mechanic telling you “your car made a noise.” Helpful, but you’d really like to know which noise and where.

Now, every Ecto query span includes two additional tags:

  • db.command: the type of SQL operation (SELECT, INSERT, UPDATE, DELETE)
  • db.rows: the number of rows affected or returned

This happens automatically. The agent pulls these values straight from the Ecto result struct, so if you already have our EctoTelemetry integration attached, you’re done:

# In your Application.start/2 (you probably already have this)
:ok = ScoutApm.Instruments.EctoTelemetry.attach(MyApp.Repo)

No configuration changes, no version bumps to worry about. Deploy the updated agent and the data starts flowing.

Why Row Counts and Command Types Matter

Knowing that an endpoint spends 400ms in Ecto is a starting point. Knowing that it runs 47 SELECTs that each return 1 row is a diagnosis. That’s an N+1 query, and you can fix it with a preload.

On the other end of the spectrum, knowing that a single SELECT returns 10,000 rows tells you a different story. Maybe you need pagination, maybe you need a stricter WHERE clause, maybe your ORM is fetching an entire table when you only need a count.

Here’s what the agent extracts from each query result:

# Under the hood, we pull from the Ecto result:
def extract_result_info(%{result: {:ok, result}}) do
  command = Map.get(result, :command)   # :select, :insert, :update, :delete
  num_rows = Map.get(result, :num_rows) # integer row count
  {normalize_command(command), num_rows}
end

These values show up as span tags in Scout, so you can filter and sort by them. Looking for all endpoints that run DELETE operations? You can find them. Want to see which queries return the most rows? That’s there too.

External Service Instrumentation

Most production Elixir apps talk to external services. Payment processors, email providers, search APIs, webhooks, that internal microservice your team swears will be deprecated by Q3 (it won’t be). These HTTP calls are frequently the slowest part of a request, but they’ve historically been invisible in traces unless you manually wrapped them with timing code.

We now support automatic instrumentation for the two most popular HTTP client patterns in the Elixir ecosystem: Finch (which also covers Req) and Tesla.

Finch and Req

Finch is the HTTP client that powers Req, and it emits telemetry events that we hook into. One line in your application startup is all it takes:

def start(_type, _args) do
  ScoutApm.Instruments.FinchTelemetry.attach()

  children = [
    # your supervision tree...
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end

Every Finch HTTP request now appears as an HTTP/{method} span in your traces. Since Req uses Finch under the hood, all your Req.get! and Req.post! calls are automatically instrumented too. No changes to your Req code needed.

Each span captures:

  • Operation: HTTP/GET, HTTP/POST, etc.
  • URL: the full request URL (sanitized, more on that below)
  • Status code: the HTTP response status

Tesla

Tesla uses a middleware architecture, so it needs one extra step. You need to include the Tesla.Middleware.Telemetry plug in your client module:

defmodule MyApp.PaymentClient do
  use Tesla

  plug Tesla.Middleware.Telemetry
  plug Tesla.Middleware.BaseUrl, "https://api.stripe.com"
  plug Tesla.Middleware.JSON
end

Then attach our handler at startup:

ScoutApm.Instruments.TeslaTelemetry.attach()

One thing to note: place Tesla.Middleware.Telemetry as close to the end of your middleware stack as you can. Middleware executes top-to-bottom on the way out, so putting it near the top means the timing measurement wraps more of the actual HTTP work.

URL Sanitization

We strip query strings from URLs before sending them to Scout. Your GET https://api.example.com/users?token=secret123&page=2 becomes GET https://api.example.com/users. This is a deliberate decision. Query strings frequently contain API keys, tokens, session identifiers, and other values that have no business living in a monitoring tool. We’d rather lose some debugging convenience than accidentally store your credentials.

Self-Exclusion

The agent automatically filters out requests to Scout’s own endpoints (apm.scoutapp.com, errors.scoutapm.com, otlp.scoutotel.com, etc.). You won’t see monitoring overhead cluttering your traces. It’s a small thing, but noisy instrumentation data is surprisingly annoying when you’re trying to diagnose a real problem.

What Shows Up in Scout

External service calls are grouped by domain in Scout’s External Services view. You can see which third-party services are slowest, how frequently your app calls them, and drill into individual calls within request traces. Operations are named like HTTP/GET, HTTP/POST, and so on, so you can quickly identify what kind of work your app is doing for each service.

The Full Picture

With these additions, a trace for a typical Phoenix controller action can now show: controller time, Ecto queries with command types and row counts, HTTP calls to external services with URLs and status codes, template rendering, and LiveView lifecycle events. That’s the kind of breakdown that turns “this endpoint is slow” into “this endpoint calls Stripe twice, runs 47 SELECTs returning 1 row each, and then does a bulk INSERT of 500 rows.”

Combine that with Scout’s N+1 detection (which gets even more useful when it can see row counts) and error monitoring, and you have a fairly complete picture of where time goes in an Elixir application. We’re not done, but the gaps are getting smaller.

Getting Started

Update your scout_apm dependency to the latest version and add the attach calls to your Application.start/2:

def start(_type, _args) do
  # Ecto (you likely already have this)
  :ok = ScoutApm.Instruments.EctoTelemetry.attach(MyApp.Repo)

  # HTTP clients (add whichever you use)
  ScoutApm.Instruments.FinchTelemetry.attach()
  ScoutApm.Instruments.TeslaTelemetry.attach()

  children = [
    # ...
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end

If you’re not already using Scout APM with your Elixir application, our Elixir documentation walks through the full setup. The agent is lightweight, the integration is straightforward, and you can be looking at real trace data within a few minutes.

Ready to Optimize Your App?

Join engineering teams who trust Scout Monitoring for hassle-free performance monitoring. With our 3-step setup, powerful tooling, and responsive support, you can quickly identify and fix performance issues before they impact your users.