Timeless

Embedded OpenTelemetry Span Storage for Elixir

Hex.pm Docs License


"I found it ironic that the first thing you do to time series data is squash the timestamp. That's how the name Timeless was born." --Mark Cotner

Embedded OpenTelemetry span storage and compression for Elixir applications.

TimelessTraces receives spans directly from the OpenTelemetry Erlang SDK (no HTTP, no protobuf), compresses them with two-tier raw/OpenZL block storage (~10x compression), and indexes them in ETS for lock-free trace-level and span-level queries. The trace index stores packed binary trace IDs on disk to keep the index compact while remaining backward-compatible with legacy text rows. Zero external infrastructure required.

Part of the embedded observability stack:

  • timeless_metrics - numeric time series compression
  • timeless_logs - log ingestion/compression/indexing
  • timeless_traces - OTel span storage/compression (this library)

Documentation

Installation

def deps do
  [
    {:timeless_traces, "~> 1.0"}
  ]
end

Configuration

# config/config.exs
config :timeless_traces,
  storage: :disk,              # :disk or :memory
  data_dir: "priv/span_stream",
  flush_interval: 1_000,       # ms between auto-flushes
  max_buffer_size: 1_000,      # spans before forced flush
  compaction_threshold: 500,   # raw entries before compaction
  compaction_format: :openzl,  # :openzl (default) or :zstd
  compression_level: 6,        # compression level 1-22 (default 6)
  retention_max_age: nil,      # seconds, nil = no age limit
  retention_max_size: nil      # bytes, nil = no size limit

# Wire up the OTel exporter
config :opentelemetry,
  traces_exporter: {TimelessTraces.Exporter, []}

Usage

Querying spans

# All error spans
TimelessTraces.query(status: :error)

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

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

# Combined filters with pagination
TimelessTraces.query(status: :error, kind: :server, limit: 50, order: :desc)

Trace lookup

# Get all spans in a trace, sorted by start time
{:ok, spans} = TimelessTraces.trace("abc123def456...")

Live tail

# Subscribe to new spans as they arrive
TimelessTraces.subscribe(status: :error)

receive do
  {:timeless_traces, :span, %TimelessTraces.Span{} = span} ->
    IO.inspect(span.name)
end

Statistics

{:ok, stats} = TimelessTraces.stats()
stats.total_blocks   #=> 42
stats.total_entries   #=> 50_000
stats.disk_size       #=> 24_000_000

Query Filters

FilterTypeDescription
:namestringSubstring match on span name
:servicestringMatch 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
:sinceinteger/DateTimeStart time lower bound (nanos or DateTime)
:untilinteger/DateTimeStart time upper bound (nanos or DateTime)
:trace_idstringFilter to specific trace
:attributesmapKey/value pairs to match
:limitintegerMax results (default 100)
:offsetintegerSkip N results (default 0)
:orderatom:desc (default) or :asc

Architecture

OTel SDK  Exporter  Buffer  Writer (raw)  ETS Index  disk log
                                    
                              Compactor (OpenZL/zstd)
  • Buffer accumulates spans, flushes every 1s or 1000 spans
  • Writer serializes blocks as raw Erlang terms initially
  • Index stores block metadata + inverted term index + trace index in ETS, persisted via snapshots + disk log
  • Compactor merges raw blocks into compressed blocks (zstd or OpenZL columnar)
  • Retention enforces age and size limits
  • Sync flushes pending index batches and runs wal_checkpoint(TRUNCATE) to compact the index DB and WAL together

Storage Modes

  • :disk - Blocks as files in data_dir/blocks/, index persisted as index.snapshot + index.log
  • :memory - Blocks in ETS tables only, no filesystem needed

Compression

Two compression backends are supported. OpenZL columnar compression (default) achieves better ratios and faster queries by encoding span fields in typed columns:

BackendSize (500K spans)RatioCompressDecompress
zstd32.8 MB6.8x2.1s1.5s
OpenZL columnar22.1 MB10.2x1.9s564ms

Performance

Run on a 28-core laptop. Reproduce with mix timeless_traces.ingest_benchmark, mix timeless_traces.compression_benchmark, and mix timeless_traces.search_benchmark.

Ingestion throughput on 500K spans (1000 spans/block):

PhaseThroughput
Writer only (serialization + disk I/O)~196K spans/sec
Writer + Index (ETS immediate + disk log persist)~468K spans/sec
Full pipeline (Buffer → Writer → async Index)~303K spans/sec

The ETS-first indexing architecture makes index overhead negligible.

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

QueryzstdOpenZLSpeedup
All spans (limit 100)1.12s860ms1.3x
status=error327ms158ms2.1x
service filter316ms225ms1.4x
kind=server324ms226ms1.4x
Trace lookup7.1ms10.0ms0.7x

License

MIT - see LICENSE