Skip to main content

JSONL for Log Processing

Why structured logging with JSONL has become the industry standard for modern application monitoring and observability

Why JSONL for Logging?

Structured vs Unstructured

Traditional plain-text logs are hard to parse and query. JSONL provides structure while maintaining the simplicity of line-based logging - perfect for real-time streaming and analysis.

Plain Text:

2026-01-15 10:30:45 ERROR Database connection failed

JSONL:

{"timestamp":"2026-01-15T10:30:45Z","level":"error","msg":"Database connection failed","db":"users","host":"prod-01"}

Industry Adoption

Newline-delimited JSON is the common currency of the logging ecosystem. It shows up in three distinct places: what applications write, what collectors carry, and what backends accept on the wire.

  • Elasticsearch and OpenSearch: the Bulk API body is newline-delimited JSON
  • Fluent Bit: the lightweight C collector that is now the common default in Kubernetes
  • Vector: Rust collector with the VRL transform language
  • OpenTelemetry Collector: filelog receiver in, JSON Lines file exporter out
  • Grafana Loki: stores the raw line, indexes only the labels
  • Fluentd and Logstash: the long-standing Ruby and JVM aggregators
  • Splunk, Datadog, CloudWatch: hosted platforms that ingest JSON events

Application Logging

Node.js with Winston

Winston is the most popular logging library for Node.js with built-in JSONL support.

const winston = require('winston');

// Configure Winston for JSONL output
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    // Write all logs to application.jsonl
    new winston.transports.File({
      filename: 'application.jsonl',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.json()
      )
    }),
    // Also log to console for development
    new winston.transports.Console({
      format: winston.format.simple()
    })
  ]
});

// Add request context middleware (Express)
const addContext = (req, res, next) => {
  req.logger = logger.child({
    request_id: req.id,
    user_id: req.user?.id,
    ip: req.ip,
    user_agent: req.get('user-agent')
  });
  next();
};

// Log examples
logger.info('Server started', { port: 3000, env: 'production' });

logger.error('Database query failed', {
  query: 'SELECT * FROM users',
  error: err.message,
  stack: err.stack,
  duration_ms: 1250
});

logger.warn('High memory usage', {
  memory_used_mb: 512,
  memory_limit_mb: 1024,
  threshold_percent: 80
});

// With request context
app.get('/api/users', addContext, async (req, res) => {
  req.logger.info('Fetching users', {
    page: req.query.page,
    limit: req.query.limit
  });
  // ... handler code
});

Pro Tip: Use child loggers to automatically include context like request IDs in all logs within a request.

Python with Structlog

Structlog makes structured logging in Python simple and performant.

import structlog
import logging
import sys

# Configure structlog for JSONL output
structlog.configure(
    processors=[
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.UnicodeDecoder(),
        structlog.processors.JSONRenderer()
    ],
    context_class=dict,
    logger_factory=structlog.stdlib.LoggerFactory(),
    cache_logger_on_first_use=True,
)

# Create logger
logger = structlog.get_logger()

# Basic logging
logger.info("server_started", port=8000, workers=4)

logger.error(
    "database_connection_failed",
    host="db.example.com",
    port=5432,
    retry_count=3,
    exc_info=True
)

# Add context for multiple log entries
log_with_context = logger.bind(
    user_id=12345,
    request_id="req_abc123",
    session_id="sess_xyz789"
)

log_with_context.info("user_action", action="login", ip="192.168.1.100")
log_with_context.info("user_action", action="view_profile")

# Django/Flask middleware example
class LoggingMiddleware:
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        request_id = environ.get('HTTP_X_REQUEST_ID', 'unknown')
        logger = structlog.get_logger().bind(
            request_id=request_id,
            path=environ.get('PATH_INFO'),
            method=environ.get('REQUEST_METHOD')
        )

        logger.info("request_started")
        try:
            return self.app(environ, start_response)
        finally:
            logger.info("request_completed")

Go with Zerolog

Zerolog is a zero-allocation JSON logger for Go with excellent performance.

package main

import (
    "os"
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

func main() {
    // Configure logger for JSONL file output
    file, err := os.OpenFile(
        "application.jsonl",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY,
        0644,
    )
    if err != nil {
        panic(err)
    }
    defer file.Close()

    log.Logger = zerolog.New(file).With().Timestamp().Caller().Logger()

    // Basic logging
    log.Info().
        Str("event", "server_start").
        Int("port", 8080).
        Str("env", "production").
        Msg("Server starting")

    // Error logging with context
    err = connectDatabase()
    if err != nil {
        log.Error().
            Err(err).
            Str("host", "db.example.com").
            Int("port", 5432).
            Int("retry", 3).
            Msg("Database connection failed")
    }

    // HTTP middleware example
    func loggingMiddleware(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()

            // Create logger with request context
            logger := log.With().
                Str("request_id", r.Header.Get("X-Request-ID")).
                Str("method", r.Method).
                Str("path", r.URL.Path).
                Str("remote_addr", r.RemoteAddr).
                Logger()

            logger.Info().Msg("request_started")

            // Wrap response writer to capture status
            wrapped := &statusWriter{ResponseWriter: w}
            next.ServeHTTP(wrapped, r)

            logger.Info().
                Int("status", wrapped.status).
                Dur("duration_ms", time.Since(start)).
                Msg("request_completed")
        })
    }
}

Java with Logback

Configure Logback for JSON structured logging in Spring Boot applications.

<!-- logback-spring.xml -->
<configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>application.jsonl</file>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeContext>false</includeContext>
            <timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="FILE" />
    </root>
</configuration>
// Java application code
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

public class UserService {
    private static final Logger logger = LoggerFactory.getLogger(UserService.class);

    public User getUser(Long userId) {
        // Add context to MDC (Mapped Diagnostic Context)
        MDC.put("user_id", userId.toString());
        MDC.put("operation", "get_user");

        try {
            logger.info("Fetching user from database");
            User user = userRepository.findById(userId);

            if (user == null) {
                logger.warn("User not found");
                return null;
            }

            logger.info("User retrieved successfully");
            return user;
        } catch (Exception e) {
            logger.error("Error fetching user", e);
            throw e;
        } finally {
            MDC.clear();
        }
    }
}

Rust with Tracing

use tracing::{info, error, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

fn main() {
    // Configure JSON formatter
    let file = std::fs::File::create("application.jsonl")
        .expect("Failed to create log file");

    tracing_subscriber::registry()
        .with(
            tracing_subscriber::fmt::layer()
                .json()
                .with_writer(std::sync::Arc::new(file))
        )
        .init();

    info!(port = 8080, env = "production", "Server started");

    // Structured logging with context
    let user_id = 12345;
    let request_id = "req_abc123";

    info!(
        user_id = user_id,
        request_id = request_id,
        action = "login",
        "User logged in"
    );

    // Error logging
    if let Err(e) = connect_database() {
        error!(
            error = %e,
            host = "db.example.com",
            port = 5432,
            "Database connection failed"
        );
    }
}

ELK Stack Integration

Elasticsearch Bulk API

Ingest JSONL logs directly into Elasticsearch using the Bulk API for high-throughput indexing.

# Bulk API expects NDJSON (JSONL) format
# Each document needs an action line followed by the document

curl -X POST "localhost:9200/_bulk" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @logs.jsonl

# logs.jsonl format:
{"index": {"_index": "logs-2026.01.15"}}
{"timestamp": "2026-01-15T10:30:00Z", "level": "info", "message": "Server started", "service": "api"}
{"index": {"_index": "logs-2026.01.15"}}
{"timestamp": "2026-01-15T10:30:05Z", "level": "error", "message": "Database timeout", "service": "api"}
{"index": {"_index": "logs-2026.01.15"}}
{"timestamp": "2026-01-15T10:30:10Z", "level": "warn", "message": "High memory usage", "service": "worker"}

OpenSearch takes the same envelope. A shipper that already speaks the Elasticsearch bulk protocol usually needs only a new endpoint and new credentials. See Loki and OpenSearch below for the differences that do matter.

Logstash Pipeline

Process JSONL logs with Logstash for filtering, enrichment, and routing.

# logstash.conf
input {
  file {
    path => "/var/log/application.jsonl"
    codec => "json_lines"
    start_position => "beginning"
    sincedb_path => "/dev/null"
  }
}

filter {
  # Parse timestamp
  date {
    match => ["timestamp", "ISO8601"]
    target => "@timestamp"
  }

  # Add hostname
  mutate {
    add_field => { "host" => "%{HOSTNAME}" }
  }

  # Extract error details
  if [level] == "error" {
    mutate {
      add_tag => ["error"]
      add_field => { "alert" => "true" }
    }
  }

  # GeoIP lookup for IP addresses
  if [ip] {
    geoip {
      source => "ip"
      target => "geoip"
    }
  }

  # User-Agent parsing
  if [user_agent] {
    useragent {
      source => "user_agent"
      target => "ua"
    }
  }
}

output {
  elasticsearch {
    hosts => ["localhost:9200"]
    index => "logs-%{+YYYY.MM.dd}"
  }

  # Also output errors to separate index
  if [level] == "error" {
    elasticsearch {
      hosts => ["localhost:9200"]
      index => "errors-%{+YYYY.MM.dd}"
    }
  }
}

Kibana Queries

Once logs are in Elasticsearch, query them with Kibana's powerful search.

# Find all errors in the last hour
level:error AND @timestamp:[now-1h TO now]

# Search for specific user activity
user_id:12345 AND action:(login OR logout)

# Find slow database queries
service:database AND duration_ms:>1000

# Search across multiple fields
message:"connection failed" OR error.message:"connection failed"

# Complex boolean query
(level:error OR level:warn) AND service:api AND NOT status:404

# Aggregation query (in Kibana Dev Tools)
GET /logs-*/_search
{
  "size": 0,
  "aggs": {
    "errors_by_service": {
      "terms": {
        "field": "service.keyword",
        "size": 10
      },
      "aggs": {
        "error_count": {
          "filter": {
            "term": { "level": "error" }
          }
        }
      }
    }
  }
}

Filebeat Configuration

Ship JSONL logs to Elasticsearch with Filebeat for lightweight, efficient log forwarding.

# filebeat.yml
filebeat.inputs:
- type: log
  enabled: true
  paths:
    - /var/log/application.jsonl
  json.keys_under_root: true
  json.add_error_key: true
  json.message_key: message

# Optional: Add fields
  fields:
    environment: production
    datacenter: us-east-1
  fields_under_root: true

# Multiline support for stack traces
  multiline.type: pattern
  multiline.pattern: '^\s'
  multiline.negate: false
  multiline.match: after

# Output to Elasticsearch
output.elasticsearch:
  hosts: ["localhost:9200"]
  index: "logs-%{+yyyy.MM.dd}"

# Or output to Logstash
output.logstash:
  hosts: ["localhost:5044"]

# Enable modules
filebeat.modules:
- module: nginx
  access:
    enabled: true
    var.paths: ["/var/log/nginx/access.jsonl"]

Fluentd Log Aggregation

Fluentd defined the shape of the modern log pipeline: a tail input, a chain of filters, a buffered output, and a plugin ecosystem for everything else. It remains a CNCF graduated project under active maintenance; the caveat is throughput rather than abandonment - see Modern Log Shippers below.

Fluentd Configuration

# fluent.conf
<source>
  @type tail
  path /var/log/application.jsonl
  pos_file /var/log/td-agent/application.pos
  tag application.logs

  <parse>
    @type json
    time_key timestamp
    time_format %Y-%m-%dT%H:%M:%S%z
  </parse>
</source>

# Filter: Add hostname and enrich data
<filter application.logs>
  @type record_transformer
  <record>
    hostname ${hostname}
    tag ${tag}
    env production
  </record>
</filter>

# Filter: Parse user agent
<filter application.logs>
  @type parser
  key_name user_agent
  reserve_data true
  <parse>
    @type user_agent
  </parse>
</filter>

# Output to Elasticsearch
<match application.logs>
  @type elasticsearch
  host localhost
  port 9200
  index_name logs-%Y.%m.%d
  type_name _doc

  <buffer>
    @type file
    path /var/log/td-agent/buffer/logs
    flush_interval 10s
    chunk_limit_size 5M
  </buffer>
</match>

# Output to S3 for archival
<match application.logs>
  @type s3
  s3_bucket my-logs-bucket
  s3_region us-east-1
  path logs/%Y/%m/%d/

  <buffer time>
    @type file
    path /var/log/td-agent/buffer/s3
    timekey 3600  # 1 hour
    timekey_wait 10m
    chunk_limit_size 256m
  </buffer>

  <format>
    @type json
  </format>
</match>

Application Integration

// Node.js: fluent-logger
const logger = require('fluent-logger');

logger.configure('application', {
  host: 'localhost',
  port: 24224,
  timeout: 3.0
});

// Send structured logs
logger.emit('user.action', {
  user_id: 12345,
  action: 'login',
  ip: '192.168.1.100',
  timestamp: new Date().toISOString()
});

logger.emit('database.query', {
  query: 'SELECT * FROM users',
  duration_ms: 45,
  rows: 100
});

// Python: fluent-logger-python
from fluent import sender
from fluent import event

logger = sender.FluentSender('application', host='localhost', port=24224)

logger.emit('user.action', {
    'user_id': 12345,
    'action': 'login',
    'ip': '192.168.1.100'
})

logger.emit('database.query', {
    'query': 'SELECT * FROM users',
    'duration_ms': 45,
    'rows': 100
})

Modern Log Shippers

Most Kubernetes clusters built in the last few years do not ship logs with Fluentd or Logstash. They run a DaemonSet of Fluent Bit, Vector, or the OpenTelemetry Collector, each of which tails container log files, parses each line as JSON, enriches it with pod metadata, and forwards it. All three read newline-delimited JSON and all three write it back out.

Fluent Bit

Fluent Bit is the lightweight C collector from the same project family as Fluentd, and now the common default for node-level collection in Kubernetes: one static binary with no language runtime, small enough for a DaemonSet on every node. A tail input follows the container log files and a parser filter turns each line into structured fields.

# fluent-bit.yaml
service:
  flush: 1
  log_level: info

pipeline:
  inputs:
    - name: tail
      path: /var/log/containers/*.log
      parser: cri
      tag: kube.*
      # Guard rails for oversized lines - see Parsing Robustness below
      skip_long_lines: on
      buffer_max_size: 1MB
      buffer_chunk_size: 256k

  filters:
    # The container runtime wraps your log line in its own envelope.
    # Parse the inner JSONL payload back out into real fields.
    - name: parser
      match: kube.*
      key_name: log
      parser: json
      reserve_data: on

    - name: kubernetes
      match: kube.*
      merge_log: on
      keep_log: off

  outputs:
    - name: http
      match: kube.*
      host: logs.example.com
      port: 443
      tls: on
      uri: /ingest
      # One JSON object per line - JSONL on the wire
      format: json_lines
      json_date_key: timestamp
      json_date_format: iso8601

Vector and VRL

Vector is a Rust collector whose distinguishing feature is VRL, the Vector Remap Language: a small expression language for rewriting events in flight. Where Logstash reaches for a Ruby filter and Fluentd for a plugin, Vector parses, redacts, and routes inside the config itself, with the transform checked at startup rather than failing on the first bad event.

# vector.toml
[sources.app_logs]
type = "file"
include = ["/var/log/application.jsonl"]
read_from = "beginning"
# Refuse a line larger than this instead of buffering it forever
max_line_bytes = 1048576

[transforms.normalize]
type = "remap"
inputs = ["app_logs"]
source = '''
  # The file source puts the raw line in .message - parse it as JSON
  parsed, parse_err = parse_json(.message)
  if parse_err != null {
    .malformed = true
  } else {
    . = object!(parsed)
  }

  # Normalize the timestamp to RFC 3339 with an explicit offset
  .timestamp = format_timestamp!(
    parse_timestamp!(.timestamp, format: "%+"),
    format: "%+"
  )

  .service = "checkout-api"
  .environment = "production"

  # Never let a secret reach the search cluster
  del(.password)
  del(.authorization)
'''

# Archive every event as JSONL, partitioned by day
[sinks.archive]
type = "file"
inputs = ["normalize"]
path = "/var/log/archive/%Y-%m-%d.jsonl"
encoding.codec = "json"

# And index the same stream for search
[sinks.search]
type = "elasticsearch"
inputs = ["normalize"]
endpoints = ["https://logs.example.com:9200"]
bulk.index = "logs-%Y.%m.%d"
compression = "gzip"

OpenTelemetry Collector

The OpenTelemetry Collector is where a vendor-neutral telemetry model meets plain newline-delimited JSON on disk. Its filelog receiver tails files and converts each line into an OpenTelemetry log record, a json_parser operator mapping your fields onto the standard shape: timestamp, severity number and text, body, attributes. Going the other way, the Protocol File Exporter specification defines its output as JSON Lines with a preferred extension of .jsonl - a file any jq pipeline can read without a proto compiler.

# otelcol-config.yaml
receivers:
  filelog:
    include: [ /var/log/application.jsonl ]
    start_at: beginning
    # Refuse a single record larger than this
    max_log_size: 1MiB
    operators:
      - type: json_parser
        parse_from: body
        timestamp:
          parse_from: attributes.timestamp
          layout_type: gotime
          layout: '2006-01-02T15:04:05.000Z07:00'
        severity:
          parse_from: attributes.level
          mapping:
            debug: debug
            info: info
            warn: warn
            error: error
            fatal: fatal

processors:
  batch:
    send_batch_size: 8192
    timeout: 5s
  resource:
    attributes:
      - key: service.name
        value: checkout-api
        action: upsert
      - key: deployment.environment
        value: production
        action: upsert

exporters:
  # JSON Lines on disk, one OTLP JSON message per line
  file:
    path: /var/log/otel/logs.jsonl
  otlphttp:
    endpoint: https://collector.example.com:4318

service:
  pipelines:
    logs:
      receivers: [filelog]
      processors: [resource, batch]
      exporters: [file, otlphttp]

Source: OpenTelemetry - Protocol File Exporter

Throughput Under a Fixed Budget

Collector choice is usually decided by how much a node-level agent moves inside a small, fixed allocation. A benchmark published by VictoriaMetrics in March 2026 ran nine log collectors in a Kubernetes cluster under identical limits of one CPU core and one GiB of memory, tailing JSON log records from 100 generator pods averaging roughly 216 bytes. Peak sustained throughput in that 100-pod scenario:

31,300/s
Fluent Bit
25,000/s
Vector
20,500/s
OpenTelemetry Collector
5,100/s
Fluentd v1.19.1

Read those figures for what they are: one workload, one set of limits, one point in time. They do not describe your cluster, and 5,100 records per second still exceeds what most single services produce. What they explain is why the Kubernetes default drifted away from Fluentd on busy nodes. Fluentd is not abandoned - it is a CNCF graduated project in active maintenance - but its Ruby runtime sets a lower ceiling than a C or Rust agent.

Source: VictoriaMetrics (March 2026) - Benchmarking Kubernetes Log Collectors

Loki and OpenSearch

The two dominant open-source destinations treat the same line in opposite ways. OpenSearch indexes the whole document so any field is queryable; Loki indexes almost nothing and parses the line only when you ask a question. Knowing which one you write to should change how you shape the line.

Grafana Loki: Label-Based Indexing

Loki builds no full-text index. It indexes a small set of metadata labels - namespace, application, environment - and stores raw log lines in compressed chunks. A query selects a stream by its labels first, then parses the lines in that stream at read time.

The mapping from JSONL is direct: the entire JSON object stays as the log line, and only stable, low-cardinality fields become labels. High-cardinality values - request ids, user ids, trace ids, status codes - must stay inside the line, because every distinct label combination creates a stream and too many streams degrade ingestion and queries alike. Loki 3.0 added structured metadata for values that fit neither place.

# LogQL: labels pick the stream, the parser reads the JSONL line

# 1. Stream selector (indexed) then JSON parsing (read time)
{namespace="checkout", container="api"}
  | json
  | level = "error"
  | duration_ms > 1000

# 2. High-cardinality fields live IN the line, not in labels.
#    This is correct - it filters after parsing:
{namespace="checkout"} | json | request_id = "req_abc123"

#    This would be wrong - request_id as a label creates one
#    stream per request and blows up the index:
#    {namespace="checkout", request_id="req_abc123"}

# 3. Pull a nested field out with an explicit label expression
{namespace="checkout"} | json service_name="service.name", err="error.type"
  | err = "UpstreamTimeout"

# 4. Metric query built from the parsed line
sum by (service_name) (
  rate({namespace="checkout"} | json service_name="service.name" | level = "error" [5m])
)

Source: Grafana Loki documentation - Understand labels

OpenSearch: The Same Bulk Envelope

OpenSearch is the community fork of Elasticsearch, and for log ingestion the important detail is how little changed. The Bulk API still takes a newline-delimited body under the application/x-ndjson content type, still alternates an action line with a source line, and still requires a trailing newline. Only the action line is parsed on the receiving node, which is what makes the format fast to accept.

# OpenSearch _bulk - identical envelope to the Elasticsearch example above
curl -X POST "https://opensearch.example.com:9200/_bulk" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @logs.jsonl

# logs.jsonl - action line, then source line, then a trailing newline
{"index": {"_index": "logs-2026.07.01"}}
{"@timestamp": "2026-07-01T10:30:00Z", "level": "info", "message": "Server started", "service": "api"}
{"create": {"_index": "logs-2026.07.01", "_id": "evt-1002"}}
{"@timestamp": "2026-07-01T10:30:05Z", "level": "error", "message": "Database timeout", "service": "api"}

# Split a large file so one request stays a reasonable size.
# The split MUST fall on an action/source pair boundary, never between them.
split -l 2000 --additional-suffix=.jsonl logs.jsonl chunk-

for f in chunk-*.jsonl; do
  curl -s -X POST "https://opensearch.example.com:9200/_bulk" \
    -H "Content-Type: application/x-ndjson" \
    --data-binary "@$f"
done

Switching a shipper between the two clusters is a matter of endpoint and credentials, not format. The differences that bite are elsewhere: index lifecycle configuration, client libraries that refuse the other project's version banner, and query dialects that have begun to diverge.

Source: OpenSearch Documentation - Bulk

Log Aggregation Patterns

Centralized Logging Architecture

Collect logs from multiple services into a central location for analysis.

# Architecture:
# App Servers -> Log Shipper -> Message Queue -> Processor -> Storage

# Example flow:
1. Applications write JSONL to local files
2. Filebeat/Fluentd ships logs to Kafka
3. Logstash consumes from Kafka and processes
4. Elasticsearch indexes for searching
5. S3 archives for long-term storage

# Sample log aggregation script
import json
import boto3
from kafka import KafkaConsumer
from elasticsearch import Elasticsearch

# Consume from Kafka
consumer = KafkaConsumer(
    'application-logs',
    bootstrap_servers=['kafka:9092'],
    value_deserializer=lambda m: json.loads(m.decode('utf-8'))
)

es = Elasticsearch(['http://elasticsearch:9200'])
s3 = boto3.client('s3')

for message in consumer:
    log_entry = message.value

    # Index in Elasticsearch for searching
    es.index(
        index=f"logs-{log_entry['timestamp'][:10]}",
        document=log_entry
    )

    # Archive to S3
    if log_entry['level'] == 'error':
        s3.put_object(
            Bucket='error-logs',
            Key=f"errors/{log_entry['timestamp']}.json",
            Body=json.dumps(log_entry)
        )

Multi-Tenant Log Segregation

# Separate logs by tenant/customer for SaaS applications

{"tenant_id": "acme_corp", "timestamp": "2026-01-15T10:00:00Z", "user": "[email protected]", "action": "login"}
{"tenant_id": "widgets_inc", "timestamp": "2026-01-15T10:00:01Z", "user": "[email protected]", "action": "purchase"}

# Logstash routing by tenant
filter {
  if [tenant_id] {
    mutate {
      add_field => { "[@metadata][target_index]" => "logs-%{tenant_id}-%{+YYYY.MM.dd}" }
    }
  }
}

output {
  elasticsearch {
    index => "%{[@metadata][target_index]}"
  }
}

# Query specific tenant logs
GET /logs-acme_corp-*/_search
{
  "query": {
    "match": {
      "user": "[email protected]"
    }
  }
}

Time-Series Log Retention

# Implement log retention policies with time-based indices

# Elasticsearch ILM (Index Lifecycle Management) policy
PUT _ilm/policy/logs-policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_size": "50GB",
            "max_age": "1d"
          }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": {
            "number_of_shards": 1
          },
          "forcemerge": {
            "max_num_segments": 1
          }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "freeze": {}
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

# Python script to archive old logs to S3
import gzip
import json
from datetime import datetime, timedelta

def archive_old_logs(days_old=30):
    cutoff_date = datetime.now() - timedelta(days=days_old)

    with open('application.jsonl', 'r') as infile:
        current_logs = []
        archive_logs = []

        for line in infile:
            log = json.loads(line)
            log_date = datetime.fromisoformat(log['timestamp'])

            if log_date < cutoff_date:
                archive_logs.append(line)
            else:
                current_logs.append(line)

    # Write current logs back
    with open('application.jsonl', 'w') as outfile:
        outfile.writelines(current_logs)

    # Compress and archive old logs
    archive_name = f"logs-{cutoff_date.strftime('%Y-%m')}.jsonl.gz"
    with gzip.open(archive_name, 'wt') as zipfile:
        zipfile.writelines(archive_logs)

Real-Time Log Processing

Streaming with Python

import json
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

class LogFileHandler(FileSystemEventHandler):
    def __init__(self, filepath):
        self.filepath = filepath
        self.file = open(filepath, 'r')
        # Move to end of file
        self.file.seek(0, 2)

    def on_modified(self, event):
        if event.src_path == self.filepath:
            # Read new lines
            for line in self.file:
                try:
                    log_entry = json.loads(line)
                    self.process_log(log_entry)
                except json.JSONDecodeError:
                    pass

    def process_log(self, log_entry):
        # Real-time processing
        if log_entry.get('level') == 'error':
            self.alert_on_error(log_entry)

        if log_entry.get('duration_ms', 0) > 1000:
            self.alert_slow_request(log_entry)

    def alert_on_error(self, log):
        print(f"ERROR ALERT: {log['message']}")
        # Send to Slack, PagerDuty, etc.

    def alert_slow_request(self, log):
        print(f"SLOW REQUEST: {log.get('path')} took {log['duration_ms']}ms")

# Watch log file
handler = LogFileHandler('/var/log/application.jsonl')
observer = Observer()
observer.schedule(handler, path='/var/log', recursive=False)
observer.start()

try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    observer.stop()
observer.join()

Real-Time Alerting

import json
from collections import defaultdict
from datetime import datetime, timedelta

class LogAlerter:
    def __init__(self):
        self.error_counts = defaultdict(int)
        self.last_reset = datetime.now()

    def process_log_stream(self, log_file):
        with open(log_file, 'r') as f:
            # Follow file like 'tail -f'
            f.seek(0, 2)
            while True:
                line = f.readline()
                if not line:
                    time.sleep(0.1)
                    continue

                try:
                    log = json.loads(line)
                    self.analyze(log)
                except json.JSONDecodeError:
                    continue

    def analyze(self, log):
        # Reset counters every minute
        if datetime.now() - self.last_reset > timedelta(minutes=1):
            self.check_thresholds()
            self.error_counts.clear()
            self.last_reset = datetime.now()

        # Count errors by service
        if log.get('level') == 'error':
            service = log.get('service', 'unknown')
            self.error_counts[service] += 1

        # Immediate alerts
        if 'out of memory' in log.get('message', '').lower():
            self.send_alert('CRITICAL', 'Out of memory error detected', log)

        if log.get('status_code') == 500:
            self.send_alert('HIGH', '500 error detected', log)

    def check_thresholds(self):
        for service, count in self.error_counts.items():
            if count > 100:  # More than 100 errors per minute
                self.send_alert(
                    'HIGH',
                    f'{service} error rate exceeded: {count}/min',
                    {'service': service, 'count': count}
                )

    def send_alert(self, severity, message, context):
        # Integrate with alerting systems
        print(f"[{severity}] {message}")
        print(f"Context: {json.dumps(context, indent=2)}")
        # Send to Slack, PagerDuty, Email, etc.

Cloud Logging Services

AWS CloudWatch Logs

import json
import boto3
from datetime import datetime

logs = boto3.client('logs')
log_group = '/aws/application/api'
log_stream = f"instance-{datetime.now().strftime('%Y-%m-%d')}"

# Create log stream if doesn't exist
try:
    logs.create_log_stream(logGroupName=log_group, logStreamName=log_stream)
except logs.exceptions.ResourceAlreadyExistsException:
    pass

# Send structured logs
events = []
with open('application.jsonl', 'r') as f:
    for line in f:
        log = json.loads(line)
        events.append({
            'timestamp': int(datetime.fromisoformat(log['timestamp']).timestamp() * 1000),
            'message': json.dumps(log)
        })

# Batch upload (max 10,000 events or 1 MB)
logs.put_log_events(
    logGroupName=log_group,
    logStreamName=log_stream,
    logEvents=events
)

# Query with CloudWatch Insights
query = '''
fields @timestamp, level, message, duration_ms
| filter level = "error"
| sort @timestamp desc
| limit 100
'''

response = logs.start_query(
    logGroupName=log_group,
    startTime=int((datetime.now() - timedelta(hours=1)).timestamp()),
    endTime=int(datetime.now().timestamp()),
    queryString=query
)

Google Cloud Logging

from google.cloud import logging
import json

client = logging.Client()
logger = client.logger('application-logs')

# Read and upload JSONL logs
with open('application.jsonl', 'r') as f:
    for line in f:
        log_entry = json.loads(line)

        # Map severity
        severity_map = {
            'debug': 'DEBUG',
            'info': 'INFO',
            'warn': 'WARNING',
            'error': 'ERROR',
            'fatal': 'CRITICAL'
        }

        logger.log_struct(
            log_entry,
            severity=severity_map.get(log_entry.get('level'), 'DEFAULT')
        )

# Query logs
from google.cloud.logging import DESCENDING

filter_str = '''
resource.type="gce_instance"
severity="ERROR"
timestamp>="2026-01-15T00:00:00Z"
'''

for entry in client.list_entries(filter_=filter_str, order_by=DESCENDING):
    print(f"{entry.timestamp}: {entry.payload}")

Azure Monitor Logs

from azure.monitor.ingestion import LogsIngestionClient
from azure.identity import DefaultAzureCredential
import json

credential = DefaultAzureCredential()
endpoint = "https://<data-collection-endpoint>.ingest.monitor.azure.com"
rule_id = "dcr-xxxxxxxxxxxxx"
stream_name = "Custom-ApplicationLogs"

client = LogsIngestionClient(endpoint=endpoint, credential=credential)

# Read JSONL and upload
logs_data = []
with open('application.jsonl', 'r') as f:
    for line in f:
        logs_data.append(json.loads(line))

# Upload in batches
client.upload(rule_id=rule_id, stream_name=stream_name, logs=logs_data)

# Query with KQL (Kusto Query Language)
from azure.monitor.query import LogsQueryClient

query_client = LogsQueryClient(credential)

query = """
ApplicationLogs
| where TimeGenerated > ago(1h)
| where Level == "error"
| project TimeGenerated, Message, Service, Host
| order by TimeGenerated desc
| limit 100
"""

response = query_client.query_workspace(
    workspace_id="<workspace-id>",
    query=query,
    timespan=timedelta(hours=1)
)

Parsing Robustness

Two failures account for most production log-pipeline incidents: a line that is not valid JSON, and a line that is valid but enormous. The first is loud. The second is quiet, and it is the one that loses data.

The Unbounded Line Hazard

Nothing in the format caps line length, but almost every reader has a buffer that does. Go is the canonical example: the standard library documents bufio.MaxScanTokenSize as 64 * 1024 bytes, and a bufio.Scanner meeting a longer line returns bufio.ErrTooLong and stops. Because the loop condition is just scanner.Scan(), code that never checks scanner.Err() treats that as a clean end of file: the program exits successfully, having silently discarded every line after the big one.

// Go: the default 64 KB scanner ceiling, and how to survive it.
scanner := bufio.NewScanner(f)

// Raise the ceiling deliberately: 64 KB initial buffer, 16 MB maximum.
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)

var parsed, malformed int
for scanner.Scan() {
    var entry LogEntry
    if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
        // One bad line must never end the stream. Count it and move on.
        malformed++
        continue
    }
    parsed++
    process(entry)
}

// THIS is the check people forget. Without it, bufio.ErrTooLong looks
// exactly like a successful end of file and the tail of the file is lost.
if err := scanner.Err(); err != nil {
    log.Fatalf("log scan aborted after %d lines: %v", parsed, err)
}
log.Printf("parsed=%d malformed=%d", parsed, malformed)

Guarding Both Ends of the Pipe

Raising a buffer limit only moves the wall. Bound the line where it is written, and make every reader explicit about what it accepts - which is why the collector configs above set those knobs deliberately.

  • At the producer: truncate long strings, cap stack traces at a fixed number of frames, never log a body in full
  • For large payloads: write the blob to object storage and log a pointer plus a checksum
  • At the collector: buffer_max_size and skip_long_lines in Fluent Bit, max_line_bytes in Vector, max_log_size in filelog
  • At the reader: set the buffer ceiling explicitly, and inspect the terminal error after the loop ends
  • Everywhere: emit a counter for skipped and malformed lines, so silent loss becomes visible
# Python: skip-and-count instead of crash-on-first-bad-line
import json

def read_logs(path, max_bytes=1_048_576):
    stats = {"parsed": 0, "malformed": 0, "oversized": 0}

    with open(path, "r", encoding="utf-8") as f:
        for lineno, line in enumerate(f, 1):
            if len(line) > max_bytes:
                stats["oversized"] += 1
                continue

            line = line.strip()
            if not line:
                continue

            try:
                yield json.loads(line)
                stats["parsed"] += 1
            except json.JSONDecodeError as exc:
                stats["malformed"] += 1
                # Log the position, never the whole offending line
                print(f"bad JSON at line {lineno}: {exc.msg}")

    print(stats)

Watch for the partial line. A reader tailing a file a writer is still appending to can read a line before its trailing newline exists. Treat a parse error on the final line of a read as "incomplete, retry" rather than "malformed, discard".

Structured Logging Conventions

JSONL buys you nothing on its own. A pipeline where one service writes ts as epoch milliseconds, another writes @timestamp as local time with no offset, and a third writes time as an ISO string is still unqueryable - it just fails in a more structured way.

One Timestamp Field, One Format

Pick one field name and one encoding. The safe choice is ISO 8601 in its RFC 3339 profile, with an explicit UTC offset and a fixed number of fractional digits: 2026-07-01T10:30:45.123456Z. It sorts lexicographically in chronological order, and every backend parses it without a custom pattern.

  • Always include the offset. A bare 2026-07-01T10:30:45 becomes wrong the moment a container runs in another region
  • Keep fractional precision constant; mixed precision breaks naive string comparison
  • Distinguish event time from ingest time, and add timestamp_unix_ms if you need epoch arithmetic

A Fixed Severity Vocabulary

Libraries ship different ladders - warn against warning, fatal against critical - and a dashboard filtering on one spelling silently drops the services using the other. Agree on a closed lower-case set, map every library onto it at the logger configuration layer, and carry a numeric severity alongside the text so "warning and above" is a range query: the pairing the OpenTelemetry log data model defines and the filelog mapping above produces. Keep the message itself low-cardinality, pushing variable parts into their own fields, with exception detail under consistent keys such as error.type.

Trace and Span Correlation

The W3C Trace Context specification defines a 32 hex character trace id constant for the whole request and a 16 hex character span id for the individual operation. Emitting both on every line turns log search into distributed tracing, because one trace id retrieves every line from every service that touched the request. Extract the context at the edge and bind it to a request-scoped child logger - Winston's child(), structlog's bind(), Logback's MDC - so later lines inherit it. In Loki these belong in the line or in structured metadata, never in a label.

{"timestamp":"2026-07-01T10:30:45.123456Z","level":"error","severity_number":17,"message":"payment authorization failed","service":{"name":"checkout-api","version":"4.2.1"},"deployment":{"environment":"production"},"host":{"name":"prod-01"},"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","trace_flags":"01","request_id":"req_abc123","user_id":12345,"error":{"type":"UpstreamTimeout","message":"gateway did not respond within 3000ms"},"http":{"method":"POST","route":"/api/payments","status_code":504},"duration_ms":3012}

Best Practices

Log Structure

  • Always include timestamp, level, and message fields
  • Add request_id for tracing requests across services
  • Include service name and hostname for distributed systems
  • Use consistent field names across all services
  • Include environment (dev/staging/prod) in logs

Performance

  • Use async/non-blocking logging to avoid slowing down applications
  • Implement log sampling for high-volume endpoints
  • Compress logs (gzip) for storage efficiency
  • Use log levels appropriately (don't log everything as INFO)
  • Rotate log files automatically to prevent disk space issues

Security

  • Never log sensitive data (passwords, tokens, credit cards)
  • Redact PII (personally identifiable information) before logging
  • Encrypt log files at rest and in transit
  • Implement access controls for log viewing
  • Set up audit trails for who accessed logs

Storage & Retention

  • Define retention policies based on compliance requirements
  • Hot storage: Recent logs (7-30 days) for quick access
  • Cold storage: Archive older logs to S3/Glacier
  • Delete logs according to GDPR/compliance after retention period
  • Monitor storage costs and optimize accordingly