Production Logging for Elixir Applications

Khimananda Oli 7 min read Programming and Languages
Production Logging for Elixir Applications

By Khimananda Oli | Last reviewed: August 2026

Debugging a distributed system at 3 AM requires more than scattered console output; it demands queryable, structured data that correlates across services. Effective production logging for Elixir applications hinges on configuring the standard Logger library to emit machine-readable JSON asynchronously, preventing I/O bottlenecks from degrading your BEAM runtime performance. This guide covers the exact configuration patterns, backend choices, and metadata strategies needed to turn raw logs into actionable observability signals without sacrificing throughput or compliance.

OTP ProcessLogger.info/2Logger CoreLevel FilterMetadata MergeAsync QueueJSON BackendNon-blocking IOAggregatorLoki / ELK
Production logging for Elixir applications relies on decoupling log emission from I/O via an asynchronous queue to protect BEAM schedulers.

How do you configure structured production logging for Elixir applications?

The default Elixir logger outputs human-readable text suitable for development but disastrous for production parsing. In 2026, every serious deployment must emit structured JSON. This allows log aggregators like Grafana Loki or Elasticsearch to index fields automatically, enabling queries like {app="billing", user_id="123"} instead of fragile regex matching against unstructured strings. The community standard for this is logger_json, which formats logs as single-line JSON objects compatible with most cloud-native stacks.

Installing and configuring logger_json

Add the dependency to your mix.exs file and configure the formatter in your runtime configuration. Avoid placing production logging configuration in config/dev.exs or config/prod.exs; use runtime.exs to read environment variables at boot time, ensuring the same release artifact works across staging and production.

# mix.exs
defp deps do
  [
    {:logger_json, "~> 6.0"},
    {:jason, "~> 1.4"}
  ]
end

In your config/runtime.exs, replace the default formatter conditionally based on the environment. This pattern ensures local development remains readable while production emits structured data:

# config/runtime.exs
if config_env() == :prod do
  config :logger, :default_formatter,
    format: {LoggerJSON.Formatters.Basic, :format},
    metadata: [:request_id, :trace_id, :span_id, :user_id],
    json_encoder: Jason

  # Set level via env var for dynamic adjustment
  level = System.get_env("LOG_LEVEL", "info") |> String.to_existing_atom()
  config :logger, level: level
end

A common mistake is forgetting to add :jason explicitly. While logger_json depends on it, some edge cases in umbrella apps fail to resolve the encoder at runtime without the direct dependency. Always declare it. For teams following broader standards, aligning this setup with structured logging best practices ensures consistency across polyglot microservices.

Why must Elixir logging be asynchronous in production?

The BEAM VM schedules lightweight processes across fixed CPU cores. If a logging call blocks waiting for disk I/O or network acknowledgment, it stalls that scheduler, potentially cascading into latency spikes across unrelated requests. Synchronous logging is acceptable for low-traffic internal tools but is a liability for any customer-facing Phoenix application handling concurrent connections.

Configuring async mode safely

Elixir’s Logger supports an asynchronous mode where log messages are sent to a dedicated handler process via message passing. The calling process returns immediately, resuming business logic while the handler batches and writes logs in the background.

# config/runtime.exs
config :logger, :default_handler,
  level: :info,
  module: :logger_std_h,
  config: [
    type: :standard_io,
    max_size: 10_000,      # Drop oldest if queue exceeds this
    burst_limit: 5000,     # Max messages per second
    sync_mode_qlen: 200,   # Switch to sync only if queue < 200
    drop_mode_qlen: 9000   # Start dropping at this threshold
  ]

The parameters above define a safety valve. Under normal load, logging is fully async. If the queue grows beyond sync_mode_qlen, Logger temporarily switches to synchronous mode to apply backpressure rather than losing logs. Only when the queue approaches memory limits does it drop entries. This hybrid approach balances reliability with availability—a critical trade-off in incident response scenarios where partial data beats total silence.

Synchronous Logging (Risky)Request ProcBLOCKEDDisk/Net IOScheduler stalled → Latency spikeAsynchronous Logging (Safe)Request ProcMSG SENDLogger QueueWriterProcess resumes instantly → Stable P99
Asynchronous logging prevents I/O operations from blocking BEAM schedulers, maintaining consistent request latency under load.

How do you integrate OpenTelemetry with Elixir Logger?

Logs without trace context are orphaned anecdotes. Modern observability requires correlating log entries with specific traces and spans. The opentelemetry_logger_metadata_bridge package automatically injects trace_id and span_id into every log entry emitted within an active span. This is non-negotiable for debugging distributed transactions in Phoenix or Broadway pipelines.

Bridge configuration and metadata propagation

After installing opentelemetry and the bridge package, configure the logger to include trace fields in its metadata whitelist. Without this explicit allowlist, the bridge attaches the data internally but the formatter ignores it.

# config/runtime.exs
config :logger, :default_formatter,
  metadata: [:request_id, :trace_id, :span_id, :otel_trace_flags]

# Ensure the bridge starts before your app supervision tree
config :opentelemetry_logger_metadata_bridge,
  enabled: true

When a request enters your Phoenix endpoint, the OpenTelemetry instrumentation creates a root span. Every subsequent Logger.info/2 call within that request cycle automatically carries the trace ID. When viewing logs in Jaeger or Tempo, you can jump directly from a slow trace to its associated log lines. For teams adopting broader telemetry standards, see OpenTelemetry: the observability standard for cross-language alignment strategies.

What metadata should you include in Elixir production logs?

Metadata transforms generic messages into queryable events. However, over-enrichment inflates storage costs and risks leaking PII. A disciplined metadata strategy includes three tiers: correlation identifiers, operational context, and domain markers.

  • Correlation: request_id, trace_id, span_id, correlation_id (for cross-service workflows).
  • Operational: node, pid, mfa (module/function/arity), file, line. Include these in dev/staging; consider stripping file and line in high-volume production to reduce payload size.
  • Domain: user_id, tenant_id, order_id, feature_flag. These enable business-level debugging like "show all logs for tenant X during the outage window."

Never log sensitive fields: passwords, tokens, full credit card numbers, or PHI. Use metadata filters or redaction libraries to scrub known keys before formatting. In regulated environments, this isn't optional—it's audit evidence. Teams managing compliance should reference automating SOC 2 compliance evidence to embed log sanitization checks directly into CI pipelines.

How do Elixir logging backends compare for production workloads?

Choosing the right backend depends on your infrastructure, volume, and compliance requirements. The table below compares the most common options used in 2026 production deployments.

BackendBest ForAsync SupportStructured OutputTrade-offs
logger_json + stdioKubernetes / Cloud NativeNativeJSON (native)Requires external aggregator; no built-in buffering
Sentry LoggerError tracking & alertsVia handlerSentry protocolOnly for errors/crashes; not general-purpose logging
LoggerFileBackendOn-prem / air-gappedYesConfigurableDisk I/O risk; manual rotation needed; no central search
Logflare / HTTPServerless / EdgeBufferedJSONNetwork dependency; potential data loss on outage
Broadway + KafkaHigh-volume audit trailsFully asyncAnyComplexity overhead; requires Kafka cluster

For most Phoenix applications deployed on Kubernetes or ECS, logger_json writing to stdout is the correct default. Container orchestrators capture stdout natively and forward it to your chosen aggregator. Custom HTTP or file backends introduce failure modes that compound during outages—exactly when you need logs most. Reserve specialized backends for specific compliance or legacy constraints.

Start: Choose BackendContainer Orchestrated?YESNOlogger_json + stdioOn-Prem / Air-Gapped?High Volume Audit?NOYESLoggerFileBackendBroadway+Kafka
Backend selection decision tree for production logging for Elixir applications based on deployment topology and compliance needs.

Implementing Production Logging for Elixir Applications Safely

Reliable observability is a product of deliberate engineering, not accidental configuration. Start with logger_json on stdout, enforce asynchronous delivery with bounded queues, and enrich every entry with trace context via OpenTelemetry. Define your metadata schema early and treat it as a contract between your application and your ops team. Review log volume weekly—what seems useful in staging often becomes noise at scale. If your current setup lacks structure or async safety, prioritize fixing those two gaps before adding new integrations. For architecture reviews or help migrating legacy Elixir systems to modern observability standards, reach out to discuss your specific deployment.

Frequently Asked Questions

Logger with the built-in Erlang/OTP 27 handlers is standard. For structured JSON output, use logger_json or logfmt to ensure compatibility with modern observability platforms like Datadog or Grafana Loki without adding heavy dependencies.

Add logger_json to your deps and set the formatter in config/prod.exs. Configure metadata keys explicitly to include request_id and trace_id, ensuring logs parse correctly in aggregation tools without custom parsing rules.

No, OTP 27 Logger uses non-blocking handlers by default. Messages are sent asynchronously to handler processes, preventing log backpressure from slowing down critical application requests during high traffic spikes.

Use Plug.RequestId middleware to generate or extract IDs from headers. Configure Logger metadata to include :request_id globally so every log line within that connection lifecycle is automatically correlated.

Set info as the minimum level. Reserve debug for local development only. Use warning for recoverable issues and error for failures requiring alerts, reducing noise and storage costs significantly.

Configure the :filter_parameters option in your endpoint or use a custom Logger filter module. This scrubs passwords, tokens, and PII before formatting, preventing accidental leaks to external log aggregators.

Yes, use the ex_aws_cloudwatch_logs handler or ship via stdout to ECS/Fargate agents. Direct API calls add latency, so prefer container-native log drivers for better performance and reliability.

Propagate W3C Trace Context headers between services. Extract trace_id and span_id into Logger metadata using telemetry spans, enabling end-to-end tracing across clustered BEAM nodes and external microservices.

Handler overload or misconfigured log levels often cause drops. Check handler queue lengths via :logger.get_handler_config and ensure async mode is enabled to prevent silent message discarding under load.

Use the built-in file handler with size and count rotation options. Avoid external logrotate for BEAM apps since it can break file descriptors; let OTP manage rotation natively.

Yes, excessive logging increases CPU, memory, and egress costs. Sample verbose events using :logger.allow_level or conditional metadata checks to maintain observability without burning budget on low-value data.

Use ExUnit.CaptureLog in tests to assert message content and metadata. Verify formatter output matches expected JSON schema to catch configuration drift before deploying to production environments.

Not initially. Stdout plus a managed collector suffices for most startups. Only adopt dedicated services when query complexity, retention needs, or compliance requirements exceed basic aggregation capabilities.

Elixir uses asynchronous OTP handlers versus Laravel’s synchronous Monolog writers. Elixir avoids blocking web requests during I/O, offering superior throughput and fault isolation under heavy concurrent loads.

Always include request_id, node name, PID, and module/function context. These fields enable rapid debugging and correlation without requiring developers to manually annotate every log statement in code.