Bridges Erlang's :logger to the OpenTelemetry Logs API
(OTel logs/api.md + logs/supplementary-guidelines.md
§How to Create a Log4J Log Appender).
Converts :logger.log_event/0 into an
Otel.Logs.LogRecord.t/0 and emits it via
Otel.Logs.emit/2. Records flow through registered
processors to exporters; if no processors are registered
the emit is a silent no-op.
Usage
:logger.add_handler(:otel, Otel.LoggerHandler, %{})No handler-specific configuration is supported — minikube
hardcodes the instrumentation scope to the SDK identity
(see Otel.InstrumentationScope).
Batching and export are handled by the SDK's processor
pipeline, not by this handler. Pair with BatchProcessor
for production use.
Severity mapping
Maps :logger levels — which are the lowercased
RFC 5424 Syslog levels — to OTel SeverityNumber per
logs/data-model.md §Mapping of SeverityNumber
(L273-L296) and the Syslog row of Appendix B
(data-model-appendix.md L806-L818):
:logger level | SeverityNumber | SeverityText (source) | OTel short name (display) |
|---|---|---|---|
:emergency | 21 | "emergency" | FATAL |
:alert | 19 | "alert" | ERROR3 |
:critical | 18 | "critical" | ERROR2 |
:error | 17 | "error" | ERROR |
:warning | 13 | "warning" | WARN |
:notice | 10 | "notice" | INFO2 |
:info | 9 | "info" | INFO |
:debug | 5 | "debug" | DEBUG |
Distinct :logger levels within the same SeverityNumber
range (e.g. :error vs :critical vs :alert in ERROR)
are assigned different numbers per spec L280-L283,
preserving their relative ordering.
SeverityText carries the source representation of
the level — the :logger level atom rendered as a string
per logs/data-model.md L240-L241 "original string
representation of the severity as it is known at the
source". Downstream tooling that wants the OTel short
name ("FATAL", "ERROR3", …) can derive it from
severity_number using the §Displaying Severity
L334-L363 table; the short name is a display concern and
is not what the SeverityText field is for.
The mapping is internal to this module rather than shared
in otel_api — Otel.Logs owns the two types
(severity_number/0, severity_level/0) but the
:logger-specific conversion lives where it is consumed.
Other bridges targeting non-:logger sources (e.g. a
direct Syslog priority number, a :telemetry handler)
define their own conversion the same way.
Body extraction
Per logs/data-model.md §Field: Body L399-L400, Body
MUST support AnyValue to preserve the semantics of
structured logs. Elixir :logger's {:report, term}
carries structured data, so we preserve the structure
instead of collapsing to a string:
msg shape | Body |
|---|---|
{:string, chardata} | IO.chardata_to_string/1 |
{:report, map} | primitive_any()-normalised map (keys stringified, values normalised recursively) |
{:report, keyword_list} | keyword list converted to map, then normalised as above |
{format, args} (:io_lib.format/2 shape) | formatted string |
These three shapes are the full :logger.msg/0 contract
(OTP logger.erl L76-L80) — any other shape is a caller
contract violation and raises FunctionClauseError,
handled by :logger's internal try/catch via
self-healing handler removal.
Values inside a report that don't fit OTel's AnyValue —
atoms, structs, tuples, references, pids, functions — are
converted to strings. Values that implement the
String.Chars protocol (atoms, Date/DateTime/Time,
URI, Version, Regex, user structs with
defimpl String.Chars, etc.) use to_string/1 to honor
the canonical string form: ~D[2024-01-01] → "2024-01-01",
:ok → "ok". Values without a String.Chars impl
(tuples, pids, refs, functions, MapSet) fall back to
inspect/1. Body therefore stays strictly within
primitive_any() at every depth without flattening
structs to %{"__struct__" => Date, ...}. Primitive
values (String.t(), integer(), float(), boolean(),
nil, and the {:bytes, binary()} tag) pass through
unchanged.
meta.report_cb — explicit formatter callback
When meta.report_cb is present on a {:report, _}
message, the callback takes precedence over structural
preservation — its presence is the caller's (or OTP's
auto-injection's) explicit declaration of the intended
rendering, so its return value becomes the Body as a
string. Matches OTP :logger convention and the erlang
reference (otel_otlp_logs.erl L127-L157).
Two callback arities are supported per OTP logger.erl
L84-L88:
| Arity | Signature | Handling |
|---|---|---|
/1 | (report()) -> {io:format(), [term()]} | Format tuple is fed to :io_lib.format/2, result coerced to String.t() |
/2 | (report(), report_cb_config()) -> unicode:chardata() | Chardata return is coerced to String.t() directly. Config passed is %{depth: :unlimited, chars_limit: :unlimited, single_line: false} — OTel backends render their own limits |
When no report_cb is present, the report is preserved
as a structured map per the table above.
Attribute mapping
meta fields map to OTel attribute keys per the
semantic conventions
code.* registry. The deprecated code.namespace /
code.function / code.filepath / code.lineno keys are
not emitted; we use the current stable names:
:logger meta | OTel attribute | Notes |
|---|---|---|
mfa: {module, fun, arity} | code.function.name | "Module.fun/arity" fully-qualified form |
file: chardata | code.file.path | |
line: integer | code.line.number | |
domain: [atom] | log.domain | non-standard convenience; emitted as [String.t()] so backends can filter by path segment |
crash_reason: {exc, stack} (exception shape) | exception.stacktrace | formatted via Exception.format_stacktrace/1; non-exception crash_reason shapes ({:exit, _}, {:shutdown, _}) produce no attribute. See ## Exception events |
pid is intentionally not emitted — process.pid is
an int-typed OS PID attribute in semantic-conventions and
does not fit an Erlang PID (#PID<0.123.0>). A follow-up
decision will settle whether to emit it under a
BEAM-specific custom key or drop it entirely.
Format choices
code.function.name renders as
"#{inspect(module)}.#{function}/#{arity}" — two
choices worth surfacing:
Arity is included. The spec's Elixir example at
semantic-conventions/model/code/registry.yamlL31 isOpenTelemetry.Ctx.new(arity-less), but L20 notes "Values and format depends on each language runtime". BEAM conventions (stacktrace format, OTP'smfatuple,Exception.format_mfa/3) include arity, andhandle/2vshandle/3are genuinely distinct functions — omitting arity would lose information.inspect(module)strips theElixir.prefix. Module atoms are stored internally as:"Elixir.<Name>";inspect/1drops the prefix (inspect(MyApp.Worker)→"MyApp.Worker"), whileAtom.to_string/1/to_string/1keep it ("Elixir.MyApp.Worker"). Forcode.function.namethe user-readable form matters to backends and stacktraces. This intentionally differs fromto_primitive_any/1(body-value path), which usesto_string/1and accepts the prefix — each function's use case dictates the choice.
log.domain is [String.t()] (homogeneous array) so
backends can filter by individual path segments
(log.domain[0] = "elixir"). A stringified literal like
"[:elixir, :phoenix]" wouldn't support segment queries.
User metadata pass-through
:logger accepts arbitrary user-provided metadata via
Logger.metadata/1 or per-call meta args. Every key not
in the reserved list below flows through as a custom
attribute — the key is Atom.to_string(meta_key) and the
value is coerced to primitive_any():
Logger.metadata(request_id: "req-abc", user_id: 42)
Logger.info("processed")
# attributes: %{"request_id" => "req-abc", "user_id" => 42, ...}Reserved keys (not emitted as custom attributes, for three distinct reasons):
| Key | Reason |
|---|---|
:mfa, :file, :line, :domain | Already mapped above to semconv-stable code.* / log.domain names |
:time | Consumed by to_timestamp/1 → timestamp field |
:report_cb | Consumed by to_body/2 → body render |
:crash_reason | Consumed by to_exception/1 → exception field, and by put_exception_stacktrace/2 → exception.stacktrace attribute |
:gl | Group-leader PID — process-internal, no OTel semantic |
:pid | process.pid type mismatch (see above) |
Value coercion delegates to to_primitive_any/1 — the same
recursive walker the body path uses. spec
common/README.md L187 — "The attribute value MUST be one
of types defined in AnyValue" — and proto
logs.proto L178 (KeyValue.value = AnyValue) define
LogRecord.attributes values as full AnyValue, including
nested maps and heterogeneous arrays. Primitives
(String.t(), integer(), float(), boolean(), nil,
{:bytes, binary()}) pass through. Non-primitives (atoms,
structs, tuples, PIDs, refs, functions) coerce to string
via String.Chars when implemented, inspect/1 otherwise.
Lists recurse element-wise so nested arrays survive.
Nested maps (non-struct) recurse with stringified keys so
the AnyValue map<string, AnyValue> contract holds at
every depth.
Exception events
Erlang/OTP routes crashes through :logger with
meta.crash_reason = {exception, stacktrace}. The two
halves of the tuple land in two OTel-aligned destinations:
exceptionstruct →log_record.exceptionfield (Otel.Logs.LogRecord.t/0). API-layer MAY-accepted sidecar perapi.mdL131. SDK converts this to the stableexception.typeandexception.messageattributes (reading.__struct__and callingException.message/1) perlogs/sdk.mdL228-L232: "If an Exception is provided, the SDK MUST by default set attributes from the exception on the LogRecord with the conventions outlined in the exception semantic conventions. User-provided attributes MUST take precedence and MUST NOT be overwritten by exception-derived attributes."stacktrace→log_record.attributesunder"exception.stacktrace"(stable semconv attribute persemantic-conventions/model/exceptions/registry.yamlL27-L38). Handler emits it directly because Elixir exception structs don't carry stacktrace (it's a separate value in the language's exception model), so the SDK's struct-based extraction can't reach it. The handler formats viaException.format_stacktrace/1— the idiomatic BEAM representation that matches spec's "natural representation for the language runtime".
Non-exception :crash_reason shapes ({:exit, reason},
{:shutdown, term}, etc.) are ignored — neither sidecar
nor attribute is populated, since they don't fit the
Exception.t() type or the exception.* attribute
semantics.
Design notes
Three intentional divergences from opentelemetry-erlang's
otel_otlp_logs.erl reference implementation. All three
trade OTP-flavoured conventions (raw-atom attribute keys,
terminal-display post-processing, exporter-stage
normalisation) for OTel data-model alignment at the
handler boundary.
1. Attribute extraction — semconv names, handler-level, broader blacklist
Erlang's attribute extraction happens in the OTLP exporter
(otel_otlp_logs.erl L84) as maps:without([gl, time, report_cb], Metadata) — a blacklist with raw atom keys
(mfa, file, line, domain) that are not
semconv-stable names. We instead:
- Map
:mfa/:file/:line/:domainto their stable semconv names (code.function.name, etc.). - Run extraction at the handler so every exporter (OTLP, custom, in-process debug) sees the canonical attribute shape without re-work.
- Blacklist a broader set (
:gl,:time,:report_cb,:crash_reason,:pid) reflecting OTel semantic concerns rather than just display.
2. No trim / single-line post-processing on string Bodies
Erlang (otel_otlp_logs.erl L72-L83) trims leading and
trailing whitespace from formatted string Bodies and
replaces \n-runs with , to force single-line output.
We pass chardata through IO.chardata_to_string/1
verbatim — {:string, _} messages, {format, args}
messages, and report_cb callback results all preserve
their line breaks. The report_cb/2 config we emit also
passes single_line: false.
Multi-line preservation is part of the source representation. Single-line collapse is a terminal-display concern handled by OTel backends (Jaeger, Tempo, Loki, etc.), which render line breaks from the string value themselves.
3. Key stringification at the handler, not the encoder
Erlang (otel_otlp_logs.erl L119) defers map-key
stringification to to_any_value/1 at the OTLP encoder
step, so in-process consumers reading the record before
OTLP encoding see the original atom-keyed form. We
normalise keys recursively inside to_primitive_any/1 so
log_record.body arrives at every processor / exporter
with string keys at every depth.
apps/otel_api/lib/otel/api/logs/log_record.ex L73 types
body: primitive_any(), whose recursive definition
requires %{String.t() => primitive_any()} at every
depth. Doing the conversion at the handler honours the
type contract uniformly across all exporter paths (OTLP,
custom in-process processors, console debug exporter),
not just OTLP.
Summary
Types
@type primitive_any() :: primitive() | [primitive_any()] | %{required(String.t()) => primitive_any()}