Current section
Files
Jump to
Current section
Files
lib/cyclium/service_levels.ex
defmodule Cyclium.ServiceLevels do
@moduledoc """
ETS-backed performance objectives tracking per expectation.
Tracks episode duration and success rate, and detects breaches
against declared performance objectives.
## Configuration
Set `service_levels` on an expectation:
expect :check_health,
service_levels: %{max_duration_ms: 30_000, success_rate: 0.95, window_episodes: 50}
## ETS layout
Key: `{actor_id, expectation_id}`
Value: `%{config: map, samples: [map], ...}`
"""
@table :cyclium_service_level_metrics
@doc "Idempotent ETS table creation."
def ensure_table do
case :ets.whereis(@table) do
:undefined ->
:ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
_ref ->
@table
end
end
@doc "Register performance objectives config for an expectation."
def register(actor_id, expectation_id, config) when is_map(config) do
ensure_table()
key = {to_string(actor_id), to_string(expectation_id)}
case :ets.lookup(@table, key) do
[{^key, state}] ->
:ets.insert(@table, {key, %{state | config: config}})
[] ->
:ets.insert(@table, {key, %{config: config, samples: []}})
end
:ok
end
@doc """
Record an episode outcome.
`measurements` should include:
- `:duration_ms` — episode wall-clock time in milliseconds
- `:success` — boolean
"""
def record(actor_id, expectation_id, measurements) when is_map(measurements) do
ensure_table()
key = {to_string(actor_id), to_string(expectation_id)}
case :ets.lookup(@table, key) do
[{^key, state}] ->
window = Map.get(state.config, :window_episodes, 100)
samples = [measurements | state.samples] |> Enum.take(window)
:ets.insert(@table, {key, %{state | samples: samples}})
[] ->
:ets.insert(@table, {key, %{config: %{}, samples: [measurements]}})
end
:ok
end
@doc """
Check if performance objectives are being met.
Returns `:ok` or `{:breach, details}`.
"""
def check(actor_id, expectation_id) do
ensure_table()
key = {to_string(actor_id), to_string(expectation_id)}
case :ets.lookup(@table, key) do
[{^key, %{config: config, samples: samples}}] when samples != [] ->
check_breaches(config, samples)
_ ->
:ok
end
end
@doc """
Get current metrics for an expectation.
"""
def metrics(actor_id, expectation_id) do
ensure_table()
key = {to_string(actor_id), to_string(expectation_id)}
case :ets.lookup(@table, key) do
[{^key, %{samples: samples}}] when samples != [] ->
compute_metrics(samples)
_ ->
%{success_rate: nil, p95_duration_ms: nil, sample_count: 0}
end
end
# --- Private ---
defp check_breaches(config, samples) do
metrics = compute_metrics(samples)
cond do
breach = check_success_rate(config, metrics) ->
{:breach, breach}
breach = check_duration(config, metrics) ->
{:breach, breach}
true ->
:ok
end
end
defp check_success_rate(%{success_rate: threshold}, %{success_rate: actual})
when is_float(threshold) and is_float(actual) and actual < threshold do
%{type: :success_rate, current: actual, threshold: threshold}
end
defp check_success_rate(_, _), do: nil
defp check_duration(%{max_duration_ms: threshold}, %{p95_duration_ms: actual})
when is_number(threshold) and is_number(actual) and actual > threshold do
%{type: :duration, current: actual, threshold: threshold}
end
defp check_duration(_, _), do: nil
defp compute_metrics(samples) do
count = length(samples)
successes = Enum.count(samples, & &1[:success])
durations =
samples
|> Enum.map(& &1[:duration_ms])
|> Enum.filter(&is_number/1)
|> Enum.sort()
p95 =
if durations != [] do
idx = trunc(length(durations) * 0.95) |> max(0) |> min(length(durations) - 1)
Enum.at(durations, idx)
end
%{
success_rate: if(count > 0, do: successes / count, else: nil),
p95_duration_ms: p95,
sample_count: count
}
end
end