Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added
- `Zexbox.Logging.install_json_handler!/1` (and the underlying
`Zexbox.Logging.JsonHandler`) — swaps the default `:logger` handler's
formatter for a JSON one wrapping `LoggerJSON.Formatters.Basic`. Mirrors
the Ruby-side opsbox `JsonFormatter` so Phoenix logs land in
Elasticsearch as one structured document per event instead of fanning
multi-line content out into many.

### Changed
- `:elixir` constraint bumped from `~> 1.14` to `~> 1.15` to match the
`logger_json` 7.x requirement.

### Dependencies
- Adds `logger_json ~> 7.0` and `jason ~> 1.4`.

## 1.5.1 - 2026-02-05

- Handles cases where `$callers` and `$ancestors` may not be pids to avoid crashing metric handler.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ Adding your own logs is as simple as calling the `Zexbox.Telementry.attach/4` (w
Zexbox.Telemetry.attach(:my_event, [:my, :event], &MyAppHandler.my_handler/3, nil)
```

### JSON-formatted logs for Logstash / Elasticsearch

By default the Elixir Logger emits multi-line plain-text output. Filebeat
ships every line as a separate Elasticsearch document, so a single struct
inspection or stack trace can fan out into dozens of indexed docs. The
`Zexbox.Logging` JSON handler swaps the default `:logger` formatter for a
JSON one — every log event becomes a single line of JSON, so multi-line
content collapses into one ES document at the ingest layer. This mirrors
the behaviour of opsbox's `JsonFormatter` on the Ruby side.

In `config/runtime.exs`:

```elixir
if config_env() == :prod do
Zexbox.Logging.install_json_handler!()
end
```
Comment on lines +161 to +170
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You only want to put configurations affected by environment variables in config/runtime.exs, for something like logger you want to put it inside config/dev.exs or config/prod.exs.

The reason for this is that config/{dev|test|prod}.exs is set at compile time so for deploys it can't inject env variables, where in this case the formatter is static

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reread brendon's review to double check. They do mention you can configure it at runtime, but in our cases that's not necessary unless we want to feature flag it (which we dont), unless you want to get fancy for opening the console.... which almost makes me backtrack the thought.
I'd suggest taking a similar approach in the readme to logger_json specifying both options
https://hexdocs.pm/logger_json/readme.html

Configuration can be set using 2nd element of the tuple of the :formatter option in [Logger](https://hexdocs.pm/logger/Logger.html) configuration. For example in config.exs:

config :logger, :default_handler,
  formatter: LoggerJSON.Formatters.GoogleCloud.new(metadata: :all, project_id: "logger-101")

or during runtime:

formatter = LoggerJSON.Formatters.Basic.new(%{metadata: {:all_except, [:conn]}})
:logger.update_handler_config(:default, :formatter, formatter)


Output (one line per event):

```json
{"time":"2026-05-02T01:23:45.678Z","severity":"info","message":"Hello","metadata":{...}}
```

To filter Logger metadata down to a specific allow-list, pass it through:

```elixir
Zexbox.Logging.install_json_handler!(metadata: [:request_id, :trace_id, :user_id])
```

See `Zexbox.Logging.JsonHandler` for the full option list, including
redactor support for stripping sensitive metadata before serialisation.

## Metrics

In order to setup metrics with InfluxDB you'll need to add the following configuration:
Expand Down
22 changes: 21 additions & 1 deletion lib/zexbox/logging.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,29 @@ defmodule Zexbox.Logging do
Module for logging events in Zexbox.
"""

alias Zexbox.Logging.LogHandler
alias Zexbox.Logging.{JsonHandler, LogHandler}
alias Zexbox.Telemetry

@doc """
Installs a JSON formatter on the default `:logger` handler so every
log event is emitted as a single JSON line.

Designed to mirror the behaviour of opsbox's `JsonFormatter` on the Ruby
side. Multi-line content (Elixir struct inspections, multi-line SQL,
stack traces) collapses into a single Elasticsearch document at the
ingest layer rather than fanning out into many.

Typical usage in `config/runtime.exs`:

if config_env() == :prod do
Zexbox.Logging.install_json_handler!()
end

See `Zexbox.Logging.JsonHandler` for the full option list.
"""
@spec install_json_handler!(keyword()) :: :ok | {:error, term()}
defdelegate install_json_handler!(opts \\ []), to: JsonHandler, as: :install!

@doc """
Attaches Telemetry handlers for Phoenix controller events.

Expand Down
76 changes: 76 additions & 0 deletions lib/zexbox/logging/json_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
defmodule Zexbox.Logging.JsonHandler do
@moduledoc """
Replaces the default `:logger` handler's formatter with a JSON formatter
suitable for Logstash / Elasticsearch ingestion.

Emits one JSON object per line, so multi-line content (Elixir struct
inspections, multi-line SQL, stack traces) collapses into a single log
event at the ingest layer instead of fanning out into N separate
Elasticsearch documents.

This is the Phoenix / Elixir equivalent of opsbox's `JsonFormatter` for Ruby.
Wraps `LoggerJSON.Formatters.Basic` with sensible defaults for the
Zappi log-ingest pipeline.

## Setup

In your application's `config/runtime.exs`:

if config_env() == :prod do
Zexbox.Logging.JsonHandler.install!()
end

After install, every log line is a single JSON object:

{"time":"...","severity":"info","message":"...","metadata":{...}}

Logstash's existing `kubernetes.container.name` rules can then JSON-parse
these into structured fields the same way they do for opsbox-formatted
Ruby logs.

## Options

* `:metadata` - which `Logger` metadata keys to include. Defaults to
`:all` (every key set via `Logger.metadata/1` or
`Logger.put_application_level/2`). Pass a list (e.g.
`[:request_id, :trace_id]`) to filter, or `[]` to omit metadata.

* `:redactors` - a list of `LoggerJSON.Redactor` modules applied to
metadata values before serialisation, e.g. for stripping sensitive
keys. Defaults to `[]`.

## Idempotency

Safe to call multiple times — `install!/1` simply swaps the formatter
on the existing default handler. Subsequent calls overwrite the previous
formatter config.
"""

@doc """
Install the JSON formatter on the `:default` `:logger` handler.

Returns `:ok` on success or `{:error, reason}` if the default handler
hasn't been configured (typically only in unusual test setups).

## Examples

iex> Zexbox.Logging.JsonHandler.install!()
:ok

iex> Zexbox.Logging.JsonHandler.install!(metadata: [:request_id, :trace_id])
:ok
"""
@spec install!(keyword()) :: :ok | {:error, term()}
def install!(opts \\ []) do
formatter_config = %{
metadata: Keyword.get(opts, :metadata, :all),
redactors: Keyword.get(opts, :redactors, [])
}

:logger.update_handler_config(
:default,
:formatter,
{LoggerJSON.Formatters.Basic, formatter_config}
)
end
end
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Zexbox.MixProject do
[
app: :zexbox,
version: "1.5.1",
elixir: "~> 1.14",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
dialyzer: [plt_add_apps: [:mix, :ex_unit]],
description: description(),
Expand Down Expand Up @@ -37,7 +37,9 @@ defmodule Zexbox.MixProject do
{:doctor, "~> 0.22.0", only: [:dev, :test]},
{:ex_doc, "~> 0.35.1", only: :dev, runtime: false},
{:instream, "~> 2.2"},
{:jason, "~> 1.4"},
{:ldclient, "~> 3.8.0", hex: :launchdarkly_server_sdk},
{:logger_json, "~> 7.0"},
{:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false},
{:mock, "~> 0.3.0", only: :test},
{:sobelow, "~> 0.8", only: [:dev, :test]},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"},
"ldclient": {:hex, :launchdarkly_server_sdk, "3.8.0", "4e900c33cfa9fdcd80f01de371d1108eb8f3834f41a1c56d1f739597a6d8bf57", [:rebar3], [{:certifi, "~> 2.14", [hex: :certifi, repo: "hexpm", optional: false]}, {:eredis, "1.7.1", [hex: :eredis, repo: "hexpm", optional: false]}, {:jsx, "3.1.0", [hex: :jsx, repo: "hexpm", optional: false]}, {:lru, "2.4.0", [hex: :lru, repo: "hexpm", optional: false]}, {:shotgun, "1.2.1", [hex: :shotgun, repo: "hexpm", optional: false]}, {:uuid, "~> 2.0.2", [hex: :uuid_erl, repo: "hexpm", optional: false]}, {:verl, "1.0.1", [hex: :verl, repo: "hexpm", optional: false]}, {:yamerl, "0.10.0", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "015deb283e9e964ebd02cc3e95c21828ff41ce62b6115793d4e93c01cd6af661"},
"logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"},
"lru": {:hex, :lru, "2.4.0", "a8f9967ca9b6f260baa19e2efb2aeb3853a3f5bd5f8416f537a672294b38c1bc", [:rebar3], [], "hexpm", "4fcf77e882b5e57eca068999acba4386a20dbce8e446c98c6a0f8fb3d170afeb"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
Expand Down
73 changes: 73 additions & 0 deletions test/zexbox/logging/json_handler_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
defmodule Zexbox.Logging.JsonHandlerTest do
use ExUnit.Case, async: false

alias Zexbox.Logging.JsonHandler

setup do
{:ok, original_config} = :logger.get_handler_config(:default)
on_exit(fn -> :logger.update_handler_config(:default, original_config) end)
:ok
end

describe "install!/1" do
test "swaps the default handler's formatter to LoggerJSON.Formatters.Basic" do
assert :ok = JsonHandler.install!()

{:ok, %{formatter: {formatter_module, _config}}} =
:logger.get_handler_config(:default)

assert formatter_module == LoggerJSON.Formatters.Basic
end

test "passes metadata: :all by default" do
assert :ok = JsonHandler.install!()

{:ok, %{formatter: {_module, config}}} =
:logger.get_handler_config(:default)

assert config.metadata == :all
end

test "passes through a metadata allow-list" do
assert :ok = JsonHandler.install!(metadata: [:request_id, :trace_id])

{:ok, %{formatter: {_module, config}}} =
:logger.get_handler_config(:default)

assert config.metadata == [:request_id, :trace_id]
end

test "passes through redactors" do
redactors = [{LoggerJSON.Redactors.RedactKeys, ["password"]}]
assert :ok = JsonHandler.install!(redactors: redactors)

{:ok, %{formatter: {_module, config}}} =
:logger.get_handler_config(:default)

assert config.redactors == redactors
end

test "is idempotent across repeated calls" do
assert :ok = JsonHandler.install!()
assert :ok = JsonHandler.install!()
assert :ok = JsonHandler.install!(metadata: [:request_id])

{:ok, %{formatter: {formatter_module, config}}} =
:logger.get_handler_config(:default)

assert formatter_module == LoggerJSON.Formatters.Basic
assert config.metadata == [:request_id]
end
end

describe "Zexbox.Logging.install_json_handler!/1 delegate" do
test "the parent module exposes the same function" do
assert :ok = Zexbox.Logging.install_json_handler!()

{:ok, %{formatter: {formatter_module, _config}}} =
:logger.get_handler_config(:default)

assert formatter_module == LoggerJSON.Formatters.Basic
end
end
end
Loading