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(query_opts()) 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, query_opts()) 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() %{rows: [[deleted]], num_rows: _} = repo.query!( """ WITH deleted AS ( DELETE FROM server_logs WHERE id IN ( SELECT id FROM server_logs WHERE level = $1 AND logged_at < $2 LIMIT 10000 ) RETURNING * ) SELECT count(*) FROM deleted """, [level, cutoff], query_opts() ) 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}') """, [], query_opts() ) :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}", [], query_opts()) :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], query_opts() ) 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 """, [], query_opts() ) 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 """, [], query_opts() ) [[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 defp query_opts() do case Config.logging_enabled() do nil -> [log: false] _ -> [] end end end