Traces tell you where time went. Logs tell you why. The join keyword is what lets one query answer both at once instead of two browser tabs.

A trace shows a slow span. A log line shows an error message. Neither alone tells the full story, and pivoting between two separate query tabs by hand, copying a trace ID across, is slow enough that most engineers stop doing it under pressure. HelixQL joins the two signals directly.

1. Join on trace_id

Because the SDK stamps every log line emitted during a traced request with the active trace_id, joining logs to traces is a matter of naming the shared field, no manual correlation table required.

from traces
| where service.name == "checkout-api"
| where status_code >= 500
| join logs on trace_id
| project trace_id, route, log.message, log.level

2. Filter before the join, not after

Joins are the most expensive operation in HelixQL, because they scan two signal types instead of one. Narrowing the trace side with where clauses before the join keeps the join itself small, filtering afterward means the engine already paid the cost of joining rows it is about to discard.

from traces
| where service.name == "checkout-api"
| where duration > 1s
| where start_time > now() - 1h
| join logs on trace_id
| where log.level == "error"

3. Widen the join key when trace_id alone misses context

Some errors happen just outside the traced request, a startup failure, a background job with no active trace. For those, join on service.name and a time window instead of a trace ID, trading precision for coverage.

from traces
| where service.name == "checkout-api" and status_code >= 500
| join logs on service.name, time_window(30s)

4. Save the shape as a saved query

This join is the single query most on-call engineers run first during an incident. Saving it once, parameterized by service name, turns a query anyone has to remember how to write into a one-click action from the alert itself.

  • Join on trace_id to pull the log lines behind a specific slow or failed request.
  • Filter before the join, not after, to keep the join itself cheap.
  • Fall back to a time-window join when there is no active trace to key on.
  • Save the join as a reusable, parameterized query linked from the alert.