Skip to main content

JSONL for Log Processing

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

Servers emitting glowing JSONL log records that flow through filters into a searchable archive

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 a common choice 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 a widely used Node.js logging library with built-in JSON output, one object per line.

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

# stdlib logging carries the lines, so give it a handler and a level first
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.INFO)

# 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 (
    "errors"
    "net/http"
    "os"
    "time"

    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

func connectDatabase() error { return errors.New("connection refused") } // stand-in

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")
    }
}

// statusWriter records the status code a handler writes
type statusWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusWriter) WriteHeader(code int) {
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}

// HTTP middleware example (package level - Go has no nested func declarations)
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, status: http.StatusOK}
        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"   # json_lines is for streams; the file input already splits lines
    start_position => "beginning"
    sincedb_path => "/dev/null"
  }
}

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

  # Add hostname from the environment (${VAR}); %{field} would read an event field.
  # In ECS mode the file input already sets [host][name], so this is optional.
  mutate {
    add_field => { "[host][name]" => "${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 from Kibana. These use the Lucene query syntax (switch the search bar from KQL to Lucene); the KQL forms are noted in the comments.

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

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

# Find slow database queries
service:database AND duration_ms:>1000   # KQL: 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:
# filestream replaces the log input, which Filebeat 9.0 removed
- type: filestream
  id: app-jsonl
  paths:
    - /var/log/application.jsonl
  parsers:
    - ndjson:
        target: ""           # put decoded keys at the event root
        add_error_key: true
        message_key: message

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

# Output to Elasticsearch (Filebeat allows exactly one output)
output.elasticsearch:
  hosts: ["https://localhost:9200"]   # 8.x and later enable TLS and auth by default

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

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/fluent/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>

# Fan out to Elasticsearch and S3. Only the first matching <match> block
# receives an event, so two destinations go inside one copy output.
<match application.logs>
  @type copy

  <store>
    @type elasticsearch
    host localhost
    port 9200
    index_name logs-%Y.%m.%d

    <buffer>
      @type file
      path /var/log/fluent/buffer/logs
      flush_interval 10s
      chunk_limit_size 5M
    </buffer>
  </store>

  # Archive to S3
  <store>
    @type s3
    s3_bucket my-logs-bucket
    s3_region us-east-1
    path logs/%Y/%m/%d/

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

    <format>
      @type json
    </format>
  </store>
</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

Many Kubernetes clusters no longer 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 a common choice 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
    # Records above this are split by default (max_log_size_behavior: split);
    # set max_log_size_behavior: truncate to cut them instead
    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.999999999Z07:00'   # RFC 3339, any fraction or none
        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.name
        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_primary_shard_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": {
          "readonly": {}
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

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

def archive_old_logs(days_old=30):
    # Aware UTC, to compare with the "...Z" timestamps (fromisoformat reads Z on 3.11+)
    cutoff_date = datetime.now(timezone.utc) - 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
import time
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, timedelta

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)
        })

# PutLogEvents takes events in chronological order, at most 10,000 per call and
# 1,048,576 bytes (message bytes + 26 per event), spanning no more than 24 hours
events.sort(key=lambda e: e['timestamp'])
batch, size = [], 0
for event in events:
    event_size = len(event['message'].encode('utf-8')) + 26
    if batch and (len(batch) == 10_000 or size + event_size > 1_048_576
                  or event['timestamp'] - batch[0]['timestamp'] > 24 * 3600 * 1000):
        logs.put_log_events(logGroupName=log_group, logStreamName=log_stream, logEvents=batch)
        batch, size = [], 0
    batch.append(event)
    size += event_size
if batch:
    logs.put_log_events(logGroupName=log_group, logStreamName=log_stream, logEvents=batch)

# 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
from datetime import timedelta
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