Current section
Files
Jump to
Current section
Files
lib/tripswitch/flusher.ex
defmodule Tripswitch.Flusher do
@moduledoc false
# Collects samples in a buffer and sends them to the Tripswitch ingest
# endpoint in gzip-compressed, HMAC-signed batches.
#
# Flushes when: buffer reaches 500 samples OR 15 seconds elapse.
# On shutdown (terminate/2), performs a final synchronous flush.
use GenServer
require Logger
alias Tripswitch.{Config, Naming}
@batch_size 500
@flush_interval_ms 15_000
@retry_delays_ms [100, 400, 1_000]
defstruct [
:config,
batch: [],
dropped_samples: 0,
flush_failures: 0,
last_successful_flush: nil
]
# ---------------------------------------------------------------------------
# Client API
# ---------------------------------------------------------------------------
def start_link(config) do
GenServer.start_link(__MODULE__, config, name: Naming.flusher(config.name))
end
def enqueue(client_name, sample) do
GenServer.cast(Naming.flusher(client_name), {:enqueue, sample})
end
def stats(client_name) do
GenServer.call(Naming.flusher(client_name), :stats)
end
# ---------------------------------------------------------------------------
# Server callbacks
# ---------------------------------------------------------------------------
@impl true
def init(config) do
schedule_flush()
{:ok, %__MODULE__{config: config}}
end
@impl true
def handle_cast({:enqueue, sample}, state) do
batch = [sample | state.batch]
if length(batch) >= @batch_size do
send_batch(batch, state.config)
{:noreply, %{state | batch: []}}
else
{:noreply, %{state | batch: batch}}
end
end
@impl true
def handle_call(:stats, _from, state) do
stats = %{
dropped_samples: state.dropped_samples,
buffer_size: length(state.batch),
flush_failures: state.flush_failures,
last_successful_flush: state.last_successful_flush
}
{:reply, stats, state}
end
@impl true
def handle_info(:flush, state) do
state =
if state.batch != [] do
case send_batch(state.batch, state.config) do
:ok ->
%{state | batch: [], last_successful_flush: DateTime.utc_now()}
{:error, :dropped, count} ->
%{
state
| batch: [],
dropped_samples: state.dropped_samples + count,
flush_failures: state.flush_failures + 1
}
end
else
state
end
schedule_flush()
{:noreply, state}
end
@impl true
def terminate(_reason, state) do
if state.batch != [] do
Logger.debug("[Tripswitch] Flushing #{length(state.batch)} samples on shutdown")
send_batch(state.batch, state.config)
end
:ok
end
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
defp schedule_flush do
Process.send_after(self(), :flush, @flush_interval_ms)
end
defp send_batch(batch, config) do
payload = %{samples: Enum.reverse(batch)}
with {:ok, json} <- Jason.encode(payload),
{:ok, compressed} <- gzip(json),
{:ok, ts_ms, signature} <- sign(compressed, config) do
do_send(compressed, ts_ms, signature, config)
else
{:error, reason} ->
Logger.error("[Tripswitch] Failed to prepare batch: #{inspect(reason)}")
{:error, :dropped, length(batch)}
end
end
defp gzip(data) do
{:ok, :zlib.gzip(data)}
rescue
e -> {:error, e}
end
defp sign(_compressed, %Config{ingest_secret: nil}) do
ts_ms = System.os_time(:millisecond)
{:ok, ts_ms, nil}
end
defp sign(compressed, %Config{ingest_secret: secret}) do
ts_ms = System.os_time(:millisecond)
case Base.decode16(secret, case: :lower) do
{:ok, secret_bytes} ->
message = "#{ts_ms}.#{compressed}"
mac = :crypto.mac(:hmac, :sha256, secret_bytes, message)
signature = "v1=" <> Base.encode16(mac, case: :lower)
{:ok, ts_ms, signature}
:error ->
{:error, :invalid_ingest_secret}
end
end
defp do_send(compressed, ts_ms, signature, config) do
url = Config.ingest_url(config)
headers =
[
{"content-type", "application/json"},
{"content-encoding", "gzip"},
{"x-eb-timestamp", Integer.to_string(ts_ms)}
] ++ if(signature, do: [{"x-eb-signature", signature}], else: [])
Enum.reduce_while([nil | @retry_delays_ms], {:error, :retries_exhausted}, fn delay, _acc ->
if delay, do: Process.sleep(delay)
case Req.post(url, body: compressed, headers: headers, retry: false) do
{:ok, %{status: status}} when status in 200..299 ->
{:halt, :ok}
{:ok, %{status: status}} when status in 400..499 ->
Logger.error("[Tripswitch] Ingest rejected (#{status}), dropping batch")
{:halt, {:error, :dropped, 0}}
{:ok, %{status: status}} ->
Logger.warning("[Tripswitch] Ingest failed (#{status}), retrying")
{:cont, {:error, :server_error}}
{:error, reason} ->
Logger.warning("[Tripswitch] Ingest request error: #{inspect(reason)}, retrying")
{:cont, {:error, reason}}
end
end)
|> case do
:ok -> :ok
_ -> {:error, :dropped, 0}
end
end
end