This guide covers the Elixir query API, all available filters, trace lookup, and query optimization.

Basic queries

# All spans (default limit 100, newest first)
{:ok, result} = TimelessTraces.query()

# Error spans
{:ok, result} = TimelessTraces.query(status: :error)

# Server spans from a specific service
{:ok, result} = TimelessTraces.query(kind: :server, service: "api-gateway")

# Slow spans (> 100ms)
{:ok, result} = TimelessTraces.query(min_duration: 100_000_000)

Result struct

Queries return a TimelessTraces.Result struct:

%TimelessTraces.Result{
  entries: [%TimelessTraces.Span{}, ...],
  total: 42,      # total matching spans (before pagination)
  limit: 100,     # max entries returned
  offset: 0       # entries skipped
}

Span struct

Each span in entries is a TimelessTraces.Span:

FieldTypeDescription
trace_idstring32-character hex trace ID
span_idstring16-character hex span ID
parent_span_idstring or nilParent span ID (nil for root spans)
namestringOperation name (e.g. "GET /users")
kindatom:internal, :server, :client, :producer, :consumer
start_timeintegerStart time in nanoseconds
end_timeintegerEnd time in nanoseconds
duration_nsintegerDuration in nanoseconds
statusatom:ok, :error, :unset
status_messagestring or nilError description
attributesmapSpan attributes (string keys)
eventslistSpan events
resourcemapOTel resource attributes (string keys)
instrumentation_scopemap or nil%{name: "...", version: "..."}

Filters

FilterTypeDescription
:namestringCase-insensitive substring match on span name
:servicestringExact match on service.name in attributes or resource
:kindatom:internal, :server, :client, :producer, :consumer
:statusatom:ok, :error, :unset
:min_durationintegerMinimum duration in nanoseconds
:max_durationintegerMaximum duration in nanoseconds
:sinceDateTime or integerStart time lower bound (DateTime or unix nanoseconds)
:untilDateTime or integerStart time upper bound (DateTime or unix nanoseconds)
:trace_idstringExact match on trace ID
:attributesmapAll key/value pairs must match (keys and values compared as strings)
:limitintegerMax results (default 100)
:offsetintegerSkip N results (default 0)
:orderatom:desc (newest first, default) or :asc (oldest first)

Examples

Time range queries

# Spans from the last hour
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600)
TimelessTraces.query(since: one_hour_ago)

# Spans in a specific window
TimelessTraces.query(
  since: ~U[2024-01-15 10:00:00Z],
  until: ~U[2024-01-15 11:00:00Z]
)

# Using nanosecond timestamps directly
TimelessTraces.query(since: 1705312200_000_000_000)

Duration queries

# Slow spans (> 500ms)
TimelessTraces.query(min_duration: 500_000_000)

# Spans in a duration range (100ms - 1s)
TimelessTraces.query(min_duration: 100_000_000, max_duration: 1_000_000_000)

Attribute queries

# Match specific attributes
TimelessTraces.query(attributes: %{"http.method" => "POST", "http.status_code" => "500"})

Pagination

# Page 1
{:ok, page1} = TimelessTraces.query(status: :error, limit: 50)

# Page 2
{:ok, page2} = TimelessTraces.query(status: :error, limit: 50, offset: 50)

Combined filters

TimelessTraces.query(
  service: "api-gateway",
  kind: :server,
  status: :error,
  min_duration: 100_000_000,
  since: DateTime.add(DateTime.utc_now(), -86400),
  limit: 20,
  order: :asc
)

Trace lookup

Retrieve all spans in a trace, sorted by start time:

{:ok, spans} = TimelessTraces.trace("abc123def456789012345678abcdef01")

This uses the trace index for fast lookup -- only blocks known to contain spans for that trace are read.

On disk, trace_index stores packed binary trace IDs for 32-character hex values to reduce index size. Lookups remain backward-compatible with older text rows during migration.

Service and operation discovery

# List all services
{:ok, services} = TimelessTraces.services()
# => {:ok, ["my_app", "api_gateway", "auth_service"]}

# List operations for a service
{:ok, ops} = TimelessTraces.operations("my_app")
# => {:ok, ["GET /users", "POST /orders", "DB query"]}

Query optimization

Use indexed filters

The following filters leverage the inverted term index, narrowing the set of blocks to read:

  • :service -- matches service:<name> terms
  • :kind -- matches kind:<kind> terms
  • :status -- matches status:<status> terms
  • :name -- matches name:<name> terms
  • :trace_id -- uses the trace index directly

Time range filters (:since, :until) narrow blocks by timestamp metadata.

Avoid full scans

Queries with no filters scan all blocks. On large datasets, always include at least one filter to leverage the index.

Duration and attribute filters

:min_duration, :max_duration, and :attributes are applied as in-memory filters after block decompression. They don't reduce the number of blocks read. Combine them with indexed filters for best performance.

Performance benchmarks

Query latency on 500K spans, 500 blocks (avg over 3 runs):

QueryzstdOpenZLSpeedup
All spans (limit 100)945ms442ms2.1x
status=error289ms148ms2.0x
service filter318ms243ms1.3x
kind=server275ms225ms1.2x
Trace lookup5.5ms5.7ms1.0x

OpenZL columnar format is faster at query time because columns can be selectively decompressed.