Current section
Files
Jump to
Current section
Files
lib/mix/tasks/http_benchmark.ex
defmodule Mix.Tasks.TimelessTraces.HttpBenchmark do
@moduledoc "Benchmark HTTP ingest and query throughput via Rocket"
use Mix.Task
@shortdoc "Benchmark HTTP endpoint throughput (ingest + query)"
@port 10_499
@warmup_requests 200
@bench_duration_sec 5
@impl true
def run(_args) do
data_dir = "http_bench_#{System.unique_integer([:positive])}"
File.mkdir_p!(Path.join(data_dir, "blocks"))
Application.put_env(:timeless_traces, :data_dir, data_dir)
Application.put_env(:timeless_traces, :storage, :memory)
Application.put_env(:timeless_traces, :flush_interval, 60_000)
Application.put_env(:timeless_traces, :http, false)
Mix.Task.run("app.start")
:inets.start()
# Start HTTP server
spec = TimelessTraces.HTTP.child_spec(port: @port)
{:ok, _} = Supervisor.start_link([spec], strategy: :one_for_one)
IO.puts("=== TimelessTraces HTTP Benchmark (Rocket) ===")
IO.puts(" Port: #{@port}")
IO.puts(" Duration per phase: #{@bench_duration_sec}s")
IO.puts("")
# --- Phase 1: Health endpoint (baseline, no processing) ---
IO.puts("--- Phase 1: GET /health (baseline) ---")
health_rps = bench_get("/health")
IO.puts(" #{fmt_number(health_rps)} req/sec\n")
# --- Phase 2: OTLP JSON ingest (single span per request) ---
IO.puts("--- Phase 2: POST OTLP ingest (1 span/req) ---")
single_body = otlp_body(1)
ingest_1_rps = bench_post("/insert/opentelemetry/v1/traces", single_body, "application/json")
IO.puts(" #{fmt_number(ingest_1_rps)} req/sec (#{fmt_number(ingest_1_rps)} spans/sec)\n")
# --- Phase 3: OTLP JSON ingest (10 spans per request) ---
IO.puts("--- Phase 3: POST OTLP ingest (10 spans/req) ---")
batch_body = otlp_body(10)
ingest_10_rps = bench_post("/insert/opentelemetry/v1/traces", batch_body, "application/json")
IO.puts(
" #{fmt_number(ingest_10_rps)} req/sec (#{fmt_number(ingest_10_rps * 10)} spans/sec)\n"
)
# --- Phase 4: OTLP JSON ingest (100 spans per request) ---
IO.puts("--- Phase 4: POST OTLP ingest (100 spans/req) ---")
big_body = otlp_body(100)
ingest_100_rps = bench_post("/insert/opentelemetry/v1/traces", big_body, "application/json")
IO.puts(
" #{fmt_number(ingest_100_rps)} req/sec (#{fmt_number(ingest_100_rps * 100)} spans/sec)\n"
)
# --- Phase 5: Concurrent ingest (multiple connections) ---
IO.puts("--- Phase 5: Concurrent ingest (8 workers, 10 spans/req) ---")
conc_rps =
bench_concurrent_post("/insert/opentelemetry/v1/traces", batch_body, "application/json", 8)
IO.puts(" #{fmt_number(conc_rps)} req/sec (#{fmt_number(conc_rps * 10)} spans/sec)\n")
# Reset state for query benchmarks with small dataset
Application.stop(:timeless_traces)
Application.ensure_all_started(:timeless_traces)
seed_query_data(50)
TimelessTraces.flush()
# --- Phase 6: Jaeger services query ---
IO.puts("--- Phase 6: GET /select/jaeger/api/services ---")
svc_rps = bench_get("/select/jaeger/api/services")
IO.puts(" #{fmt_number(svc_rps)} req/sec\n")
# --- Phase 7: Jaeger trace search ---
IO.puts("--- Phase 7: GET /select/jaeger/api/traces?service=bench-svc&limit=10 ---")
search_rps = bench_get("/select/jaeger/api/traces?service=bench-svc&limit=10")
IO.puts(" #{fmt_number(search_rps)} req/sec\n")
# --- Phase 8: Jaeger get trace ---
IO.puts("--- Phase 8: GET /select/jaeger/api/traces/:trace_id ---")
trace_rps = bench_get("/select/jaeger/api/traces/benchtraceabcdef01")
IO.puts(" #{fmt_number(trace_rps)} req/sec\n")
# Summary
IO.puts("=== Summary ===")
IO.puts(" Health (baseline): #{fmt_number(health_rps)} req/sec")
IO.puts(" Ingest 1 span/req: #{fmt_number(ingest_1_rps)} req/sec")
IO.puts(
" Ingest 10 spans/req: #{fmt_number(ingest_10_rps)} req/sec (#{fmt_number(ingest_10_rps * 10)} spans/sec)"
)
IO.puts(
" Ingest 100 spans/req: #{fmt_number(ingest_100_rps)} req/sec (#{fmt_number(ingest_100_rps * 100)} spans/sec)"
)
IO.puts(
" Concurrent ingest (8x): #{fmt_number(conc_rps)} req/sec (#{fmt_number(conc_rps * 10)} spans/sec)"
)
IO.puts(" Services query: #{fmt_number(svc_rps)} req/sec")
IO.puts(" Trace search: #{fmt_number(search_rps)} req/sec")
IO.puts(" Get trace: #{fmt_number(trace_rps)} req/sec")
Application.stop(:timeless_traces)
File.rm_rf!(data_dir)
end
defp seed_query_data(count) do
now = System.system_time(:nanosecond)
spans =
Enum.map(1..count, fn i ->
%{
trace_id: "benchtraceabcdef01",
span_id: "span#{String.pad_leading("#{i}", 12, "0")}",
parent_span_id: if(i == 1, do: nil, else: "span#{String.pad_leading("1", 12, "0")}"),
name: "bench-op-#{i}",
kind: :server,
start_time: now - i * 1_000_000,
end_time: now - i * 1_000_000 + 50_000_000,
duration_ns: 50_000_000,
status: :ok,
status_message: nil,
attributes: %{"service.name" => "bench-svc", "http.method" => "GET"},
events: [],
resource: %{"service.name" => "bench-svc"},
instrumentation_scope: nil
}
end)
TimelessTraces.Buffer.ingest(spans)
end
defp bench_get(path) do
url = ~c"http://127.0.0.1:#{@port}#{path}"
http_opts = [timeout: 30_000, connect_timeout: 5_000]
req_opts = [body_format: :binary]
# Warmup
for _ <- 1..@warmup_requests do
{:ok, _} = :httpc.request(:get, {url, []}, http_opts, req_opts)
end
# Timed run
deadline = System.monotonic_time(:millisecond) + @bench_duration_sec * 1000
count =
run_until(deadline, 0, fn ->
{:ok, _} = :httpc.request(:get, {url, []}, http_opts, req_opts)
end)
round(count / @bench_duration_sec)
end
defp bench_post(path, body, content_type) do
url = ~c"http://127.0.0.1:#{@port}#{path}"
ct = to_charlist(content_type)
http_opts = [timeout: 30_000, connect_timeout: 5_000]
req_opts = [body_format: :binary]
# Warmup
for _ <- 1..@warmup_requests do
{:ok, _} = :httpc.request(:post, {url, [], ct, body}, http_opts, req_opts)
end
# Timed run
deadline = System.monotonic_time(:millisecond) + @bench_duration_sec * 1000
count =
run_until(deadline, 0, fn ->
{:ok, _} = :httpc.request(:post, {url, [], ct, body}, http_opts, req_opts)
end)
round(count / @bench_duration_sec)
end
defp bench_concurrent_post(path, body, content_type, workers) do
url = ~c"http://127.0.0.1:#{@port}#{path}"
ct = to_charlist(content_type)
http_opts = [timeout: 30_000, connect_timeout: 5_000]
req_opts = [body_format: :binary]
# Warmup
for _ <- 1..@warmup_requests do
{:ok, _} = :httpc.request(:post, {url, [], ct, body}, http_opts, req_opts)
end
parent = self()
deadline = System.monotonic_time(:millisecond) + @bench_duration_sec * 1000
pids =
for _ <- 1..workers do
spawn_link(fn ->
count =
run_until(deadline, 0, fn ->
{:ok, _} = :httpc.request(:post, {url, [], ct, body}, http_opts, req_opts)
end)
send(parent, {:done, count})
end)
end
total =
Enum.reduce(pids, 0, fn _pid, acc ->
receive do
{:done, count} -> acc + count
end
end)
round(total / @bench_duration_sec)
end
defp run_until(deadline, count, fun) do
if System.monotonic_time(:millisecond) >= deadline do
count
else
fun.()
run_until(deadline, count + 1, fun)
end
end
defp otlp_body(span_count) do
spans =
Enum.map(1..span_count, fn i ->
ts = 1_700_000_000_000_000_000 + i * 1_000_000
%{
"traceId" => random_hex(16),
"spanId" => random_hex(8),
"parentSpanId" => "",
"name" => "bench-op-#{i}",
"kind" => 2,
"startTimeUnixNano" => "#{ts}",
"endTimeUnixNano" => "#{ts + 50_000_000}",
"status" => %{"code" => 1},
"attributes" => [
%{"key" => "http.method", "value" => %{"stringValue" => "GET"}},
%{"key" => "http.status_code", "value" => %{"intValue" => 200}}
],
"events" => []
}
end)
%{
"resourceSpans" => [
%{
"resource" => %{
"attributes" => [
%{"key" => "service.name", "value" => %{"stringValue" => "bench-svc"}}
]
},
"scopeSpans" => [
%{
"scope" => %{"name" => "bench", "version" => "1.0"},
"spans" => spans
}
]
}
]
}
|> :json.encode()
|> IO.iodata_to_binary()
end
defp random_hex(n), do: :crypto.strong_rand_bytes(n) |> Base.encode16(case: :lower)
defp fmt_number(n) when is_float(n), do: fmt_number(round(n))
defp fmt_number(n) when n >= 1_000_000,
do: "#{:erlang.float_to_binary(n / 1_000_000, decimals: 1)}M"
defp fmt_number(n) when n >= 1_000, do: "#{:erlang.float_to_binary(n / 1_000, decimals: 1)}K"
defp fmt_number(n), do: "#{n}"
end