Packages

Custom Credo checks for common Elixir/Phoenix anti-patterns and CWE Top 25 security vulnerabilities. Provides 40 comprehensive checks covering code quality, error handling, performance, change risk, telemetry, and security (SQL injection, XSS, path traversal, CSRF, SSRF, hardcoded credentials, etc).

Current section

Files

Jump to
oeditus_credo lib credo check warning telemetry_in_recursive_function.ex
Raw

lib/credo/check/warning/telemetry_in_recursive_function.ex

defmodule OeditusCredo.Check.Warning.TelemetryInRecursiveFunction do
@moduledoc """
Detects `:telemetry.execute/3` or `:telemetry.span/3` calls inside
functions that call themselves recursively.
Emitting telemetry on every recursive step causes metric spam and
performance degradation; wrap the entire recursive operation instead.
"""
use Credo.Check,
base_priority: :high,
category: :warning,
explanations: [
check: """
Telemetry events should not be emitted inside recursive functions.
Emitting telemetry in recursive functions causes metric spam and performance
degradation. Instead, wrap the entire recursive operation with telemetry.
Bad:
defp process_list([head | tail]) do
:telemetry.execute([:app, :process_item], %{}) # Called N times!
do_work(head)
process_list(tail)
end
defp process_list([]), do: :ok
Good:
def process_list(items) do
:telemetry.span([:app, :process_list], %{count: length(items)}, fn ->
{do_process_list(items), %{}}
end)
end
defp do_process_list([]), do: :ok
defp do_process_list([head | tail]) do
do_work(head)
do_process_list(tail)
end
""",
params: [
exclude_test_files: "Set to true to skip test files (default: false)"
]
]
import OeditusCredo.Helpers, only: [test_file?: 1]
@doc false
@impl true
def run(%SourceFile{}, false), do: []
def run(%SourceFile{} = source_file, params) do
issue_meta = IssueMeta.for(source_file, params)
if Params.get(params, :exclude_test_files, __MODULE__) and
test_file?(source_file.filename) do
[]
else
source_file
|> Credo.Code.prewalk(&traverse(&1, &2, issue_meta))
end
end
@doc false
@impl true
def param_defaults, do: [exclude_test_files: false]
# Traverse and check functions
defp traverse({:def, meta, [{name, _, args}, [do: body]]} = ast, issues, issue_meta)
when is_atom(name) and is_list(args) do
issues = check_function(name, length(args), body, meta, issues, issue_meta)
{ast, issues}
end
defp traverse({:defp, meta, [{name, _, args}, [do: body]]} = ast, issues, issue_meta)
when is_atom(name) and is_list(args) do
issues = check_function(name, length(args), body, meta, issues, issue_meta)
{ast, issues}
end
defp traverse(ast, issues, _issue_meta), do: {ast, issues}
defp check_function(name, arity, body, meta, issues, issue_meta) do
if calls_self?(body, name, arity) and has_telemetry?(body) do
[issue_for(issue_meta, meta[:line], name, arity) | issues]
else
issues
end
end
defp calls_self?(nil, _target_name, _target_arity), do: false
defp calls_self?(body, _target_name, _target_arity) when is_atom(body), do: false
defp calls_self?(body, _target_name, _target_arity) when is_number(body), do: false
defp calls_self?(body, _target_name, _target_arity) when is_binary(body), do: false
defp calls_self?(body, target_name, target_arity) do
{_ast, found} =
Macro.prewalk(body, false, fn
# Direct function call: function_name(args...)
{^target_name, _, args} = ast, _acc when is_list(args) and length(args) == target_arity ->
{ast, true}
ast, acc ->
{ast, acc}
end)
found
end
defp has_telemetry?(body) when is_atom(body), do: false
defp has_telemetry?(body) when is_number(body), do: false
defp has_telemetry?(body) when is_binary(body), do: false
defp has_telemetry?(body) do
{_ast, found} =
Macro.prewalk(body, false, fn
{{:., _, [:telemetry, func]}, _, _} = ast, _acc when func in [:execute, :span] ->
{ast, true}
{{:., _, [{:__aliases__, _, [:telemetry]}, func]}, _, _} = ast, _acc
when func in [:execute, :span] ->
{ast, true}
ast, acc ->
{ast, acc}
end)
found
end
defp issue_for(issue_meta, line_no, name, arity) do
format_issue(
issue_meta,
message:
"Telemetry should not be emitted inside recursive function #{name}/#{arity} - wrap the entire operation instead",
trigger: "#{name}",
line_no: line_no
)
end
end