TimelessTraces User’s Guide
Setup
Mix.install(
[
{:timeless_traces, github: "awksedgreep/timeless_traces"},
{:opentelemetry_api, "~> 1.4"},
{:opentelemetry, "~> 1.5"}
],
config: [
opentelemetry: [traces_exporter: {TimelessTraces.Exporter, []}],
timeless_traces: [flush_interval: 5_000, compaction_threshold: 100]
]
)
Start the application:
Application.ensure_all_started(:timeless_traces)
Seed some trace data so the query examples below have something to return:
now = System.os_time(:nanosecond)
spans =
for i <- 1..50 do
trace_id = Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)
root_id = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
child_id = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
service = Enum.random(["api", "payments", "auth", "web"])
status = Enum.random([:ok, :ok, :ok, :error])
duration = :rand.uniform(500_000_000)
start = now - :rand.uniform(3_600_000_000_000)
[
%{
trace_id: trace_id,
span_id: root_id,
parent_span_id: nil,
name: "HTTP GET /#{service}/health",
kind: :server,
start_time: start,
end_time: start + duration,
duration_ns: duration,
status: status,
status_message: if(status == :error, do: "Internal error", else: nil),
attributes: %{"service.name" => service, "http.method" => "GET", "http.status_code" => if(status == :error, do: "500", else: "200")},
events: [],
resource: %{"service.name" => service},
instrumentation_scope: nil
},
%{
trace_id: trace_id,
span_id: child_id,
parent_span_id: root_id,
name: "db.query",
kind: :client,
start_time: start + 10_000,
end_time: start + duration - 10_000,
duration_ns: duration - 20_000,
status: :ok,
status_message: nil,
attributes: %{"service.name" => service, "db.system" => "postgresql"},
events: [],
resource: %{"service.name" => service},
instrumentation_scope: nil
}
]
end
|> List.flatten()
TimelessTraces.Buffer.ingest(spans)
Process.sleep(2_000)
TimelessTraces.flush()
Getting Started
TimelessTraces is an embedded OpenTelemetry span storage and compression library for Elixir. It plugs into the OTel SDK as a span exporter, stores spans locally with zstd or OpenZL compression (~10x), indexes them in SQLite, and exposes a Jaeger-compatible HTTP API for trace visualization.
Add the Dependency
# mix.exs
defp deps do
[
{:timeless_traces, "~> 0.5"},
{:opentelemetry_api, "~> 1.4"},
{:opentelemetry, "~> 1.5"}
]
end
Configure the Exporter
Tell the OpenTelemetry SDK to export spans to TimelessTraces:
# config/config.exs
config :opentelemetry,
traces_exporter: {TimelessTraces.Exporter, []}
Configuration Options
TimelessTraces is configured via application config. All options have sensible defaults:
| Option | Default | Description |
|---|---|---|
:data_dir |
"priv/span_stream" |
Directory for block files and SQLite index |
:storage |
:disk |
:disk or :memory (memory stores blocks as SQLite BLOBs) |
:flush_interval |
1_000 |
Buffer flush interval (ms) |
:max_buffer_size |
1_000 |
Spans before auto-flush |
:query_timeout |
30_000 |
Query operation timeout (ms) |
:retention_max_age |
604_800 |
Delete spans older than this (seconds, default 7 days) |
:retention_max_size |
536_870_912 |
Max total block size (bytes, default 512 MB) |
:retention_check_interval |
300_000 |
Retention check interval (ms, default 5 min) |
:compaction_threshold |
500 |
Min raw entries to trigger compaction |
:compaction_interval |
30_000 |
Compaction check interval (ms) |
:compaction_max_raw_age |
60 |
Force compact raw blocks older than this (seconds) |
:compaction_format |
:openzl |
Compression format: :zstd or :openzl |
:compression_level |
6 |
Compression level (1-22) |
:index_publish_interval |
2_000 |
ETS → SQLite batch flush interval (ms) |
:http |
false |
true, or [port: 10428, bearer_token: "secret"] |
Full Configuration Example
# config/config.exs
config :timeless_traces,
data_dir: "/var/data/traces",
storage: :disk,
flush_interval: 1_000,
max_buffer_size: 1_000,
retention_max_age: 30 * 86_400,
retention_max_size: 2 * 1_073_741_824,
compaction_format: :openzl,
compression_level: 6,
http: [port: 10428, bearer_token: "my-secret-token"]
config :opentelemetry,
traces_exporter: {TimelessTraces.Exporter, []}
Enable the HTTP API
# Defaults to port 10428, no auth
config :timeless_traces, http: true
# Custom port + bearer token auth
config :timeless_traces, http: [port: 10500, bearer_token: "secret"]
Instrumenting Your Code
With OpenTelemetry configured, use the standard API to create spans:
require OpenTelemetry.Tracer, as: Tracer
Tracer.with_span "process_order", %{attributes: %{"order.id" => "ord-123"}} do
# Your business logic here
Tracer.with_span "validate_payment" do
Process.sleep(10)
end
Tracer.with_span "update_inventory", %{attributes: %{"db.system" => "postgresql"}} do
Process.sleep(5)
end
end
All spans are automatically exported to TimelessTraces when they end.
Querying Traces (Elixir)
Search Spans
query/1 returns matching spans with pagination:
{:ok, result} = TimelessTraces.query(limit: 10)
Query Filters
All filters are optional and can be combined:
# By service name
{:ok, result} = TimelessTraces.query(service: "api")
# By span name (substring, case-insensitive)
{:ok, result} = TimelessTraces.query(name: "HTTP GET")
# By span kind
{:ok, result} = TimelessTraces.query(kind: :server)
# By status
{:ok, result} = TimelessTraces.query(status: :error)
# By duration range (nanoseconds)
{:ok, result} = TimelessTraces.query(
min_duration: 100_000_000,
max_duration: 1_000_000_000
)
# By time range
{:ok, result} = TimelessTraces.query(
since: DateTime.utc_now() |> DateTime.add(-3600, :second),
until: DateTime.utc_now()
)
# By trace ID
{:ok, result} = TimelessTraces.query(trace_id: "abc123def456...")
# By attributes
{:ok, result} = TimelessTraces.query(
attributes: %{"http.status_code" => "500"}
)
# Combined filters with pagination
{:ok, result} = TimelessTraces.query(
service: "payments",
status: :error,
min_duration: 100_000_000,
since: DateTime.utc_now() |> DateTime.add(-86_400, :second),
limit: 50,
offset: 0,
order: :desc
)
Filter Reference
| Filter | Type | Description |
|---|---|---|
:name |
string | Case-insensitive 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 (nanoseconds) |
:max_duration |
integer | Maximum duration (nanoseconds) |
:since |
DateTime or unix nanos | Lower time bound |
:until |
DateTime or unix nanos | Upper time bound |
:trace_id |
string | Filter to specific trace |
:attributes |
map | Exact key/value matches |
:limit |
integer | Max entries returned (default 100) |
:offset |
integer | Skip N entries (default 0) |
:order |
atom |
:asc (oldest first) or :desc (newest first, default) |
Retrieve a Full Trace
trace/1 returns all spans for a given trace ID, sorted by start time:
# Get a trace ID from a query result
{:ok, result} = TimelessTraces.query(status: :error, limit: 1)
[span | _] = result.entries
{:ok, spans} = TimelessTraces.trace(span.trace_id)
for s <- spans do
indent = if s.parent_span_id, do: " ", else: ""
duration_ms = s.duration_ns / 1_000_000
IO.puts("#{indent}#{s.name} (#{s.kind}) #{Float.round(duration_ms, 2)}ms [#{s.status}]")
end
Service and Operation Discovery
List all services that have reported spans:
{:ok, services} = TimelessTraces.services()
List all operation names for a service:
{:ok, ops} = TimelessTraces.operations("api")
Querying Traces (HTTP)
The HTTP API is Jaeger-compatible, so you can point the Jaeger UI directly at TimelessTraces.
List Services
curl "http://localhost:10428/select/jaeger/api/services"
{"data":["api","payments","auth","web"]}
List Operations
curl "http://localhost:10428/select/jaeger/api/services/api/operations"
{"data":["HTTP GET /api/health","db.query"]}
Search Traces
curl "http://localhost:10428/select/jaeger/api/traces?\
service=payments&\
operation=HTTP+GET&\
minDuration=100ms&\
limit=20"
Duration supports units: 100us, 1ms, 2s.
Time range uses microsecond timestamps:
curl "http://localhost:10428/select/jaeger/api/traces?\
service=api&\
start=1700000000000000&\
end=1700003600000000&\
limit=50"
Get a Single Trace
curl "http://localhost:10428/select/jaeger/api/traces/abc123def456..."
Response is Jaeger-compatible JSON with full span details.
OTLP JSON Ingest
Ingest spans via the OpenTelemetry Protocol (OTLP) JSON format:
curl -X POST http://localhost:10428/insert/opentelemetry/v1/traces \
-H "Content-Type: application/json" \
-d '{
"resourceSpans": [{
"resource": {
"attributes": [{"key": "service.name", "value": {"stringValue": "my-service"}}]
},
"scopeSpans": [{
"spans": [{
"traceId": "0af7651916cd43dd8448eb211c80319c",
"spanId": "b7ad6b7169203331",
"name": "HTTP GET /health",
"kind": 2,
"startTimeUnixNano": "1700000000000000000",
"endTimeUnixNano": "1700000000050000000",
"status": {"code": 1}
}]
}]
}]
}'
Jaeger UI Integration
To use the Jaeger UI with TimelessTraces, point Jaeger’s query service at the TimelessTraces HTTP API:
http://localhost:10428/select/jaeger
Real-Time Subscriptions
Subscribe to receive spans as they arrive, before they are buffered and flushed to disk:
{:ok, _pid} = TimelessTraces.subscribe(status: :error)
# Generate some spans to see them come through
Task.start(fn ->
Process.sleep(500)
spans = [
%{
trace_id: Base.encode16(:crypto.strong_rand_bytes(16), case: :lower),
span_id: Base.encode16(:crypto.strong_rand_bytes(8), case: :lower),
parent_span_id: nil,
name: "failing_request",
kind: :server,
start_time: System.os_time(:nanosecond),
end_time: System.os_time(:nanosecond) + 50_000_000,
duration_ns: 50_000_000,
status: :error,
status_message: "Connection refused",
attributes: %{"service.name" => "payments"},
events: [],
resource: %{"service.name" => "payments"},
instrumentation_scope: nil
}
]
TimelessTraces.Buffer.ingest(spans)
end)
receive do
{:timeless_traces, :span, span} ->
IO.puts("Got span: #{span.name} [#{span.status}] #{span.status_message}")
after
3_000 -> IO.puts("(no span received)")
end
Filter subscriptions by name, kind, status, or service:
# Only server spans from a specific service
{:ok, _pid} = TimelessTraces.subscribe(kind: :server, service: "payments")
Task.start(fn ->
Process.sleep(500)
TimelessTraces.Buffer.ingest([
%{
trace_id: Base.encode16(:crypto.strong_rand_bytes(16), case: :lower),
span_id: Base.encode16(:crypto.strong_rand_bytes(8), case: :lower),
parent_span_id: nil,
name: "payment_processed",
kind: :server,
start_time: System.os_time(:nanosecond),
end_time: System.os_time(:nanosecond) + 25_000_000,
duration_ns: 25_000_000,
status: :ok,
status_message: nil,
attributes: %{"service.name" => "payments"},
events: [],
resource: %{"service.name" => "payments"},
instrumentation_scope: nil
}
])
end)
receive do
{:timeless_traces, :span, span} ->
IO.puts("Got: #{span.name} (#{span.kind}) from #{span.attributes["service.name"]}")
after
3_000 -> IO.puts("(no span received)")
end
Unsubscribe when done:
TimelessTraces.unsubscribe()
Statistics
Elixir API
{:ok, stats} = TimelessTraces.stats()
Returns a %TimelessTraces.Stats{} struct with fields:
| Field | Description |
|---|---|
total_blocks |
Total block count |
total_entries |
Total spans stored |
total_bytes |
Total block data bytes |
disk_size |
On-disk storage size |
index_size |
SQLite index size |
oldest_timestamp |
Oldest span timestamp (nanoseconds) |
newest_timestamp |
Newest span timestamp (nanoseconds) |
raw_blocks / raw_bytes / raw_entries |
Uncompressed block stats |
zstd_blocks / zstd_bytes / zstd_entries |
Zstd-compressed block stats |
openzl_blocks / openzl_bytes / openzl_entries |
OpenZL-compressed block stats |
compression_raw_bytes_in |
Total bytes before compression |
compression_compressed_bytes_out |
Total bytes after compression |
compaction_count |
Number of compaction runs |
HTTP API
curl "http://localhost:10428/health"
{"status":"ok","blocks":48,"spans":125000,"disk_size":24000000}
Operations
Flush
Force all buffered spans to disk immediately:
TimelessTraces.flush()
Via HTTP:
curl "http://localhost:10428/api/v1/flush"
{"status":"ok"}
Backup
Create a consistent online backup without stopping the application:
{:ok, result} = TimelessTraces.backup("/tmp/traces_backup")
# result => %{path: "/tmp/traces_backup", files: ["index.db", ...], total_bytes: 24000000}
Via HTTP:
curl -X POST http://localhost:10428/api/v1/backup \
-H "Content-Type: application/json" \
-d '{"path": "/tmp/traces_backup"}'
{"status":"ok","path":"/tmp/traces_backup","files":["index.db","blocks"],"total_bytes":24000000}
To download the backup, archive it from the server filesystem:
tar czf traces_backup.tar.gz -C /tmp/traces_backup .
To restore, stop the application, replace the data_dir contents with the
backup files, and restart.
Authentication
All endpoints except /health support optional Bearer token authentication
when configured:
# Via header
curl -H "Authorization: Bearer my-secret-token" \
"http://localhost:10428/select/jaeger/api/services"
# Via query parameter
curl "http://localhost:10428/select/jaeger/api/traces?service=api&token=my-secret-token"
Telemetry
TimelessTraces emits telemetry events for monitoring integration:
| Event | Measurements | Metadata |
|---|---|---|
[:timeless_traces, :flush, :stop] |
duration, entry_count, byte_size |
block_id |
[:timeless_traces, :query, :stop] |
duration, total, blocks_read |
filters |
[:timeless_traces, :compaction, :stop] |
duration, raw_blocks, entry_count, byte_size |
— |
[:timeless_traces, :retention, :stop] |
duration, blocks_deleted |
— |
[:timeless_traces, :block, :error] |
— |
file_path, reason |
Attach a handler to monitor query performance:
:telemetry.attach("trace-query-monitor", [:timeless_traces, :query, :stop], fn _event, measurements, metadata, _config ->
require Logger
Logger.info("Query took #{measurements.duration}ns, scanned #{measurements.blocks_read} blocks, found #{measurements.total} spans")
end, nil)