This guide covers integrating Python's logging module with Erlang's logger, and distributed tracing support for Python code.
erlang_python provides:
- Logging: Python
loggingforwarded to Erlanglogger - Tracing: Span-based distributed tracing from Python
Both features use fire-and-forget NIFs, meaning Python execution is never blocked waiting for Erlang.
%% Configure Python logging
ok = py:configure_logging().
%% Run Python code that logs
{ok, _} = py:eval(<<"
import logging
logging.info('Hello from Python!')
logging.warning('Something happened')
">>).Log messages appear in your Erlang logger output with domain [python].
After py:configure_logging(), these functions are available on the erlang module:
import erlang
# ErlangHandler - logging.Handler subclass
handler = erlang.ErlangHandler()
logging.getLogger().addHandler(handler)
# Or use the setup helper
erlang.setup_logging(level=20) # INFO level
erlang.setup_logging(level=10, format='%(name)s: %(message)s')%% Configure with defaults (debug level)
ok = py:configure_logging().
%% Configure with options
ok = py:configure_logging(#{
level => info, % debug | info | warning | error | critical
format => <<"%(name)s - %(message)s">> % Optional Python format string
}).| Python Level | Python levelno | Erlang Level |
|---|---|---|
| DEBUG | 10 | debug |
| INFO | 20 | info |
| WARNING | 30 | warning |
| ERROR | 40 | error |
| CRITICAL | 50 | critical |
Each log message includes Python metadata:
%% In your Erlang logger handler, you'll receive:
#{
domain => [python],
py_logger => <<"root">>, % Logger name
py_meta => #{
<<"module">> => <<"mymodule">>,
<<"lineno">> => 42,
<<"funcName">> => <<"my_function">>
}
}%% Enable tracing
ok = py:enable_tracing().
%% Run Python code with spans
{ok, _} = py:eval(<<"
import erlang
with erlang.Span('process-request', user_id=123):
do_work()
">>).
%% Retrieve collected spans
{ok, Spans} = py:get_traces().
%% Spans = [#{name => <<"process-request">>, status => ok, ...}]
%% Clean up
ok = py:clear_traces().
ok = py:disable_tracing().import erlang
with erlang.Span('operation-name', key='value', count=42) as span:
# Your code here
span.event('checkpoint', items_processed=10)
# Nested spans
with erlang.Span('sub-operation'):
passimport erlang
@erlang.trace()
def my_function():
return 42
@erlang.trace(name='custom-name')
def another_function():
pass%% Enable/disable tracing
ok = py:enable_tracing().
ok = py:disable_tracing().
%% Get all collected spans
{ok, Spans} = py:get_traces().
%% Clear collected spans
ok = py:clear_traces().Each completed span is a map with these keys:
#{
name => <<"operation-name">>,
span_id => 12345678901234567890, % Unique 64-bit ID
parent_id => 9876543210987654321, % Parent span ID (or 'undefined')
start_time => 1234567890123, % Microseconds (monotonic)
end_time => 1234567890456,
duration_us => 333, % Duration in microseconds
status => ok | error,
attributes => #{<<"key">> => <<"value">>},
end_attrs => #{}, % Attributes added at span end
events => [ % Events within the span
#{
name => <<"checkpoint">>,
attrs => #{<<"items_processed">> => 10},
time => 1234567890200
}
]
}Spans automatically capture exceptions:
import erlang
try:
with erlang.Span('risky-operation'):
raise ValueError('something went wrong')
except ValueError:
pass
# The span will have:
# - status: 'error'
# - end_attrs: {'exception': 'something went wrong'}Both logging and tracing are thread-safe:
- Span context is stored in thread-local storage
- Each thread maintains its own span stack for proper parent-child relationships
- NIFs use atomic operations for receiver registration
Python NIF Erlang
─────── ───── ────────
logging.info(msg) │ │
│ │ │
▼ │ │
ErlangHandler.emit() │ │
│ │ │
▼ │ │
erlang._log(...) ───────► nif_py_log() ──► enif_send() ──► py_logger
│ │ (gen_server)
│ (returns immediately) │ │
▼ │ logger:log(...)
continue execution │ │
Key design decisions:
- Fire-and-forget:
enif_send()is non-blocking - Level filtering: Done in NIF before message creation
- No Python blocking: Python never waits for Erlang
- Log messages below the configured level are filtered at the NIF level
- No heap allocation occurs for filtered messages
- Tracing disabled by default; enable only when needed
- Span data is accumulated in memory until retrieved with
get_traces()
| Option | Type | Default | Description |
|---|---|---|---|
level |
atom | debug |
Minimum log level |
format |
binary | %(message)s |
Python format string |
The tracer has no configuration options. Enable/disable with py:enable_tracing()/py:disable_tracing().
See examples/logging_example.erl for a complete working example.
%% Basic usage
{ok, _} = application:ensure_all_started(erlang_python).
%% Logging
ok = py:configure_logging(#{level => info}).
{ok, _} = py:eval(<<"import logging; logging.info('hello')">>).
%% Tracing
ok = py:enable_tracing().
{ok, _} = py:eval(<<"
import erlang
with erlang.Span('work'):
pass
">>).
{ok, Spans} = py:get_traces().
io:format("Collected ~p spans~n", [length(Spans)]).