View Source Reporting errors
Because an Errata error carries its full context, it is straightforward to get it into your observability stack at a boundary. Errata provides two thin, composable functions for this, and — deliberately — no integration with any particular external service.
Errata.log/2 logs an error's developer message, attaching its reason, kind,
code, severity, retryable, http_status, context, and origin env as Logger metadata
rather than flattening them into the message string, so they stay queryable in
structured logging backends. With no level given it logs at the error's own severity, which
is :error unless the type sets one:
Errata.log(error) # logs at the error's severity
Errata.log(error, :warning) # at a chosen levelErrata.report/2 emits a :telemetry event for
the error (and, optionally, logs it). This is the seam for external reporting:
rather than Errata depending on Sentry (or any other service), your application
attaches a telemetry handler that forwards the error wherever it needs to go.
The vendor integration lives in your application; Errata stays out of it.
Errata.report(error)
Errata.report(error, metadata: %{request_id: request_id}, log: :warning)The event is [:errata, :error], with measurements %{system_time: _, count: 1}
(so Telemetry.Metrics counters work out
of the box) and metadata carrying the full :error struct plus :kind,
:reason, :error_type, :code, :severity, :retryable, :http_status,
and :context as top-level keys — simple values that work directly as metric
tags. A handler in your application wires it up:
:telemetry.attach("myapp-errata", [:errata, :error], &MyApp.ErrorReporter.handle/4, nil)
def handle([:errata, :error], _measurements, metadata, _config) do
Sentry.capture_message(Exception.message(metadata.error),
extra: Errata.to_map(metadata.error),
tags: %{error_type: inspect(metadata.error_type), reason: metadata.reason}
)
endWhat a handler sees of the cause chain
An error that wraps a lower-level failure has two facts worth reporting, and the
outer one is usually the less useful: Exception.message/1 on the error above
says what your code was trying to do, not what went wrong. So the cause travels
in metadata, in two shapes for two kinds of consumer:
cause— the same nested mapErrata.to_map/1emits, so a structured log formatter or a telemetry handler gets every level of the chain with its owncode,contextand classification. Redaction applies at each level.caused_by— one greppable line naming the deepest failure, for a console reader or a single log field.
error = Errata.wrap(RetriesExhausted, %RuntimeError{message: "connection refused"})
# in a Logger backend or a telemetry handler:
metadata.caused_by #=> "** (RuntimeError) connection refused"
metadata.cause #=> %{error_type: "RuntimeError", message: "connection refused"}Both are nil for an error with no cause. Note that caused_by is not named
root_cause: Errata.root_cause/1 returns the error itself when there is no
cause, and a metadata key that contradicted the function of the same name would
be worse than a slightly different word.
Neither key requires the handler to know what kind of thing the cause is — a
foreign exception, an {:error, reason} tuple and a nested Errata error are all
rendered for you.
Reaching for the whole chain
A telemetry handler also receives the error struct itself under :error, so it
can render the full chain including stacktraces, which no metadata key carries:
def handle_event([:errata, :error], _measurements, %{error: error}, _config) do
Logger.error(Errata.format_chain(error))
endErrata.format_chain/1 is the right call for a log line about a wrapped failure:
it shows each level and the original stacktrace, where Errata.log/2 logs the
outer error's message with the chain in metadata.
The one place a handler still has to look at types is a reporter that wants an
exception rather than a map — Sentry.capture_exception/2, say. Which one you
want is an application decision, so pick it explicitly:
case Errata.root_cause(error) do
%{__exception__: true} = exception -> Sentry.capture_exception(exception, extra: Errata.to_map(error))
_plain_term -> Sentry.capture_message(Exception.message(error), extra: Errata.to_map(error))
endRedacting sensitive context
Everything above ships an error's :context outward — into your logs, your
telemetry handlers, and your JSON responses. That is the point of capturing it,
and it is also how a password ends up in your log aggregator, because the
natural thing to write is:
context: %{params: params} # password, token, card number
context: %{headers: headers} # Authorization bearer tokenDeclare the sensitive keys with :redact and Errata replaces their values with
"[REDACTED]" everywhere it serializes the context:
defmodule MyApp.Auth.LoginFailed do
use Errata.DomainError, redact: [:password, :token]
endRedaction is recursive and matches atom and binary keys alike, so declaring
:password also covers the "password" buried inside that captured params map:
error =
MyApp.Auth.LoginFailed.new(
context: %{params: %{"email" => "kim@example.com", "password" => "hunter2"}}
)
Errata.to_map(error).context
#=> %{params: %{"email" => "kim@example.com", "password" => "[REDACTED]"}}It applies at the serialization seam, not at creation, so the error struct
you are holding still has the real values for local debugging — only the copies
Errata emits are redacted. That includes the :error struct in telemetry
metadata, so the Sentry.capture_message(..., extra: Errata.to_map(metadata.error))
handler above cannot leak what you asked to be redacted.
For a floor of protection across every error type, set the keys globally:
config :errata, redact: [:password, :token, :secret, :authorization, :api_key]The global default is [] — nothing is redacted until you ask, so adding Errata
to an existing app never silently changes what it logs. Declared and global keys
compose. When a key list is not enough, override redact_context/1; see
Errata.Redaction.
Testing what you have wired up
:telemetry_test ships with the telemetry dependency Errata already requires, so
asserting on the [:errata, :error] event needs nothing extra, and capture_log/1
needs metadata: :all before the metadata that log/2 exists for shows up. Both
idioms are in Testing with Errata, along with the seam at which
redaction has to be asserted.