# SPDX-License-Identifier: Apache-2.0
defmodule EctoSparkles.Log do
use Untangle
@moduledoc """
Log Ecto queries, and output warnings for slow or possible n+1 queries
To set up, simply add `EctoSparkles.Log.setup(YourApp.Repo)` in your app's main `Application.start/2` module.
"""
@exclude_sources ["oban_jobs", "oban_peers"]
@exclude_queries ["commit", "begin"]
@exclude_match ["oban_jobs", "oban_peers", "oban_insert", "pg_notify", "pg_try_advisory_xact_lock", "schema_migrations"]
def setup(repo_module) do
config = repo_module.config()
prefix = config[:telemetry_prefix]
# <- Telemetry event id for Ecto queries
query_event = prefix ++ [:query]
# events = [
# query_event,
# prefix ++ [:insert],
# prefix ++ [:update],
# prefix ++ [:delete]
# ]
# :telemetry.attach_many("ectosparkles-log", events, &EctoSparkles.Log.handle_event/4, [])
:telemetry.attach(
"ectosparkles-log",
query_event,
&EctoSparkles.Log.handle_event/4,
[]
)
end
def handle_event(
_,
measurements,
%{query: query, source: source} = metadata,
_config
)
when (is_nil(source) or source not in @exclude_sources) and
query not in @exclude_queries do
do_handle_event(measurements, metadata)
end
def handle_event(_, _measurements, _metadata, _config) do
# IO.inspect(metadata, label: "EctoSparkles: ignoring ecto log")
nil
end
defp do_handle_event(
%{query_time: query_time, decode_time: decode_time} = measurements,
%{query: query, source: source} = metadata
) do
check_if_slow(
System.convert_time_unit(query_time, :native, :millisecond) +
System.convert_time_unit(decode_time, :native, :millisecond),
measurements,
metadata
)
end
defp do_handle_event(
%{query_time: query_time} = measurements,
%{query: query, source: source} = metadata
) do
check_if_slow(
System.convert_time_unit(query_time, :native, :millisecond),
measurements,
metadata
)
end
defp do_handle_event(measurements, metadata) do
{result, _} = metadata.result
log_query(result, nil, measurements, metadata)
end
defp check_if_slow(duration_in_ms, measurements, %{query: query} = metadata)
when duration_in_ms > 10 do
slow_definition_in_ms = Application.get_env(:ecto_sparkles, :slow_query_ms, 100)
{result, _} = metadata.result
if duration_in_ms > slow_definition_in_ms do
Logger.warning(
"Slow database query: " <>
format_log(result, duration_in_ms, measurements, metadata)
)
else
log_query(result, duration_in_ms, measurements, metadata)
end
end
defp check_if_slow(duration_in_ms, measurements, metadata) do
{result_key, _} = metadata.result
log_query(result_key, duration_in_ms, measurements, metadata)
end
def log_query(result_key, duration_in_ms, measurements, metadata)
when result_key in [:error, "error"] do
if not String.contains?(metadata.query, @exclude_match),
do:
Logger.error(
"SQL query: " <>
format_log(result_key, duration_in_ms, measurements, metadata)
)
end
def log_query(result_key, duration_in_ms, measurements, metadata) do
level = Application.get_env(:ecto_sparkles, :queries_log_level, :debug)
if level && not String.contains?(metadata.query, @exclude_match) do
count_n_plus_1 = check_if_n_plus_1(metadata.query)
cond do
is_integer(count_n_plus_1) ->
Logger.warning(
"---------> Possible n+1 query detected! Number of occurrences: #{count_n_plus_1} SQL query: " <>
format_log(result_key, duration_in_ms, measurements, metadata)
)
not is_nil(level) ->
Logger.log(
level,
"SQL query: " <>
format_log(result_key, duration_in_ms, measurements, metadata)
)
true -> # skip
end
end
end
def check_if_n_plus_1(query) do
case EctoSparkles.NPlus1Detector.check(query) do
{:match, count} ->
count
_ ->
# no match
end
end
def format_log(result_key, duration_in_ms, measurements, metadata) do
params = metadata.params
|> Enum.map(&prepare_value/1)
#|> inspect(charlists: false)
# Strip out unnecessary quotes from the query for readability
# Regex.replace(~r/(\d\.)"([^"]+)"/, metadata.query, "\\1\\2")
source = if metadata.source, do: "source=#{inspect(metadata.source)}"
# \n params=#{params}
"#{result_key} db=#{duration_in_ms}ms #{source}\n #{inline_params(metadata.query, params, metadata[:repo].__adapter__())} \n#{format_stacktrace_sliced(metadata[:stacktrace])}"
end
def inline_params(query, params, repo_adapter \\ Ecto.Adapters.SQL) do
query
|> Ecto.DevLogger.inline_params(params, sql_color(query), repo_adapter)
end
defp prepare_value(value) when is_list(value) do
Enum.map(value, &prepare_value/1)
end
defp prepare_value("-----BEGIN RSA PRIVATE KEY"<>_), do: "***"
defp prepare_value("$pbkdf2"<>_), do: "***"
defp prepare_value("$argon2"<>_), do: "***"
defp prepare_value(binary) when is_binary(binary) do
with {:ok, ulid} <- Code.ensure_loaded?(Needle.ULID) and Needle.ULID.load(binary) do
ulid
else
_ -> binary
end
end
defp prepare_value(%Ecto.Query.Tagged{value: value}), do: prepare_value(value)
defp prepare_value(value), do: value
defp sql_color("SELECT" <> _), do: :cyan
defp sql_color("ROLLBACK" <> _), do: :red
defp sql_color("LOCK" <> _), do: :white
defp sql_color("INSERT" <> _), do: :green
defp sql_color("UPDATE" <> _), do: :yellow
defp sql_color("DELETE" <> _), do: :red
defp sql_color("begin" <> _), do: :magenta
defp sql_color("commit" <> _), do: :magenta
defp sql_color(_), do: :default_color
end