feat: add JSON log handler for Logstash / Elasticsearch ingestion#57
feat: add JSON log handler for Logstash / Elasticsearch ingestion#57tsu-shiuan wants to merge 2 commits intomasterfrom
Conversation
Adds Zexbox.Logging.install_json_handler!/1 (and the underlying
Zexbox.Logging.JsonHandler) that 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 (Elixir struct inspections, multi-line SQL, stack
traces) out across many docs at the Filebeat-ingest layer.
Bumps the :elixir constraint from ~> 1.14 to ~> 1.15 to match
logger_json 7.x's requirement. Adds logger_json ~> 7.0 and
jason ~> 1.4 as dependencies.
Companion changes that need to land separately to deliver the
end-to-end pipeline:
- kubernetes-infra: extend the Logstash filter that JSON-parses
the rails container's message field to also match the phoenix
and elixir container names. The existing block already handles
the JSON shape this handler produces.
- per-app: 3-line addition to runtime.exs, e.g.
if config_env() == :prod do
Zexbox.Logging.install_json_handler!()
end
Tests: 6 new tests covering formatter swap, default options,
metadata allow-list, redactor passthrough, idempotency, and the
parent-module delegate. Full zexbox suite: 37 tests, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Group Zexbox.Logging.JsonHandler + Zexbox.Logging.LogHandler into
the brace-expansion form so the file uses a single alias style for
that namespace.
[Consistency] Most of the time you are using the multi-alias/require/import/use
syntax, but here you are using multiple single directives.
No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
brendon9x
left a comment
There was a problem hiding this comment.
Top-level question: should this wrapper exist at all?
The biggest piece of feedback isn't about the implementation — it's about whether the install_json_handler!/1 API needs to exist in the first place.
The Elixir ecosystem norm is declarative config, not runtime installers. Phoenix, Ecto, Plug, Telemetry all document a config snippet to paste; none of them ship a Foo.install!/1 wrapper. logger_json itself tells users to write this in config/runtime.exs:
config :logger, :default_handler,
formatter: {LoggerJSON.Formatters.Basic, %{}}That is the entire setup. Concrete downsides of the runtime-call approach in this PR vs. pure config:
- Late activation.
:loggeris configured before the application starts. Anything logged during app boot — supervisor start, dependency init, early telemetry — goes out in the old text format untilinstall_json_handler!()runs. Pure config makes JSON active from the very first log line. - Hidden seam. A new engineer looking at
config/runtime.exsto understand why logs look the way they do sees nothing JSON-related. They have to know to grep application code forinstall_json_handler. - API surface for two lines of config. Both options (
:metadata,:redactors) are passthroughs into a map that would otherwise live in config. The wrapper adds a maintenance burden without abstracting anything.
The legitimate counter-argument is "we want one place to set Zappi-wide defaults so apps don't drift." That's real, but it's better served by either:
- A documented config snippet in the Zexbox README with the standardised defaults, or
- Exposing
Zexbox.Logging.json_formatter_config/1that returns the{LoggerJSON.Formatters.Basic, %{...}}tuple to splat into config — keeping the configuration declarative while still centralising defaults.
Recommendation: drop install!/1 and the delegate. Replace with a README section showing the exact config :logger, :default_handler, ... line plus the standardised redactors/metadata list. If centralised defaults are wanted, expose them as a function that returns config, not one that mutates :logger at runtime.
The remaining two notes assume the wrapper survives this discussion.
The ! is wrong for the return type
lib/zexbox/logging/json_handler.ex declares:
@spec install!(keyword()) :: :ok | {:error, term()}and the moduledoc explicitly documents the {:error, reason} return. In Elixir, ! means "raises on failure." A function that returns {:error, _} must not be named with a bang. Same applies to the defdelegate ... as: :install! in lib/zexbox/logging.ex.
Pick one:
- Drop the bang:
install/1 :: :ok | {:error, term()}, or - Keep the bang and actually raise on
{:error, reason}(e.g. wrap the:logger.update_handler_config/3call in acasethat raises aRuntimeErrorwith the reason).
Don't pin :jason as a direct dep
mix.exs adds {:jason, "~> 1.4"} alongside {:logger_json, "~> 7.0"}. Per the logger_json docs:
By default, LoggerJSON is using Jason as the JSON encoder. If you use Elixir 1.18 or later, you can use the built-in JSON module as the encoder. To do this, you need to set the
:encoderoption in yourconfig.exsfile. This setting is only available at compile-time:config :logger_json, encoder: JSON
Pinning :jason in zexbox pushes the encoder choice down onto every consuming app, even those on Elixir 1.18+ that would prefer the stdlib JSON module. That's exactly the kind of constraint a base library shouldn't impose.
Recommendation: drop the :jason line from mix.exs. Let consumers pick their encoder via config :logger_json, encoder: ….
brendon9x
left a comment
There was a problem hiding this comment.
As per comment. I don't think this needs to exist other than docs.
Maybe an Igniter task but that feels like overkill.
An igniter task sounds like a good starting point for having the utility of "Set up my application for observability". It should be easy enough to add with a ton of benefits |
Summary
Adds
Zexbox.Logging.install_json_handler!/1(and the underlyingZexbox.Logging.JsonHandler). Swaps the default:loggerhandler's formatter for a JSON one wrappingLoggerJSON.Formatters.Basic. Every log event becomes a single JSON line so multi-line content (Elixir struct inspections, multi-line SQL, stack traces) collapses into one Elasticsearch document at the ingest layer instead of fanning out into many.This is the Phoenix / Elixir equivalent of opsbox's
JsonFormatterfor Ruby.Why
Today filebeat ships every line of every container as its own ES document. Phoenix apps emit multi-line plain text (default
Logger.bare_format), so a single struct inspection or SQLINSERT … VALUES (…), (…), …statement materialises as dozens of indexed docs. Today's production analysis showed live-api (the syndicated-api in our index) emitting ~2.9M docs/day from this fan-out alone, and data-stories ~580k/day from per-line%Phoenix.Socket{}struct dumps.The Ruby pipeline doesn't have this problem because opsbox's
JsonFormatteralready emits one JSON line per event, and Logstash'sif [kubernetes][container][name] == "rails"block JSON-parses each line into structured[log]fields. This PR brings that same pattern to Phoenix.Usage
In
config/runtime.exsof the consuming Phoenix app:Output (one line per event):
{"time":"2026-05-02T01:23:45.678Z","severity":"info","message":"...","metadata":{...}}Optional metadata allow-list:
Companion changes (not in this PR)
End-to-end the pipeline needs two more changes, each independently safe:
kubernetes-infraLogstash config — extend the existingkubernetes.container.name == "rails"block in02_config_maps.yml.j2:103-155to also match thephoenixandelixircontainer names. The existing JSON parser handles the shape this handler emits.runtime.exschange.skip_on_invalid_json => trueis already set in the Logstash json filter, so a Phoenix container still emitting plain text (pre-rollout) is a safe no-op.The intended sequence: ship this gem → release → ship the kubernetes-infra change → roll Phoenix apps one at a time. Each phase is reversible.
Changes
lib/zexbox/logging/json_handler.ex— new module, single public functioninstall!/1.lib/zexbox/logging.ex— exposesinstall_json_handler!/1as a delegate, mirroring the existingattach_controller_logs!/0pattern.mix.exs— adds{:logger_json, "~> 7.0"}and{:jason, "~> 1.4"}as runtime deps; bumps:elixirconstraint from~> 1.14to~> 1.15to match logger_json 7.x's requirement. Of the consuming Phoenix apps, onlynarratoris still on~> 1.14in its mix.exs and can bump trivially;live-api,data-stories,brand_service,zappi-apiare already on 1.18+ anddata-apion 1.15.mix.lock— lockslogger_json 7.0.4.README.md— documents the new### JSON-formatted logs for Logstash / Elasticsearchsection.CHANGELOG.md—Unreleasedentry; left version bump to whoever cuts the release.Tests
test/zexbox/logging/json_handler_test.exs— 6 cases covering formatter swap, default options, metadata allow-list, redactor passthrough, idempotency, parent-module delegate. Thesetupblock snapshots and restores the default handler config so the tests don't bleed into other suites.Local run: 37 tests, 0 failures across the full zexbox suite.
Risk
Low. New code path with no implicit activation — apps opt in via the explicit
install_json_handler!()call. The Elixir constraint bump is the only consumer-visible change in this PR; nothing existing in zexbox's API breaks.Claude, but not flawed