Current section

Files

Jump to
oak lib metric summary.ex
Raw

lib/metric/summary.ex

# Copyright 2024 Clivern. All rights reserved.
# Use of this source code is governed by the MIT
# license that can be found in the LICENSE file.
defmodule Oak.Metric.Summary do
@moduledoc """
Represents a Summary metric.
A Summary samples observations (usually things like request durations and response sizes) and provides
a total count of observations and sum of all observed values, while calculating configurable quantiles over a sliding time window.
"""
defstruct [:name, :help, :labels, :quantiles, :sum, :count, :observations]
@doc """
Creates a new Summary metric.
## Parameters
* `name` - The name of the summary
* `help` - The help text of the summary
* `quantiles` - The quantiles to calculate (defaults to [0.5, 0.9, 0.95, 0.99])
* `labels` - The labels of the summary
"""
def new(name, help, quantiles \\ [0.5, 0.9, 0.95, 0.99], labels \\ %{}) do
if name == "" or help == "" do
raise "name and help cannot be empty"
end
if quantiles == [] do
raise "quantiles cannot be empty"
end
if Enum.uniq(quantiles) != quantiles do
raise "quantiles must be unique"
end
%__MODULE__{
name: name,
help: help,
labels: labels,
quantiles: quantiles,
sum: 0,
count: 0,
observations: []
}
end
@doc """
Observes a value in the summary.
## Parameters
* `summary` - The summary to observe
* `value` - The value to observe
"""
def observe(summary, value) when is_number(value) do
%{
summary
| sum: summary.sum + value,
count: summary.count + 1,
observations: [value | summary.observations]
}
end
@doc """
Returns the current sum of all observed values.
## Parameters
* `summary` - The summary to get the sum from
"""
def sum(summary), do: summary.sum
@doc """
Returns the current count of observations.
## Parameters
* `summary` - The summary to get the count from
"""
def count(summary), do: summary.count
@doc """
Returns the current observations.
## Parameters
* `summary` - The summary to get the observations from
"""
def observations(summary), do: summary.observations
@doc """
Calculates the specified quantile from observations.
Uses the standard percentile calculation method:
- Index = (n-1) × p where n is the number of observations and p is the percentile (0-1)
- Interpolates between values when the index is not an integer
"""
def quantile(summary, q) when q >= 0 and q <= 1 do
case summary.observations do
[] ->
0
obs ->
sorted = Enum.sort(obs)
n = length(sorted)
if n == 1 do
# Single observation
List.first(sorted)
else
cond do
q == 0.0 ->
# 0th percentile returns the minimum value
List.first(sorted)
q == 1.0 ->
# 100th percentile returns the maximum value
List.last(sorted)
true ->
# Calculate the position using standard percentile formula
# For quantile q, position = q * (n - 1)
position = q * (n - 1)
# Get the integer part and fractional part
lower_index = trunc(position)
upper_index = min(lower_index + 1, n - 1)
# Get the values at these indices
lower_value = Enum.at(sorted, lower_index)
upper_value = Enum.at(sorted, upper_index)
# If we're at an exact integer position, return that value
if position == lower_index do
lower_value
else
# Interpolate between the two values
weight = position - lower_index
lower_value * (1 - weight) + upper_value * weight
end
end
end
end
end
@doc """
Returns the id of the counter
## Parameters
* `counter` - The counter to get the id from
"""
def id(counter),
do:
"#{counter.name}|#{format_labels(counter.labels)}"
|> String.replace(" ", "")
|> String.downcase()
defp format_labels(labels) when is_map(labels) and map_size(labels) == 0, do: ""
defp format_labels(labels) when is_map(labels) do
labels
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map(fn {key, value} -> "#{key}_#{value}" end)
|> Enum.join(",")
end
@doc """
Returns a string representation of the summary in Prometheus exposition format.
"""
def to_string(summary) do
labels_str =
summary.labels
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map(fn {key, value} -> "#{key}=\"#{value}\"" end)
|> Enum.join(",")
labels_formatted = if labels_str == "", do: "", else: "{#{labels_str}}"
quantile_strings =
summary.quantiles
|> Enum.map(fn q ->
value = quantile(summary, q)
if labels_str == "",
do: "#{summary.name}{quantile=\"#{q}\"} #{value}",
else: "#{summary.name}{#{labels_str},quantile=\"#{q}\"} #{value}"
end)
|> Enum.join("\n")
"""
# HELP #{summary.name} #{summary.help}
# TYPE #{summary.name} summary
#{quantile_strings}
#{summary.name}_sum#{labels_formatted} #{summary.sum}
#{summary.name}_count#{labels_formatted} #{summary.count}
"""
end
end