Skip to content

Commit 38de323

Browse files
committed
Add multiline input codec for grouping multi-line log events
Signed-off-by: Manisha Yadav <yavmanis@amazon.com>
1 parent 5e96f81 commit 38de323

10 files changed

Lines changed: 1784 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Multiline Codecs
2+
3+
This plugin provides a multiline input codec for Data Prepper that groups consecutive lines from an input stream into single events based on a configurable regex pattern.
4+
5+
## Usages
6+
7+
The multiline input codec can be configured with source plugins (e.g. S3 source, file source) in the pipeline file.
8+
9+
### Use Cases
10+
11+
- **Java/Kotlin stack traces**: Exception messages followed by `at ...` lines
12+
- **Python tracebacks**: `Traceback` blocks spanning multiple lines
13+
- **Timestamp-prefixed logs**: Logs where each entry starts with a timestamp and continuation lines don't
14+
- **Multi-line JSON/XML in logs**: Structured data embedded across multiple lines within log entries
15+
- **Custom log formats**: Any format where a recognizable pattern marks the start of a new event
16+
17+
## Configuration Options
18+
19+
| Option | Required | Type | Default | Description |
20+
|---|---|---|---|---|
21+
| `match` | Yes | String (regex) | - | A regular expression pattern used to identify line boundaries |
22+
| `negate` | No | Boolean | `false` | When `false`, lines matching the pattern are continuation lines. When `true`, lines NOT matching the pattern are continuation lines |
23+
| `what` | No | String | `previous` | Whether continuation lines belong to the `previous` or `next` event |
24+
| `max_lines` | No | Integer | `500` | Maximum number of lines that can be combined into a single event |
25+
| `max_length` | No | Integer | `10000` | Maximum character length of a combined multiline event |
26+
| `line_separator` | No | String | `\n` | Separator string used when joining lines into a single event message |
27+
28+
## How It Works
29+
30+
The codec reads lines from the input stream and uses the `match` regex to determine event boundaries:
31+
32+
1. **`negate=true` + `what=previous`** (most common): A new event starts when a line matches the pattern. Lines that do NOT match are appended to the preceding event.
33+
34+
2. **`negate=false` + `what=previous`**: Lines that match the pattern are appended to the preceding event.
35+
36+
3. **`negate=true` + `what=next`**: Lines that do NOT match the pattern are prepended to the next matching line.
37+
38+
4. **`negate=false` + `what=next`**: Lines that match the pattern are prepended to the next non-matching line.
39+
40+
## Examples
41+
42+
### Java Stack Traces (timestamp-based grouping)
43+
44+
Each log entry starts with a timestamp. Lines without a timestamp are continuations of the previous entry.
45+
46+
```yaml
47+
pipeline:
48+
source:
49+
s3:
50+
codec:
51+
multiline:
52+
match: "^\\d{4}-\\d{2}-\\d{2}"
53+
negate: true
54+
what: previous
55+
```
56+
57+
Input:
58+
```
59+
2024-01-01 12:00:00 ERROR NullPointerException
60+
at com.example.Service.method(Service.java:42)
61+
at com.example.Main.run(Main.java:10)
62+
2024-01-01 12:00:01 INFO Application recovered
63+
```
64+
65+
Result: 2 events
66+
- Event 1: The ERROR line with its full stack trace grouped together
67+
- Event 2: The INFO line as a single event
68+
69+
### Java Stack Traces (pattern-based grouping)
70+
71+
Lines starting with whitespace followed by `at `, `...`, or `Caused by:` are continuations.
72+
73+
```yaml
74+
pipeline:
75+
source:
76+
s3:
77+
codec:
78+
multiline:
79+
match: "^\\s+(at |\\.\\.\\.|Caused by:)"
80+
negate: false
81+
what: previous
82+
```
83+
84+
### Python Tracebacks
85+
86+
```yaml
87+
pipeline:
88+
source:
89+
s3:
90+
codec:
91+
multiline:
92+
match: "^Traceback|^\\s|^\\w+Error"
93+
negate: false
94+
what: previous
95+
```
96+
97+
### Log Entries with Preamble (next mode)
98+
99+
Lines starting with whitespace are prepended to the next non-indented line.
100+
101+
```yaml
102+
pipeline:
103+
source:
104+
s3:
105+
codec:
106+
multiline:
107+
match: "^\\s"
108+
negate: false
109+
what: next
110+
```
111+
112+
## Developer Guide
113+
114+
This plugin is compatible with Java 11. See below:
115+
116+
- [CONTRIBUTING](https://github.com/opensearch-project/data-prepper/blob/main/CONTRIBUTING.md)
117+
- [monitoring](https://github.com/opensearch-project/data-prepper/blob/main/docs/monitoring.md)
118+
119+
The following command runs the unit and integration tests:
120+
121+
```
122+
./gradlew :data-prepper-plugins:multiline-codecs:test
123+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
plugins {
11+
id 'java'
12+
}
13+
14+
dependencies {
15+
implementation project(':data-prepper-api')
16+
implementation 'com.fasterxml.jackson.core:jackson-annotations'
17+
implementation libs.parquet.common
18+
testImplementation project(':data-prepper-plugins:common')
19+
testImplementation project(':data-prepper-test:test-event')
20+
}
21+
22+
test {
23+
useJUnitPlatform()
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
package org.opensearch.dataprepper.plugins.codec.multiline;
11+
12+
import org.opensearch.dataprepper.model.annotations.DataPrepperPlugin;
13+
import org.opensearch.dataprepper.model.annotations.DataPrepperPluginConstructor;
14+
import org.opensearch.dataprepper.model.codec.InputCodec;
15+
import org.opensearch.dataprepper.model.event.Event;
16+
import org.opensearch.dataprepper.model.event.EventFactory;
17+
import org.opensearch.dataprepper.model.event.LogEventBuilder;
18+
import org.opensearch.dataprepper.model.log.Log;
19+
import org.opensearch.dataprepper.model.record.Record;
20+
import org.slf4j.Logger;
21+
import org.slf4j.LoggerFactory;
22+
23+
import java.io.BufferedReader;
24+
import java.io.IOException;
25+
import java.io.InputStream;
26+
import java.io.InputStreamReader;
27+
import java.util.Collections;
28+
import java.util.Objects;
29+
import java.util.function.Consumer;
30+
import java.util.regex.Pattern;
31+
32+
/**
33+
* An implementation of {@link InputCodec} which groups multiple lines from an input stream
34+
* into single events based on a configurable regex pattern.
35+
*
36+
* <p>This is useful for ingesting logs where a single logical event spans multiple lines,
37+
* such as Java stack traces, Python tracebacks, or any log format where entries begin with
38+
* a recognizable pattern (e.g., a timestamp).</p>
39+
*
40+
* <p>The codec supports two grouping modes via the {@code what} configuration:</p>
41+
* <ul>
42+
* <li>{@code previous}: Continuation lines are appended to the preceding event.</li>
43+
* <li>{@code next}: Continuation lines are prepended to the following event.</li>
44+
* </ul>
45+
*
46+
* <p>The {@code negate} option controls which lines are considered continuation lines:</p>
47+
* <ul>
48+
* <li>{@code negate=false}: Lines matching the pattern are continuation lines.</li>
49+
* <li>{@code negate=true}: Lines NOT matching the pattern are continuation lines.</li>
50+
* </ul>
51+
*/
52+
@DataPrepperPlugin(name = "multiline", pluginType = InputCodec.class, pluginConfigurationType = MultilineInputCodecConfig.class)
53+
public class MultilineInputCodec implements InputCodec {
54+
55+
private static final Logger LOG = LoggerFactory.getLogger(MultilineInputCodec.class);
56+
static final String MESSAGE_FIELD_NAME = "message";
57+
58+
private final Pattern pattern;
59+
private final boolean negate;
60+
private final MultilineWhat what;
61+
private final int maxLines;
62+
private final int maxLength;
63+
private final String lineSeparator;
64+
private final EventFactory eventFactory;
65+
66+
@DataPrepperPluginConstructor
67+
public MultilineInputCodec(final MultilineInputCodecConfig config, final EventFactory eventFactory) {
68+
Objects.requireNonNull(config, "config must not be null");
69+
this.eventFactory = Objects.requireNonNull(eventFactory, "eventFactory must not be null");
70+
try {
71+
this.pattern = Pattern.compile(config.getMatch());
72+
} catch (final Exception e) {
73+
throw new IllegalArgumentException("Invalid regex pattern for 'match': " + config.getMatch(), e);
74+
}
75+
this.negate = config.getNegate();
76+
this.what = config.getWhat();
77+
this.maxLines = config.getMaxLines();
78+
this.maxLength = config.getMaxLength();
79+
this.lineSeparator = config.getLineSeparator();
80+
}
81+
82+
@Override
83+
public void parse(final InputStream inputStream, final Consumer<Record<Event>> eventConsumer) throws IOException {
84+
Objects.requireNonNull(inputStream, "inputStream must not be null");
85+
Objects.requireNonNull(eventConsumer, "eventConsumer must not be null");
86+
87+
try (final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
88+
if (what == MultilineWhat.PREVIOUS) {
89+
parsePreviousMode(reader, eventConsumer);
90+
} else {
91+
parseNextMode(reader, eventConsumer);
92+
}
93+
}
94+
}
95+
96+
/**
97+
* In PREVIOUS mode, continuation lines are appended to the preceding event.
98+
* A new event boundary is detected when a line is NOT a continuation line
99+
* (i.e., it's a "start" line).
100+
*/
101+
private void parsePreviousMode(final BufferedReader reader, final Consumer<Record<Event>> eventConsumer) throws IOException {
102+
final StringBuilder buffer = new StringBuilder();
103+
int lineCount = 0;
104+
String line;
105+
106+
while ((line = reader.readLine()) != null) {
107+
final boolean isContinuation = isContinuationLine(line);
108+
109+
if (!isContinuation && buffer.length() > 0) {
110+
emitEvent(buffer.toString(), eventConsumer);
111+
buffer.setLength(0);
112+
lineCount = 0;
113+
}
114+
115+
if (shouldFlush(buffer, lineCount, line)) {
116+
if (buffer.length() > 0) {
117+
emitEvent(buffer.toString(), eventConsumer);
118+
buffer.setLength(0);
119+
lineCount = 0;
120+
}
121+
}
122+
123+
if (buffer.length() > 0) {
124+
buffer.append(lineSeparator);
125+
}
126+
buffer.append(line);
127+
lineCount++;
128+
}
129+
130+
if (buffer.length() > 0) {
131+
emitEvent(buffer.toString(), eventConsumer);
132+
}
133+
}
134+
135+
/**
136+
* In NEXT mode, continuation lines are prepended to the following event.
137+
* A new event boundary is detected when a line is NOT a continuation line,
138+
* and the buffer (containing prior continuation lines) is combined with this line.
139+
*/
140+
private void parseNextMode(final BufferedReader reader, final Consumer<Record<Event>> eventConsumer) throws IOException {
141+
final StringBuilder buffer = new StringBuilder();
142+
int lineCount = 0;
143+
boolean bufferHasNonContinuation = false;
144+
String line;
145+
146+
while ((line = reader.readLine()) != null) {
147+
final boolean isContinuation = isContinuationLine(line);
148+
149+
if (!isContinuation) {
150+
if (bufferHasNonContinuation) {
151+
// The buffer already has a complete event (non-continuation at end).
152+
// Emit it and start fresh.
153+
emitEvent(buffer.toString(), eventConsumer);
154+
buffer.setLength(0);
155+
lineCount = 0;
156+
bufferHasNonContinuation = false;
157+
}
158+
// Append this non-continuation line to the buffer (with any preceding continuations).
159+
if (buffer.length() > 0) {
160+
buffer.append(lineSeparator);
161+
}
162+
buffer.append(line);
163+
lineCount++;
164+
bufferHasNonContinuation = true;
165+
continue;
166+
}
167+
168+
// This is a continuation line.
169+
if (bufferHasNonContinuation) {
170+
// Buffer has a complete event ending with non-continuation.
171+
// Emit it, then start collecting continuations for the next event.
172+
emitEvent(buffer.toString(), eventConsumer);
173+
buffer.setLength(0);
174+
lineCount = 0;
175+
bufferHasNonContinuation = false;
176+
}
177+
178+
if (shouldFlush(buffer, lineCount, line)) {
179+
if (buffer.length() > 0) {
180+
emitEvent(buffer.toString(), eventConsumer);
181+
buffer.setLength(0);
182+
lineCount = 0;
183+
}
184+
}
185+
186+
if (buffer.length() > 0) {
187+
buffer.append(lineSeparator);
188+
}
189+
buffer.append(line);
190+
lineCount++;
191+
}
192+
193+
if (buffer.length() > 0) {
194+
emitEvent(buffer.toString(), eventConsumer);
195+
}
196+
}
197+
198+
/**
199+
* Determines if a line is a continuation line based on the pattern and negate settings.
200+
*
201+
* <p>When {@code negate=false}: a line matching the pattern IS a continuation line.</p>
202+
* <p>When {@code negate=true}: a line NOT matching the pattern IS a continuation line.</p>
203+
*/
204+
boolean isContinuationLine(final String line) {
205+
final boolean matches = pattern.matcher(line).find();
206+
return negate != matches;
207+
}
208+
209+
private boolean shouldFlush(final StringBuilder buffer, final int lineCount, final String nextLine) {
210+
if (lineCount >= maxLines) {
211+
LOG.debug("Flushing multiline event due to max_lines limit of {}", maxLines);
212+
return true;
213+
}
214+
if (buffer.length() + lineSeparator.length() + nextLine.length() > maxLength) {
215+
LOG.debug("Flushing multiline event due to max_length limit of {}", maxLength);
216+
return true;
217+
}
218+
return false;
219+
}
220+
221+
private void emitEvent(final String message, final Consumer<Record<Event>> eventConsumer) {
222+
final Log event = eventFactory.eventBuilder(LogEventBuilder.class)
223+
.withData(Collections.singletonMap(MESSAGE_FIELD_NAME, message))
224+
.build();
225+
eventConsumer.accept(new Record<>(event));
226+
}
227+
}

0 commit comments

Comments
 (0)