Otel.LoggerHandler (otel v0.4.1)

Copy Markdown View Source

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 levelSeverityNumberSeverityText (source)OTel short name (display)
:emergency21"emergency"FATAL
:alert19"alert"ERROR3
:critical18"critical"ERROR2
:error17"error"ERROR
:warning13"warning"WARN
:notice10"notice"INFO2
:info9"info"INFO
:debug5"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_apiOtel.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 shapeBody
{: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:

AritySignatureHandling
/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 metaOTel attributeNotes
mfa: {module, fun, arity}code.function.name"Module.fun/arity" fully-qualified form
file: chardatacode.file.path
line: integercode.line.number
domain: [atom]log.domainnon-standard convenience; emitted as [String.t()] so backends can filter by path segment
crash_reason: {exc, stack} (exception shape)exception.stacktraceformatted 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:

  1. Arity is included. The spec's Elixir example at semantic-conventions/model/code/registry.yaml L31 is OpenTelemetry.Ctx.new (arity-less), but L20 notes "Values and format depends on each language runtime". BEAM conventions (stacktrace format, OTP's mfa tuple, Exception.format_mfa/3) include arity, and handle/2 vs handle/3 are genuinely distinct functions — omitting arity would lose information.

  2. inspect(module) strips the Elixir. prefix. Module atoms are stored internally as :"Elixir.<Name>"; inspect/1 drops the prefix (inspect(MyApp.Worker)"MyApp.Worker"), while Atom.to_string/1 / to_string/1 keep it ("Elixir.MyApp.Worker"). For code.function.name the user-readable form matters to backends and stacktraces. This intentionally differs from to_primitive_any/1 (body-value path), which uses to_string/1 and 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):

KeyReason
:mfa, :file, :line, :domainAlready mapped above to semconv-stable code.* / log.domain names
:timeConsumed by to_timestamp/1timestamp field
:report_cbConsumed by to_body/2 → body render
:crash_reasonConsumed by to_exception/1exception field, and by put_exception_stacktrace/2exception.stacktrace attribute
:glGroup-leader PID — process-internal, no OTel semantic
:pidprocess.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:

  • exception structlog_record.exception field (Otel.Logs.LogRecord.t/0). API-layer MAY-accepted sidecar per api.md L131. SDK converts this to the stable exception.type and exception.message attributes (reading .__struct__ and calling Exception.message/1) per logs/sdk.md L228-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."
  • stacktracelog_record.attributes under "exception.stacktrace" (stable semconv attribute per semantic-conventions/model/exceptions/registry.yaml L27-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 via Exception.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:

  1. Map :mfa / :file / :line / :domain to their stable semconv names (code.function.name, etc.).
  2. Run extraction at the handler so every exporter (OTLP, custom, in-process debug) sees the canonical attribute shape without re-work.
  3. 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

primitive()

@type primitive() ::
  String.t() | {:bytes, binary()} | boolean() | integer() | float() | nil

primitive_any()

@type primitive_any() ::
  primitive() | [primitive_any()] | %{required(String.t()) => primitive_any()}