Skip to content

Embedded OpenTelemetry span storage and compression for Elixir

License

Notifications You must be signed in to change notification settings

awksedgreep/timeless_traces

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

40 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TimelessTraces

"I always found it odd 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 SQLite for fast trace-level and span-level queries. 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

Filter Type Description
:name string Substring match on span name
:service string Match service.name in attributes or resource
:kind atom :internal, :server, :client, :producer, :consumer
:status atom :ok, :error, :unset
:min_duration integer Minimum duration in nanoseconds
:max_duration integer Maximum duration in nanoseconds
:since integer/DateTime Start time lower bound (nanos or DateTime)
:until integer/DateTime Start time upper bound (nanos or DateTime)
:trace_id string Filter to specific trace
:attributes map Key/value pairs to match
:limit integer Max results (default 100)
:offset integer Skip N results (default 0)
:order atom :desc (default) or :asc

Architecture

OTel SDK → Exporter → Buffer → Writer (raw) → SQLite Index
                                    ↓
                              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 SQLite
  • Compactor merges raw blocks into compressed blocks (zstd or OpenZL columnar)
  • Retention enforces age and size limits

Storage Modes

  • :disk - Blocks as files in data_dir/blocks/, index in data_dir/index.db
  • :memory - Blocks as BLOBs in SQLite :memory:, 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:

Backend Size (500K spans) Ratio Compress Decompress
zstd 32.8 MB 6.8x 2.0s 1.1s
OpenZL columnar 22.3 MB 10.0x 2.0s 2.3s

Performance

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

Phase Throughput
Writer only (serialization + disk I/O) ~131K spans/sec
Writer + Index (sync SQLite indexing) ~38K spans/sec
Full pipeline (Buffer → Writer → async Index) ~115K spans/sec

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

Query zstd OpenZL Speedup
All spans (limit 100) 945ms 442ms 2.1x
status=error 289ms 148ms 2.0x
service filter 318ms 243ms 1.3x
kind=server 275ms 225ms 1.2x
Trace lookup 5.5ms 5.7ms 1.0x

License

MIT - see LICENSE

About

Embedded OpenTelemetry span storage and compression for Elixir

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages