Command Palette

Search for a command to run...

Hectal
Case 3.1·Tracing Deep DiveSEV3

“The product page gets slower every time the catalogue team adds products. No single query is slow.”

case name: Death by a thousand queries

Service
shoplite-api · GET /products
Impact
/products p99 grew from 40 ms to 380 ms as the catalogue grew to 200 products
Detected by
Weekly latency review
Time to resolve
1 h (slow-burn)

Skills you'll use on this case

reading trace shapeTraceQL count() and structural queriesspan metrics from TempoN+1 queries

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ docker compose exec postgres psql -U postgres -d shoplite -c "\
insert into products select g, 'Product ' || g, 100 + g from generate_series(4, 200) g;\
insert into stock select g, 100 from generate_series(4, 200) g;"
curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"n-plus-one"}'
── lab output ──
INSERT 0 197
INSERT 0 197
{"active":["n-plus-one"]}

01 The investigation

  1. 10:00

    REPORT

    Weekly review: /products p99 has grown every week for a month

    /products p99 (ms), weeklyp99
    0.002184374 weeks agonow
  2. 10:08

    DEAD END

    Postgres slow-query log (Case 2.4): nothing over 1 ms

    Every query is fast. The problem isn't any single query, it's HOW MANY there are. Neither a slow-query log nor the database metrics can show that.

  3. 10:14

    QUERY

    Find /products traces with lots of database spans

    trace:rootName is an intrinsic: the name of the trace's root span. | count() > 50 keeps traces where more than 50 spans match the selector. That's a question about trace SHAPE, which only traces can answer.

    TraceQL· Tempo
    { trace:rootName = "GET /products" && span.db.system = "postgresql" } | count() > 50
    result
    Trace ID                          Root span       Matched spans  Duration
    6d1e0c2b9a7f4e3c8b5a2d1f0e9c8b7a  GET /products   201            352ms
    ...
  4. 10:16

    FINDING

    One list query, then one stock query PER product, one after another

    The waterfall is unmistakable: a staircase of tiny identical spans. Each takes ~1.5 ms including the network round trip, and 200 of them in sequence is 300 ms. It grows linearly with the catalogue.

    GET /products (200 products)352 ms totalshoplite
    GET /productsshoplite
    352 ms
    pg.query:SELECT productsshoplite
    3 ms
    pg.query:SELECT stock (1)shoplite
    2 ms
    pg.query:SELECT stock (2)shoplite
    2 ms
    pg.query:SELECT stock (3)shoplite
    1 ms
    … 196 more identical spans …shoplite
    336 ms
    pg.query:SELECT stock (200)shoplite
    2 ms
  5. 11:02

    RESOLVED

    Replaced with one JOIN: 2 queries total, p99 back to 35 ms

Root cause

The N+1 query pattern: fetch a list (1 query), then fetch related data for each item (N queries), sequentially. Each query was fast, so query-level monitoring saw nothing; the cost was round trips multiplied by catalogue size. The trace's shape, hundreds of identical sibling spans, made it obvious.

02 The concepts behind it

Trace shape is information

Beyond 'which span is longest', the SHAPE of a trace tells a story: a staircase of identical siblings means N+1 or missing batching; a wide fan of parallel calls means fan-out (and a latency floor set by the slowest one); gaps between spans mean time in your own code (add manual spans); repeated sibling calls to the same dependency mean retries (Case 3.2).

TraceQL beyond attribute matching

Spanset pipelines: { ... } | count() > 50, | avg(duration) > 100ms. Structural operators: { A } >> { B } (B is a descendant of A), { A } > { B } (direct child), { A } ~ { B } (siblings). Intrinsics: name, duration, status, kind, trace:rootName, trace:duration. These let you search for PATTERNS across millions of traces, not just for attribute values.

Span metrics: metrics generated from traces

Tempo's metrics-generator (and the OTel Collector's spanmetrics connector) turns every span into RED metrics: call counts and duration histograms per service, span name, and kind, written to Prometheus. You get metrics for things nobody instrumented explicitly, such as 'database calls per second from shoplite', including the ratio that exposes N+1 continuously.

03 The fix

  1. 01One query instead of N+1

    A JOIN fetches everything in one round trip. For ORMs, the equivalent is eager loading (include, JOIN FETCH, select_related/prefetch_related) or a DataLoader-style batch. Switch the scenario off; the lab's normal path has no N+1.

    obs-lab/shoplite/server.jsadd to filejs
    const { rows } = await db.query(
      "select p.id, p.name, p.price, s.qty as stock from products p left join stock s on s.product_id = p.id order by p.id",
    );
    terminal
    $ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"n-plus-one","enabled":false}'
    ── expected output ──
    {"active":[]}

04 Make sure it never surprises you again

  1. 01Turn on Tempo's span metrics

    Tempo remote-writes generated metrics into Prometheus, which must accept remote writes: add --web.enable-remote-write-receiver to Prometheus's command. The service-graphs processor also produces the service dependency map Grafana draws.

    obs-lab/tempo/tempo.ymladd to fileyaml
    metrics_generator:
      registry:
        external_labels: { source: tempo }
      storage:
        path: /var/tempo/generator/wal
        remote_write:
          - url: http://prometheus:9090/api/v1/write
            send_exemplars: true
    
    overrides:
      defaults:
        metrics_generator:
          processors: [service-graphs, span-metrics]
  2. 02Database calls per request — graph it, alert on a jump

    Normally ~1.3 for ShopLite (1 per product listing, 2 per checkout). N+1 shows up immediately as this ratio climbing with catalogue size.

    PromQL· Prometheus
    sum(rate(traces_spanmetrics_calls_total{service="shoplite", span_kind="SPAN_KIND_CLIENT", span_name=~"pg.query.*"}[5m]))
    /
    sum(rate(http_request_duration_seconds_count{job="shoplite"}[5m]))
    result
    {}  1.31

05 Your turn: write the query

Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.

01

Write a TraceQL query for checkout traces where the payments call is a DESCENDANT of the checkout span and took over 500 ms.

02

Using span metrics, write the p95 duration of every span name in the payments service.

06 Interview questions from this case

01

What is the N+1 query problem and how do you detect it?

02

What are span metrics and why are they useful?

0/4 · 0%