# `Otel.LoggerHandler`
[🔗](https://github.com/yangbancode/otel/blob/main/lib/otel/logger_handler.ex#L1)

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](https://github.com/open-telemetry/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:

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):

| 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](#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` struct** → `log_record.exception` field
  (`t: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."*
- **`stacktrace`** → `log_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.

# `primitive`

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

# `primitive_any`

```elixir
@type primitive_any() ::
  primitive() | [primitive_any()] | %{required(String.t()) =&gt; primitive_any()}
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
