ELK Stack — Multiline Logs

5 minute read

The Multiline Problem

Most log parsing tools treat each line as a separate event. But many log entries span multiple lines:

  • Java stack traces — exception + dozens of at ... lines
  • Python tracebacks — Traceback + frame lines + exception
  • XML/JSON blocks — pretty-printed payloads
  • SQL queries — multi-line formatted queries

Without multiline handling, a single stack trace becomes dozens of broken events:

                     Without Multiline Handling
 ┌─────────────────────────────────────────────────────────┐
 │  Log File                    Elasticsearch              │
 │                                                         │
 │  ERROR NullPointer     →  Event 1: "ERROR NullPointer"  │
 │    at com.App.run()    →  Event 2: "at com.App.run()"   │
 │    at com.App.main()   →  Event 3: "at com.App.main()"  │
 │  Caused by: IOEx       →  Event 4: "Caused by: IOEx"    │
 │    at java.io.File()   →  Event 5: "at java.io.File()"  │
 └─────────────────────────────────────────────────────────┘

                      With Multiline Handling
 ┌─────────────────────────────────────────────────────────┐
 │  Log File                    Elasticsearch              │
 │                                                         │
 │  ERROR NullPointer     →  Event 1: "ERROR NullPointer   │
 │    at com.App.run()    →    at com.App.run()             │
 │    at com.App.main()   →    at com.App.main()            │
 │  Caused by: IOEx       →  Caused by: IOEx               │
 │    at java.io.File()   →    at java.io.File()"          │
 └─────────────────────────────────────────────────────────┘

Best practice: Handle multiline merging at the shipper level (Filebeat), before logs reach Logstash or Elasticsearch.


Filebeat Multiline Configuration

Filebeat’s multiline support is configured under filebeat.inputs:

filebeat.inputs:
  - type: filestream
    id: java-app-logs
    paths:
      - /var/log/myapp/*.log
    multiline:
      type: pattern
      pattern: '^\d{4}-\d{2}-\d{2}'
      negate: true
      match: after
      max_lines: 500
      timeout: 5s

Configuration Options

Option Description
pattern Regex pattern to identify the start (or continuation) of a multiline event
negate true = lines NOT matching the pattern are continuation lines; false = lines matching are continuation lines
match after = continuation lines go after the previous line; before = continuation lines go before the next line
max_lines Maximum number of lines to merge (default: 500)
timeout Maximum wait time for the next line before flushing (default: 5s)

Understanding negate + match

negate match Behavior
false after Lines matching the pattern are appended to the previous event
false before Lines matching the pattern are prepended to the next event
true after Lines not matching the pattern are appended to the previous event
true before Lines not matching the pattern are prepended to the next event

The most common combination is negate: true + match: after — which means “every line that does NOT start a new log event gets appended to the previous event.”


Java Stack Trace — Complete Example

Raw log:

2026-04-01 10:15:30.456 [http-nio-8080-exec-1] ERROR c.e.UserController - Failed to process request
java.lang.NullPointerException: User not found
	at com.example.service.UserService.getUser(UserService.java:42)
	at com.example.controller.UserController.handleRequest(UserController.java:28)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
Caused by: java.sql.SQLException: Connection refused
	at com.mysql.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:800)
	at com.mysql.jdbc.ConnectionImpl.<init>(ConnectionImpl.java:407)
	... 15 more
2026-04-01 10:15:31.123 [http-nio-8080-exec-2] INFO  c.e.HealthController - Health check OK

Filebeat Config

Java log lines typically start with a timestamp YYYY-MM-DD:

filebeat.inputs:
  - type: filestream
    id: java-app-logs
    paths:
      - /var/log/myapp/*.log
    multiline:
      type: pattern
      pattern: '^\d{4}-\d{2}-\d{2}'
      negate: true
      match: after
      max_lines: 500
      timeout: 5s

Logic: Any line NOT starting with YYYY-MM-DD is a continuation of the previous event.

Result: The entire stack trace is merged into a single event:

{
  "message": "2026-04-01 10:15:30.456 [http-nio-8080-exec-1] ERROR c.e.UserController - Failed to process request\njava.lang.NullPointerException: User not found\n\tat com.example.service.UserService.getUser(UserService.java:42)\n\tat com.example.controller.UserController.handleRequest(UserController.java:28)\n\tat sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\nCaused by: java.sql.SQLException: Connection refused\n\tat com.mysql.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:800)\n\tat com.mysql.jdbc.ConnectionImpl.<init>(ConnectionImpl.java:407)\n\t... 15 more"
}

Logstash Parsing (after Filebeat merges)

filter {
  grok {
    match => {
      "message" => "%{TIMESTAMP_ISO8601:timestamp} \[%{DATA:thread}\] %{LOGLEVEL:level}\s+%{NOTSPACE:logger} - %{GREEDYDATA:log_message}"
    }
  }

  # Extract exception class from the stack trace
  if [log_message] =~ /\n/ {
    grok {
      match => {
        "log_message" => "(?m)%{DATA:error_summary}\n%{NOTSPACE:exception_class}: %{DATA:exception_message}\n%{GREEDYDATA:stack_trace}"
      }
      tag_on_failure => ["_grok_stacktrace_fail"]
    }
  }
}

Python Traceback — Complete Example

Raw log:

2026-04-01 10:15:30 ERROR app.views - Unhandled exception
Traceback (most recent call last):
  File "/app/views.py", line 42, in get_user
    user = User.objects.get(pk=user_id)
  File "/app/venv/lib/python3.11/site-packages/django/db/models/manager.py", line 87, in get
    return self.get_queryset().get(*args, **kwargs)
app.models.User.DoesNotExist: User matching query does not exist.
2026-04-01 10:15:31 INFO  app.middleware - Request completed

Filebeat Config

Python log lines typically start with a timestamp:

filebeat.inputs:
  - type: filestream
    id: python-app-logs
    paths:
      - /var/log/python-app/*.log
    multiline:
      type: pattern
      pattern: '^\d{4}-\d{2}-\d{2}'
      negate: true
      match: after
      max_lines: 200
      timeout: 5s

Alternative: Match on Traceback Start

If your Python logs don’t always start with a timestamp, match on Traceback:

filebeat.inputs:
  - type: filestream
    id: python-app-logs
    paths:
      - /var/log/python-app/*.log
    multiline:
      type: pattern
      pattern: '^Traceback|^  File|^\s+|^[A-Za-z_][A-Za-z0-9_.]*Error|^[A-Za-z_][A-Za-z0-9_.]*Exception'
      negate: false
      match: after
      max_lines: 200
      timeout: 5s

Logstash Multiline Codec

If you cannot use Filebeat multiline, Logstash has a multiline codec for input plugins:

input {
  file {
    path => "/var/log/myapp/*.log"
    start_position => "beginning"
    codec => multiline {
      pattern => "^\d{4}-\d{2}-\d{2}"
      negate => true
      what => "previous"
      max_lines => 500
      max_bytes => "10 MiB"
    }
  }
}
Logstash Codec Filebeat Equivalent
what => "previous" match: after
what => "next" match: before

⚠️ Warning: Using the multiline codec in Logstash means multiline merging happens on the Logstash node, not the shipper. This consumes more Logstash memory and can cause issues with multiple workers. Prefer Filebeat multiline whenever possible.


Logstash Multiline Filter

The multiline filter plugin is deprecated in favor of the multiline codec or Filebeat multiline. If you still need it:

filter {
  multiline {
    pattern => "^\s"
    what => "previous"
    negate => false
  }
}

⚠️ Deprecated: The multiline filter does not work with multiple Logstash pipeline workers (-w). Use the multiline codec or Filebeat multiline instead.


Common Multiline Patterns

Java Stack Traces

# Lines start with timestamp (YYYY-MM-DD or ISO 8601)
multiline:
  type: pattern
  pattern: '^\d{4}-\d{2}-\d{2}[T ]'
  negate: true
  match: after

Python Tracebacks

# Lines start with timestamp
multiline:
  type: pattern
  pattern: '^\d{4}-\d{2}-\d{2}'
  negate: true
  match: after

XML/JSON Blocks

# JSON blocks start with {
multiline:
  type: pattern
  pattern: '^\{'
  negate: true
  match: after

SQL Queries (continuation lines start with whitespace)

multiline:
  type: pattern
  pattern: '^\s'
  negate: false
  match: after

Log4j Pattern (lines start with log level or timestamp)

multiline:
  type: pattern
  pattern: '^(TRACE|DEBUG|INFO|WARN|ERROR|FATAL|\d{4})'
  negate: true
  match: after

Best Practices

Practice Description
Handle at the shipper Use Filebeat multiline, not Logstash multiline filter
Set max_lines Prevent runaway merging — 500 is a reasonable default
Set timeout 5s is usually sufficient — prevents waiting forever for the next line
Test patterns thoroughly A wrong pattern can merge unrelated events or split valid ones
Use structured logging JSON logs avoid the multiline problem entirely
Monitor parse failures Track _grokparsefailure tags for merged events that fail parsing
Consider log format If you control the app, add a unique prefix to every log line

Quick Reference

# Filebeat multiline (recommended)
multiline:
  type: pattern
  pattern: '^\d{4}-\d{2}-\d{2}'   # Regex for line start
  negate: true                      # Lines NOT matching = continuation
  match: after                      # Append to previous event
  max_lines: 500                    # Safety limit
  timeout: 5s                       # Flush timeout
# Logstash multiline codec (if Filebeat not used)
codec => multiline {
  pattern => "^\d{4}-\d{2}-\d{2}"
  negate => true
  what => "previous"
  max_lines => 500
}
# Common negate + match combinations
negate: true  + match: after   → Most common (timestamp-prefixed logs)
negate: false + match: after   → Continuation lines match the pattern
negate: true  + match: before  → Rare — merge upward

Previous: Dissect Filter ← · Next: Structured Logging →