Current section
Files
Jump to
Current section
Files
lib/server_logger.ex
defmodule ServerLogger do
@moduledoc """
Context module for server log persistence and querying.
Provides functions for inserting, listing, counting, pruning logs,
and managing monthly partitions.
"""
import Ecto.Query
alias ServerLogger.ServerLog
alias ServerLogger.Config
# --- Logging ---
@doc """
Conditionally logs a message with the `[ServerLogger]` prefix,
based on the `:logging_enabled` config setting.
"""
def maybe_log_message(message) do
case Config.logging_enabled() do
:stdio -> IO.puts(:stdio, "[ServerLogger] " <> message)
:stderr -> IO.puts(:stderr, "[ServerLogger] " <> message)
_ -> :ok
end
end
# --- Insert ---
@doc """
Bulk inserts log entries into the server_logs table.
Uses `log: false` to prevent Ecto query logs from causing recursion.
"""
def insert_logs([]), do: {:ok, 0}
# Postgres protocol supports max 65,535 parameters per query.
# With 9 fields per entry, that's ~7,281 rows max. Use 5,000 for safety.
@insert_batch_size 5_000
def insert_logs(entries) when is_list(entries) do
repo = Config.repo()
total =
entries
|> Enum.chunk_every(@insert_batch_size)
|> Enum.reduce(0, fn batch, acc ->
{count, _} = repo.insert_all(ServerLog, batch, log: false)
acc + count
end)
maybe_log_message("Flushed #{total} log entries")
{:ok, total}
rescue
e ->
{:error, e}
end
# --- Query ---
@doc """
Lists logs with filtering, sorting, and pagination.
## Options
* `:levels` — list of level strings to include
* `:search` — keyword search via ILIKE on message, module, request_id
* `:from` / `:to` — datetime range filter on logged_at
* `:sort_by` — column atom (default: :logged_at)
* `:sort_dir` — :asc or :desc (default: :desc)
* `:limit` — max results (default: 100)
* `:offset` — offset for pagination (default: 0)
"""
def list_logs(opts \\ []) do
base_query()
|> apply_filters(opts)
|> apply_sorting(opts)
|> apply_pagination(opts)
|> Config.repo().all(log: false)
end
@doc """
Counts logs matching the given filters (ignores limit/offset).
"""
def count_logs(opts \\ []) do
base_query()
|> apply_filters(opts)
|> Config.repo().aggregate(:count, :id, log: false)
end
# --- Pruning ---
@doc """
Deletes logs of the specified level older than the given number of days.
Deletes in batches of 10,000 to avoid long locks.
"""
def prune_logs(level, older_than_days) when is_binary(level) and is_number(older_than_days) do
cutoff = DateTime.add(DateTime.utc_now(), -round(older_than_days * 86_400), :second)
do_prune_batch(level, cutoff, 0)
end
defp do_prune_batch(level, cutoff, total_deleted) do
repo = Config.repo()
{deleted, _} =
repo.query!(
"""
DELETE FROM server_logs
WHERE ctid = ANY(
ARRAY(
SELECT ctid FROM server_logs
WHERE level = $1 AND logged_at < $2
LIMIT 10000
)
)
""",
[level, cutoff],
log: false
)
|> then(fn result -> {result.num_rows, nil} end)
if deleted == 0 do
total_deleted
else
do_prune_batch(level, cutoff, total_deleted + deleted)
end
end
# --- Partition Management ---
@doc """
Creates a partition for the given year/month. Idempotent.
"""
def ensure_partition_exists(year, month)
when is_integer(year) and is_integer(month) and month >= 1 and month <= 12 do
name = partition_name(year, month)
from_date = Date.new!(year, month, 1)
to_date = from_date |> Date.end_of_month() |> Date.add(1)
Config.repo().query!(
"""
CREATE TABLE IF NOT EXISTS #{name} PARTITION OF server_logs
FOR VALUES FROM ('#{from_date}') TO ('#{to_date}')
""",
[],
log: false
)
:ok
end
@doc """
Drops a partition for the given year/month.
"""
def drop_partition(year, month)
when is_integer(year) and is_integer(month) and month >= 1 and month <= 12 do
name = partition_name(year, month)
Config.repo().query!("DROP TABLE IF EXISTS #{name}", [], log: false)
:ok
end
@doc """
Checks if a partition exists for the given year/month.
"""
def partition_exists?(year, month)
when is_integer(year) and is_integer(month) and month >= 1 and month <= 12 do
name = partition_name(year, month)
result =
Config.repo().query!(
"SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = $1)",
[name],
log: false
)
result.rows == [[true]]
end
@doc """
Lists all child partitions of the server_logs table.
Returns a list of maps with :name, :from, and :to keys.
"""
def list_partitions do
result =
Config.repo().query!(
"""
SELECT c.relname,
pg_get_expr(c.relpartbound, c.oid) as partition_bound
FROM pg_class c
JOIN pg_inherits i ON c.oid = i.inhrelid
JOIN pg_class p ON i.inhparent = p.oid
WHERE p.relname = 'server_logs'
ORDER BY c.relname
""",
[],
log: false
)
Enum.map(result.rows, fn [name, bound_expr] ->
{from_date, to_date} = parse_partition_bound(bound_expr)
%{name: name, from: from_date, to: to_date}
end)
end
# --- Disk Usage ---
@doc """
Returns the total disk usage of the server_logs table (including all partitions and indexes) in bytes.
"""
def table_size_bytes do
result =
Config.repo().query!(
"""
SELECT COALESCE(SUM(pg_total_relation_size(inhrelid)), 0)
FROM pg_inherits
WHERE inhparent = 'server_logs'::regclass
""",
[],
log: false
)
[[size]] = result.rows
if is_struct(size, Decimal), do: Decimal.to_integer(size), else: size
end
# --- Message Truncation ---
@doc """
Truncates a message if it exceeds the configured max_message_size_mb.
Appends " [truncated]" to truncated messages.
"""
def truncate_message(message) when is_binary(message) do
max_bytes = Config.max_message_size_bytes()
if byte_size(message) > max_bytes do
truncated = binary_slice(message, 0, max_bytes)
# binary_slice may cut in the middle of a multi-byte UTF-8 codepoint,
# leaving trailing bytes that form an incomplete sequence. Trim them
# so the result is always valid UTF-8.
truncated = ensure_valid_utf8(truncated)
truncated <> " [truncated]"
else
message
end
end
def truncate_message(message), do: message
defp ensure_valid_utf8(binary) when is_binary(binary) do
if String.valid?(binary) do
binary
else
case :unicode.characters_to_binary(binary, :utf8, :utf8) do
{:incomplete, valid, _rest} -> valid
{:error, valid, _rest} -> valid
valid when is_binary(valid) -> valid
end
end
end
# --- Private Helpers ---
defp base_query do
from(l in ServerLog)
end
defp apply_filters(query, opts) do
query
|> filter_by_date_range(Keyword.get(opts, :from), Keyword.get(opts, :to))
|> filter_by_levels(Keyword.get(opts, :levels))
|> filter_by_search(Keyword.get(opts, :search))
end
defp filter_by_levels(query, nil), do: query
defp filter_by_levels(query, []), do: query
defp filter_by_levels(query, levels) when is_list(levels) do
from(l in query, where: l.level in ^levels)
end
defp filter_by_search(query, nil), do: query
defp filter_by_search(query, ""), do: query
defp filter_by_search(query, search) when is_binary(search) do
escaped =
search
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
pattern = "%#{escaped}%"
from(l in query,
where:
ilike(l.message, ^pattern) or
ilike(l.module, ^pattern) or
ilike(l.request_id, ^pattern)
)
end
defp filter_by_date_range(query, nil, nil), do: query
defp filter_by_date_range(query, from, nil) do
from(l in query, where: l.logged_at >= ^from)
end
defp filter_by_date_range(query, nil, to) do
from(l in query, where: l.logged_at <= ^to)
end
defp filter_by_date_range(query, from, to) do
from(l in query, where: l.logged_at >= ^from and l.logged_at <= ^to)
end
defp apply_sorting(query, opts) do
sort_by = Keyword.get(opts, :sort_by, :logged_at)
sort_dir = Keyword.get(opts, :sort_dir, :desc)
valid_columns = ~w(logged_at level message module pid)a
if sort_by in valid_columns do
from(l in query, order_by: [{^sort_dir, ^sort_by}])
else
from(l in query, order_by: [desc: :logged_at])
end
end
defp apply_pagination(query, opts) do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
from(l in query, limit: ^limit, offset: ^offset)
end
defp partition_name(year, month) do
"server_logs_#{year}#{String.pad_leading(Integer.to_string(month), 2, "0")}"
end
defp parse_partition_bound(expr) do
# Parses expressions like: "FOR VALUES FROM ('2026-03-01 00:00:00+00') TO ('2026-04-01 00:00:00+00')"
case Regex.run(~r/FROM \('([^']+)'\) TO \('([^']+)'\)/, expr) do
[_, from_str, to_str] ->
{parse_date(from_str), parse_date(to_str)}
_ ->
{nil, nil}
end
end
defp parse_date(str) do
# Handle both "2026-03-01" and "2026-03-01 00:00:00+00" formats
date_str = str |> String.split(" ") |> List.first()
Date.from_iso8601!(date_str)
end
end