Packages

LogDot SDK for Elixir - Cloud logging and metrics

Current section

Files

Jump to
logdot lib logdot metrics.ex
Raw

lib/logdot/metrics.ex

defmodule LogDot.Metrics do
@moduledoc """
GenServer for handling metrics transmission to LogDot.
## Usage
# Start the GenServer (typically in your supervision tree)
LogDot.Metrics.start_link()
# Create or get an entity
{:ok, entity} = LogDot.Metrics.get_or_create_entity("my-service", "My service description")
# Bind to the entity for sending metrics
:ok = LogDot.Metrics.for_entity(entity.id)
# Send metrics
:ok = LogDot.Metrics.send("cpu.usage", 45.5, "percent")
:ok = LogDot.Metrics.send("response_time", 42, "ms", %{endpoint: "/api/users"})
"""
use GenServer
alias LogDot.{Config, HTTP}
defstruct [
:config,
entity_id: nil,
entity_id_cached: false,
batch_mode: false,
multi_batch_mode: false,
batch_metric_name: nil,
batch_unit: nil,
batch_queue: [],
last_error: nil
]
# Entity struct for return values
defmodule Entity do
@moduledoc "Represents a LogDot entity"
defstruct [:id, :name, :description]
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
description: String.t() | nil
}
end
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Create a new entity.
## Example
{:ok, entity} = LogDot.Metrics.create_entity("my-service", "Production service", %{version: "1.0"})
"""
@spec create_entity(String.t(), String.t(), map()) :: {:ok, Entity.t()} | {:error, term()}
def create_entity(name, description \\ "", metadata \\ %{}) do
GenServer.call(__MODULE__, {:create_entity, name, description, metadata}, 30_000)
end
@doc """
Get an entity by name.
## Example
{:ok, entity} = LogDot.Metrics.get_entity_by_name("my-service")
"""
@spec get_entity_by_name(String.t()) :: {:ok, Entity.t()} | {:error, term()}
def get_entity_by_name(name) do
GenServer.call(__MODULE__, {:get_entity_by_name, name}, 30_000)
end
@doc """
Get or create an entity by name.
## Example
{:ok, entity} = LogDot.Metrics.get_or_create_entity("my-service", "Created if not exists")
"""
@spec get_or_create_entity(String.t(), String.t(), map()) :: {:ok, Entity.t()} | {:error, term()}
def get_or_create_entity(name, description \\ "", metadata \\ %{}) do
GenServer.call(__MODULE__, {:get_or_create_entity, name, description, metadata}, 30_000)
end
@doc """
Bind to an entity for sending metrics.
## Example
{:ok, entity} = LogDot.Metrics.get_or_create_entity("my-service")
:ok = LogDot.Metrics.for_entity(entity.id)
:ok = LogDot.Metrics.send("cpu", 50, "percent")
"""
@spec for_entity(String.t()) :: :ok
def for_entity(entity_id) do
GenServer.call(__MODULE__, {:for_entity, entity_id})
end
@doc """
Initialize the metrics entity using config.
Deprecated: Use get_or_create_entity/3 and for_entity/1 instead.
"""
@spec init_entity(map()) :: :ok | {:error, term()}
def init_entity(metadata \\ %{}) do
GenServer.call(__MODULE__, {:init, metadata}, 30_000)
end
@spec is_ready?() :: boolean()
def is_ready? do
GenServer.call(__MODULE__, :is_ready)
end
@spec send(String.t(), number(), String.t(), map()) :: :ok | {:error, term()}
def send(name, value, unit, tags \\ %{}) do
GenServer.call(__MODULE__, {:send, name, value, unit, tags})
end
@spec begin_batch(String.t(), String.t()) :: :ok
def begin_batch(metric_name, unit) do
GenServer.cast(__MODULE__, {:begin_batch, metric_name, unit})
end
@spec add(number(), map()) :: :ok | {:error, term()}
def add(value, tags \\ %{}) do
GenServer.call(__MODULE__, {:add, value, tags})
end
@spec begin_multi_batch() :: :ok
def begin_multi_batch do
GenServer.cast(__MODULE__, :begin_multi_batch)
end
@spec add_metric(String.t(), number(), String.t(), map()) :: :ok | {:error, term()}
def add_metric(name, value, unit, tags \\ %{}) do
GenServer.call(__MODULE__, {:add_metric, name, value, unit, tags})
end
@spec send_batch() :: :ok | {:error, term()}
def send_batch do
GenServer.call(__MODULE__, :send_batch, 30_000)
end
@spec end_batch() :: :ok
def end_batch do
GenServer.cast(__MODULE__, :end_batch)
end
@spec batch_size() :: non_neg_integer()
def batch_size do
GenServer.call(__MODULE__, :batch_size)
end
@spec entity_id() :: String.t() | nil
def entity_id do
GenServer.call(__MODULE__, :entity_id)
end
@spec last_error() :: term()
def last_error do
GenServer.call(__MODULE__, :last_error)
end
# Server Callbacks
@impl true
def init(_opts) do
config = Config.from_env()
{:ok, %__MODULE__{config: config}}
end
@impl true
def handle_call({:create_entity, name, description, metadata}, _from, state) do
case do_create_entity(state.config, name, description, metadata) do
{:ok, entity} ->
{:reply, {:ok, entity}, state}
{:error, reason} ->
{:reply, {:error, reason}, %{state | last_error: reason}}
end
end
@impl true
def handle_call({:get_entity_by_name, name}, _from, state) do
case do_get_entity_by_name(state.config, name) do
{:ok, entity} ->
{:reply, {:ok, entity}, state}
{:error, reason} ->
{:reply, {:error, reason}, %{state | last_error: reason}}
end
end
@impl true
def handle_call({:get_or_create_entity, name, description, metadata}, _from, state) do
case do_get_entity_by_name(state.config, name) do
{:ok, entity} ->
{:reply, {:ok, entity}, state}
{:error, :not_found} ->
case do_create_entity(state.config, name, description, metadata) do
{:ok, entity} ->
{:reply, {:ok, entity}, state}
{:error, reason} ->
{:reply, {:error, reason}, %{state | last_error: reason}}
end
{:error, reason} ->
{:reply, {:error, reason}, %{state | last_error: reason}}
end
end
@impl true
def handle_call({:for_entity, entity_id}, _from, state) do
{:reply, :ok, %{state | entity_id: entity_id, entity_id_cached: true}}
end
@impl true
def handle_call({:init, metadata}, _from, state) do
case check_or_create_entity(state, metadata) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, %{state | last_error: reason}}
end
end
@impl true
def handle_call(:is_ready, _from, state) do
{:reply, state.entity_id_cached and not is_nil(state.entity_id), state}
end
@impl true
def handle_call({:send, name, value, unit, tags}, _from, state) do
if state.batch_mode do
{:reply, {:error, :batch_mode_active}, state}
else
case send_metric(state, name, value, unit, tags) do
:ok -> {:reply, :ok, state}
{:error, reason} -> {:reply, {:error, reason}, %{state | last_error: reason}}
end
end
end
@impl true
def handle_call({:add, value, tags}, _from, state) do
if state.batch_mode and not state.multi_batch_mode do
entry = %{
name: state.batch_metric_name,
value: value,
unit: state.batch_unit,
tags: format_tags(tags)
}
{:reply, :ok, %{state | batch_queue: [entry | state.batch_queue]}}
else
{:reply, {:error, :not_in_batch_mode}, state}
end
end
@impl true
def handle_call({:add_metric, name, value, unit, tags}, _from, state) do
if state.multi_batch_mode do
entry = %{name: name, value: value, unit: unit, tags: format_tags(tags)}
{:reply, :ok, %{state | batch_queue: [entry | state.batch_queue]}}
else
{:reply, {:error, :not_in_multi_batch_mode}, state}
end
end
@impl true
def handle_call(:send_batch, _from, state) do
if state.batch_mode and length(state.batch_queue) > 0 do
metrics =
state.batch_queue
|> Enum.reverse()
|> Enum.map(fn entry ->
base = %{value: entry.value, unit: entry.unit}
base =
if state.multi_batch_mode do
Map.put(base, :name, entry.name)
else
base
end
if entry.tags && length(entry.tags) > 0 do
Map.put(base, :tags, entry.tags)
else
base
end
end)
payload = %{
entity_id: state.entity_id,
metrics: metrics
}
payload =
if not state.multi_batch_mode do
Map.put(payload, :name, state.batch_metric_name)
else
payload
end
case HTTP.post("/metrics/batch", payload, state.config) do
{:ok, _} ->
{:reply, :ok, %{state | batch_queue: []}}
{:error, reason} ->
{:reply, {:error, reason}, %{state | last_error: reason}}
end
else
{:reply, :ok, state}
end
end
@impl true
def handle_call(:batch_size, _from, state) do
{:reply, length(state.batch_queue), state}
end
@impl true
def handle_call(:entity_id, _from, state) do
{:reply, state.entity_id, state}
end
@impl true
def handle_call(:last_error, _from, state) do
{:reply, state.last_error, state}
end
@impl true
def handle_cast({:begin_batch, metric_name, unit}, state) do
{:noreply,
%{
state
| batch_mode: true,
multi_batch_mode: false,
batch_metric_name: metric_name,
batch_unit: unit,
batch_queue: []
}}
end
@impl true
def handle_cast(:begin_multi_batch, state) do
{:noreply, %{state | batch_mode: true, multi_batch_mode: true, batch_queue: []}}
end
@impl true
def handle_cast(:end_batch, state) do
{:noreply, %{state | batch_mode: false, multi_batch_mode: false, batch_queue: []}}
end
# Private functions
defp do_create_entity(config, name, description, metadata) do
payload = %{
name: name,
description: description,
metadata: metadata
}
case HTTP.post("/entities", payload, config) do
{:ok, %{"data" => %{"id" => id}}} ->
debug_log(config, "Entity created: #{id}")
{:ok, %Entity{id: id, name: name, description: description}}
{:error, reason} ->
{:error, reason}
end
end
defp do_get_entity_by_name(config, name) do
encoded_name = URI.encode(name)
case HTTP.get("/entities/by-name/#{encoded_name}", config) do
{:ok, %{"data" => %{"id" => id} = data}} ->
debug_log(config, "Entity found: #{id}")
{:ok,
%Entity{
id: id,
name: Map.get(data, "name", name),
description: Map.get(data, "description")
}}
{:error, :not_found} ->
{:error, :not_found}
{:error, reason} ->
{:error, reason}
end
end
defp format_tags(tags) when is_map(tags) and map_size(tags) > 0 do
Enum.map(tags, fn {key, value} -> "#{key}:#{value}" end)
end
defp format_tags(_), do: nil
defp check_or_create_entity(state, metadata) do
entity_name = state.config.entity_name || "unknown"
encoded_name = URI.encode(entity_name)
case HTTP.get("/entities/by-name/#{encoded_name}", state.config) do
{:ok, %{"data" => %{"id" => id}}} ->
debug_log(state.config, "Entity found: #{id}")
{:ok, %{state | entity_id: id, entity_id_cached: true}}
{:error, :not_found} ->
create_entity_legacy(state, entity_name, metadata)
{:error, reason} ->
{:error, reason}
end
end
defp create_entity_legacy(state, entity_name, metadata) do
payload = %{
name: entity_name,
description: state.config.entity_description || "",
metadata: metadata
}
case HTTP.post("/entities", payload, state.config) do
{:ok, %{"data" => %{"id" => id}}} ->
debug_log(state.config, "Entity created: #{id}")
{:ok, %{state | entity_id: id, entity_id_cached: true}}
{:error, reason} ->
{:error, reason}
end
end
defp send_metric(state, name, value, unit, tags) do
if is_nil(state.entity_id) do
{:error, :entity_not_initialized}
else
payload = %{
entity_id: state.entity_id,
name: name,
value: value,
unit: unit
}
payload =
case format_tags(tags) do
nil -> payload
formatted -> Map.put(payload, :tags, formatted)
end
case HTTP.post("/metrics", payload, state.config) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
defp debug_log(config, message) do
if config.debug do
IO.puts("[LogDotMetrics] #{message}")
end
end
end