diff --git a/data-prepper-plugins/format-detection/build.gradle b/data-prepper-plugins/format-detection/build.gradle
new file mode 100644
index 0000000000..bfca14e762
--- /dev/null
+++ b/data-prepper-plugins/format-detection/build.gradle
@@ -0,0 +1,24 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+plugins {
+ id 'java'
+}
+
+dependencies {
+ implementation 'org.apache.tika:tika-core:2.9.2'
+ implementation 'org.xerial.snappy:snappy-java:1.1.10.8'
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
+ testImplementation 'org.assertj:assertj-core:3.25.3'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/Confidence.java b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/Confidence.java
new file mode 100644
index 0000000000..75cb688f96
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/Confidence.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+/**
+ * Confidence level of a format detection result.
+ */
+public enum Confidence {
+ HIGH,
+ MEDIUM,
+ LOW
+}
diff --git a/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/DetectedCompression.java b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/DetectedCompression.java
new file mode 100644
index 0000000000..5d14b14466
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/DetectedCompression.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+/**
+ * Compression types detectable from magic bytes.
+ */
+public enum DetectedCompression {
+ GZIP,
+ ZSTD,
+ SNAPPY,
+ NONE
+}
diff --git a/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/DetectedFormat.java b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/DetectedFormat.java
new file mode 100644
index 0000000000..019dc0c616
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/DetectedFormat.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+/**
+ * Data formats that can be detected from S3 object bytes.
+ */
+public enum DetectedFormat {
+ PARQUET,
+ AVRO,
+ ORC,
+ PDF,
+ IMAGE,
+ JSON,
+ NDJSON,
+ XML,
+ CSV,
+ TSV,
+ TEXT,
+ UNKNOWN
+}
diff --git a/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectionResult.java b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectionResult.java
new file mode 100644
index 0000000000..62b9d97b19
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectionResult.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+/**
+ * Result of detecting the format and compression of a data sample.
+ */
+public class FormatDetectionResult {
+ private final DetectedCompression compression;
+ private final DetectedFormat format;
+ private final Confidence confidence;
+
+ public FormatDetectionResult(final DetectedCompression compression,
+ final DetectedFormat format,
+ final Confidence confidence) {
+ this.compression = compression;
+ this.format = format;
+ this.confidence = confidence;
+ }
+
+ public DetectedCompression getCompression() {
+ return compression;
+ }
+
+ public DetectedFormat getFormat() {
+ return format;
+ }
+
+ public Confidence getConfidence() {
+ return confidence;
+ }
+
+ @Override
+ public String toString() {
+ return "FormatDetectionResult{compression=" + compression +
+ ", format=" + format +
+ ", confidence=" + confidence + "}";
+ }
+}
diff --git a/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetector.java b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetector.java
new file mode 100644
index 0000000000..d25b3e1547
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/main/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetector.java
@@ -0,0 +1,360 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+import org.apache.tika.Tika;
+import org.apache.tika.mime.MediaType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xerial.snappy.SnappyInputStream;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Detects data format and compression from raw bytes using Apache Tika.
+ *
+ *
Detection proceeds in two layers:
+ *
+ * - Compression: Tika identifies compression wrappers (gzip, zstd, snappy) from magic bytes.
+ * If compressed, the sample is decompressed before format detection.
+ * - Format: Tika detects the MIME type of the (decompressed) content, which is mapped to a
+ * {@link DetectedFormat}. Additional text heuristics disambiguate JSON arrays, ND-JSON,
+ * CSV, and TSV, which Tika reports generically.
+ *
+ */
+public class FormatDetector {
+ private static final Logger LOG = LoggerFactory.getLogger(FormatDetector.class);
+
+ private static final int DEFAULT_SAMPLE_SIZE = 65536; // 64 KB
+ private static final double NDJSON_LINE_THRESHOLD = 0.8;
+ private static final double CSV_CONSISTENCY_THRESHOLD = 0.8;
+ private static final int CSV_MIN_LINES = 2;
+ private static final int CSV_MAX_SAMPLE_LINES = 10;
+
+ private final Tika tika;
+
+ public FormatDetector() {
+ this.tika = new Tika();
+ }
+
+ /**
+ * Detect format and compression from a byte array sample.
+ *
+ * @param sample raw bytes from the beginning of the data source
+ * @return detection result with compression, format, and confidence
+ */
+ public FormatDetectionResult detect(final byte[] sample) {
+ if (sample == null || sample.length < 4) {
+ return new FormatDetectionResult(DetectedCompression.NONE, DetectedFormat.UNKNOWN, Confidence.LOW);
+ }
+
+ final DetectedCompression compression = detectCompression(sample);
+ final byte[] decompressed = decompress(sample, compression);
+
+ if (decompressed.length < 4) {
+ return new FormatDetectionResult(compression, DetectedFormat.UNKNOWN, Confidence.LOW);
+ }
+
+ return detectFormat(decompressed, compression);
+ }
+
+ /**
+ * Detect compression by asking Tika for the MIME type of the raw bytes.
+ */
+ DetectedCompression detectCompression(final byte[] data) {
+ final String mimeType = tikaDetect(data);
+ if (mimeType == null) {
+ return DetectedCompression.NONE;
+ }
+ if (mimeType.contains("gzip")) {
+ return DetectedCompression.GZIP;
+ }
+ if (mimeType.contains("zstd") || mimeType.contains("zstandard")) {
+ return DetectedCompression.ZSTD;
+ }
+ if (mimeType.contains("snappy") || mimeType.contains("x-snappy")) {
+ return DetectedCompression.SNAPPY;
+ }
+ return DetectedCompression.NONE;
+ }
+
+ private FormatDetectionResult detectFormat(final byte[] decompressed, final DetectedCompression compression) {
+ final String mimeType = tikaDetect(decompressed);
+
+ // Binary and document formats — Tika identifies these definitively from magic bytes.
+ final DetectedFormat binaryFormat = mapBinaryMimeType(mimeType);
+ if (binaryFormat != null) {
+ return new FormatDetectionResult(compression, binaryFormat, Confidence.HIGH);
+ }
+
+ // Text-based content — refine with heuristics since Tika reports generic text/plain,
+ // application/json, etc. without distinguishing JSON array vs ND-JSON vs CSV.
+ String text = new String(decompressed, StandardCharsets.UTF_8).trim();
+ if (text.startsWith("\uFEFF")) {
+ text = text.substring(1);
+ }
+ if (text.isEmpty()) {
+ return new FormatDetectionResult(compression, DetectedFormat.UNKNOWN, Confidence.LOW);
+ }
+
+ return detectTextFormat(text, mimeType, compression);
+ }
+
+ /**
+ * Map Tika binary/document MIME types to {@link DetectedFormat}.
+ * Returns null for text-based types that require further heuristics.
+ */
+ private DetectedFormat mapBinaryMimeType(final String mimeType) {
+ if (mimeType == null) {
+ return null;
+ }
+ if (mimeType.contains("parquet")) {
+ return DetectedFormat.PARQUET;
+ }
+ if (mimeType.contains("avro")) {
+ return DetectedFormat.AVRO;
+ }
+ if (mimeType.contains("orc")) {
+ return DetectedFormat.ORC;
+ }
+ if (mimeType.equals("application/pdf")) {
+ return DetectedFormat.PDF;
+ }
+ if (mimeType.startsWith("image/")) {
+ return DetectedFormat.IMAGE;
+ }
+ return null;
+ }
+
+ FormatDetectionResult detectTextFormat(final String text, final String mimeType,
+ final DetectedCompression compression) {
+ // XML — trust Tika's MIME detection plus a structural check.
+ if ((mimeType != null && mimeType.contains("xml"))
+ || text.startsWith(""))) {
+ return new FormatDetectionResult(compression, DetectedFormat.XML, Confidence.HIGH);
+ }
+
+ // JSON array vs ND-JSON vs single object.
+ if (text.startsWith("{") || text.startsWith("[")) {
+ if (isCompleteJson(text)) {
+ if (text.startsWith("[")) {
+ return new FormatDetectionResult(compression, DetectedFormat.JSON, Confidence.HIGH);
+ }
+ // A single JSON object maps to ND-JSON (the json codec only handles arrays).
+ return new FormatDetectionResult(compression, DetectedFormat.NDJSON, Confidence.HIGH);
+ }
+ // Truncated JSON array (sample cut mid-array).
+ if (text.startsWith("[") && text.length() > 2 && text.substring(1).trim().startsWith("{")) {
+ return new FormatDetectionResult(compression, DetectedFormat.JSON, Confidence.MEDIUM);
+ }
+ // Truncated single JSON object (single line only).
+ if (text.startsWith("{") && text.length() > 10 && !text.contains("\n")
+ && (text.contains("\":") || text.contains("\": "))) {
+ return new FormatDetectionResult(compression, DetectedFormat.NDJSON, Confidence.MEDIUM);
+ }
+ }
+
+ // ND-JSON: most lines parse as JSON objects.
+ final String[] lines = text.split("\\r?\\n");
+ final String[] nonEmptyLines = filterNonEmpty(lines);
+ if (nonEmptyLines.length > 0) {
+ final long jsonLineCount = countJsonLines(nonEmptyLines);
+ final double ratio = (double) jsonLineCount / nonEmptyLines.length;
+ if (ratio >= NDJSON_LINE_THRESHOLD) {
+ final Confidence confidence = ratio >= 0.95 ? Confidence.HIGH : Confidence.MEDIUM;
+ return new FormatDetectionResult(compression, DetectedFormat.NDJSON, confidence);
+ }
+ }
+
+ // CSV / TSV: consistent delimiter count across lines.
+ if (nonEmptyLines.length >= CSV_MIN_LINES) {
+ final DetectedFormat delimiterFormat = detectDelimitedFormat(nonEmptyLines);
+ if (delimiterFormat != null) {
+ return new FormatDetectionResult(compression, delimiterFormat, Confidence.MEDIUM);
+ }
+ }
+
+ // Verify it is genuinely readable text before falling back.
+ if (!isReadableText(text)) {
+ return new FormatDetectionResult(compression, DetectedFormat.UNKNOWN, Confidence.LOW);
+ }
+
+ return new FormatDetectionResult(compression, DetectedFormat.TEXT, Confidence.LOW);
+ }
+
+ private String tikaDetect(final byte[] data) {
+ try {
+ final MediaType mediaType = MediaType.parse(tika.detect(data));
+ return mediaType == null ? null : mediaType.toString();
+ } catch (final Exception e) {
+ LOG.debug("Tika detection failed: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private DetectedFormat detectDelimitedFormat(final String[] lines) {
+ if (isConsistentDelimiter(lines, ',')) {
+ return DetectedFormat.CSV;
+ }
+ if (isConsistentDelimiter(lines, '\t')) {
+ return DetectedFormat.TSV;
+ }
+ if (isConsistentDelimiter(lines, '|')) {
+ return DetectedFormat.CSV;
+ }
+ return null;
+ }
+
+ private boolean isConsistentDelimiter(final String[] lines, final char delimiter) {
+ final int sampleSize = Math.min(lines.length, CSV_MAX_SAMPLE_LINES);
+ final long firstLineCount = countDelimiterOutsideQuotes(lines[0], delimiter);
+ if (firstLineCount == 0) {
+ return false;
+ }
+ int matches = 0;
+ for (int i = 1; i < sampleSize; i++) {
+ if (countDelimiterOutsideQuotes(lines[i], delimiter) == firstLineCount) {
+ matches++;
+ }
+ }
+ return (double) matches / (sampleSize - 1) >= CSV_CONSISTENCY_THRESHOLD;
+ }
+
+ private boolean isCompleteJson(final String text) {
+ if (text.startsWith("{") && text.endsWith("}")) {
+ return bracketsBalanced(text, '{', '}');
+ }
+ if (text.startsWith("[") && text.endsWith("]")) {
+ return bracketsBalanced(text, '[', ']');
+ }
+ return false;
+ }
+
+ private boolean bracketsBalanced(final String text, final char open, final char close) {
+ int depth = 0;
+ boolean inString = false;
+ boolean escaped = false;
+ for (int i = 0; i < text.length(); i++) {
+ final char c = text.charAt(i);
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+ if (c == '\\' && inString) {
+ escaped = true;
+ continue;
+ }
+ if (c == '"') {
+ inString = !inString;
+ continue;
+ }
+ if (inString) {
+ continue;
+ }
+ if (c == open) {
+ depth++;
+ } else if (c == close) {
+ depth--;
+ if (depth == 0 && i < text.length() - 1) {
+ return false;
+ }
+ }
+ }
+ return depth == 0;
+ }
+
+ private long countJsonLines(final String[] lines) {
+ long count = 0;
+ for (final String line : lines) {
+ final String trimmed = line.trim();
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private String[] filterNonEmpty(final String[] lines) {
+ return java.util.Arrays.stream(lines)
+ .filter(l -> !l.trim().isEmpty())
+ .toArray(String[]::new);
+ }
+
+ private long countDelimiterOutsideQuotes(final String line, final char delimiter) {
+ long count = 0;
+ boolean inQuotes = false;
+ for (int i = 0; i < line.length(); i++) {
+ final char c = line.charAt(i);
+ if (c == '"') {
+ inQuotes = !inQuotes;
+ } else if (c == delimiter && !inQuotes) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Checks if the text content is readable (mostly printable characters).
+ * Binary data disguised as text will have a high ratio of non-printable chars.
+ */
+ private boolean isReadableText(final String text) {
+ if (text.isEmpty()) {
+ return false;
+ }
+ final int sampleLength = Math.min(text.length(), 1024);
+ int nonPrintable = 0;
+ for (int i = 0; i < sampleLength; i++) {
+ final char c = text.charAt(i);
+ if (c < 0x20 && c != '\n' && c != '\r' && c != '\t') {
+ nonPrintable++;
+ } else if (c >= 0x7F && c <= 0x9F) {
+ nonPrintable++;
+ }
+ }
+ return (double) nonPrintable / sampleLength < 0.10;
+ }
+
+ private byte[] decompress(final byte[] data, final DetectedCompression compression) {
+ if (compression == DetectedCompression.GZIP) {
+ return decompressStream(data, true);
+ }
+ if (compression == DetectedCompression.SNAPPY) {
+ return decompressStream(data, false);
+ }
+ // Zstd decompression not yet implemented.
+ return data;
+ }
+
+ private byte[] decompressStream(final byte[] data, final boolean gzip) {
+ try (final InputStream is = gzip
+ ? new GZIPInputStream(new ByteArrayInputStream(data))
+ : new SnappyInputStream(new ByteArrayInputStream(data))) {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ final byte[] buffer = new byte[4096];
+ int bytesRead;
+ int totalRead = 0;
+ while ((bytesRead = is.read(buffer)) != -1 && totalRead < DEFAULT_SAMPLE_SIZE) {
+ out.write(buffer, 0, bytesRead);
+ totalRead += bytesRead;
+ }
+ return out.toByteArray();
+ } catch (final IOException e) {
+ // Corrupt compression — return empty so format comes back UNKNOWN.
+ return new byte[0];
+ }
+ }
+}
diff --git a/data-prepper-plugins/format-detection/src/test/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectorFileTest.java b/data-prepper-plugins/format-detection/src/test/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectorFileTest.java
new file mode 100644
index 0000000000..0017b4fe8e
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectorFileTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.stream.Stream;
+import java.util.zip.GZIPOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests format detection against plain-text sample files from test resources.
+ *
+ * Compressed variants are generated at test time (rather than checked in) to keep
+ * the repository free of binary artifacts.
+ */
+class FormatDetectorFileTest {
+
+ private FormatDetector detector;
+
+ @BeforeEach
+ void setUp() {
+ detector = new FormatDetector();
+ }
+
+ @ParameterizedTest(name = "{0} -> format={1}")
+ @MethodSource("sampleFiles")
+ void detectsFormatFromSampleFile(final String fileName,
+ final DetectedFormat expectedFormat) throws IOException {
+ final byte[] sample = readResource("samples/" + fileName);
+ final FormatDetectionResult result = detector.detect(sample);
+
+ assertThat(result.getFormat())
+ .as("format for %s", fileName)
+ .isEqualTo(expectedFormat);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.NONE);
+ }
+
+ static Stream sampleFiles() {
+ return Stream.of(
+ Arguments.of("sample.json", DetectedFormat.JSON),
+ Arguments.of("sample.ndjson", DetectedFormat.NDJSON),
+ Arguments.of("sample.csv", DetectedFormat.CSV),
+ Arguments.of("sample.tsv", DetectedFormat.TSV),
+ Arguments.of("sample.xml", DetectedFormat.XML),
+ Arguments.of("sample.log", DetectedFormat.TEXT)
+ );
+ }
+
+ @Test
+ void detectsGzippedNdjsonGeneratedAtTestTime() throws IOException {
+ final byte[] plain = readResource("samples/sample.ndjson");
+ final byte[] gzipped = gzip(plain);
+
+ final FormatDetectionResult result = detector.detect(gzipped);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.GZIP);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ }
+
+ @Test
+ void detectsGzippedCsvGeneratedAtTestTime() throws IOException {
+ final byte[] plain = readResource("samples/sample.csv");
+ final byte[] gzipped = gzip(plain);
+
+ final FormatDetectionResult result = detector.detect(gzipped);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.GZIP);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.CSV);
+ }
+
+ private byte[] readResource(final String path) throws IOException {
+ try (final InputStream is = getClass().getClassLoader().getResourceAsStream(path)) {
+ if (is == null) {
+ throw new IOException("Resource not found: " + path);
+ }
+ return is.readAllBytes();
+ }
+ }
+
+ private byte[] gzip(final byte[] data) throws IOException {
+ final ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (final GZIPOutputStream gos = new GZIPOutputStream(bos)) {
+ gos.write(data);
+ }
+ return bos.toByteArray();
+ }
+}
diff --git a/data-prepper-plugins/format-detection/src/test/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectorTest.java b/data-prepper-plugins/format-detection/src/test/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectorTest.java
new file mode 100644
index 0000000000..9f174ce0ac
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/java/org/opensearch/dataprepper/plugins/formatdetection/FormatDetectorTest.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.formatdetection;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FormatDetectorTest {
+
+ private FormatDetector detector;
+
+ @BeforeEach
+ void setUp() {
+ detector = new FormatDetector();
+ }
+
+ @Nested
+ class CompressionDetection {
+
+ @Test
+ void detectsGzip() throws IOException {
+ final byte[] gzipData = gzip("{\"a\":1}\n{\"b\":2}\n".getBytes(StandardCharsets.UTF_8));
+ assertThat(detector.detectCompression(gzipData)).isEqualTo(DetectedCompression.GZIP);
+ }
+
+ @Test
+ void returnsNoneForUncompressed() {
+ final byte[] plainData = "hello world this is plain text content".getBytes(StandardCharsets.UTF_8);
+ assertThat(detector.detectCompression(plainData)).isEqualTo(DetectedCompression.NONE);
+ }
+ }
+
+ @Nested
+ class JsonDetection {
+
+ @Test
+ void detectsSingleJsonObjectAsNdjson() {
+ final byte[] json = "{\"name\": \"test\", \"value\": 42}".getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(json);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.HIGH);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.NONE);
+ }
+
+ @Test
+ void detectsJsonArray() {
+ final byte[] json = "[{\"a\":1},{\"a\":2},{\"a\":3}]".getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(json);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.JSON);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.HIGH);
+ }
+
+ @Test
+ void detectsTruncatedJsonArrayAsMediumConfidence() {
+ final byte[] json = "[{\"a\":1},{\"b\":2},{\"c\":3}".getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(json);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.JSON);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.MEDIUM);
+ }
+
+ @Test
+ void handlesJsonWithEscapedBrackets() {
+ final byte[] json = "{\"msg\": \"value with \\\"nested\\\" and {braces}\"}".getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(json);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.HIGH);
+ }
+ }
+
+ @Nested
+ class NdjsonDetection {
+
+ @Test
+ void detectsNdjson() {
+ final String ndjson = "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n{\"d\":4}\n{\"e\":5}\n";
+ final FormatDetectionResult result = detector.detect(ndjson.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.HIGH);
+ }
+
+ @Test
+ void detectsNdjsonMediumWhenSomeLinesInvalid() {
+ final String ndjson = "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n{\"d\":4}\nnot json\n";
+ final FormatDetectionResult result = detector.detect(ndjson.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.MEDIUM);
+ }
+
+ @Test
+ void doesNotDetectNdjsonWhenTooFewJsonLines() {
+ final String text = "{\"a\":1}\nnot json\nalso not\nnope\nstill no\n";
+ final FormatDetectionResult result = detector.detect(text.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isNotEqualTo(DetectedFormat.NDJSON);
+ }
+
+ @Test
+ void detectsNdjsonWithEmptyLines() {
+ final String ndjson = "{\"a\":1}\n\n{\"b\":2}\n\n{\"c\":3}\n\n{\"d\":4}\n\n{\"e\":5}\n";
+ final FormatDetectionResult result = detector.detect(ndjson.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ }
+ }
+
+ @Nested
+ class XmlDetection {
+
+ @Test
+ void detectsXmlWithDeclaration() {
+ final byte[] xml = " ".getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(xml);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.XML);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.HIGH);
+ }
+
+ @Test
+ void detectsXmlWithRootTag() {
+ final byte[] xml = "data".getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(xml);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.XML);
+ }
+ }
+
+ @Nested
+ class CsvDetection {
+
+ @Test
+ void detectsCsv() {
+ final String csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\nCharlie,35,Chicago\n";
+ final FormatDetectionResult result = detector.detect(csv.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.CSV);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.MEDIUM);
+ }
+
+ @Test
+ void detectsTsv() {
+ final String tsv = "name\tage\tcity\nAlice\t30\tNYC\nBob\t25\tLA\nCharlie\t35\tChicago\n";
+ final FormatDetectionResult result = detector.detect(tsv.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.TSV);
+ }
+
+ @Test
+ void detectsCsvWithQuotedCommas() {
+ final String csv = "name,address,city\n\"Smith, John\",\"123 Main St\",NYC\n"
+ + "\"Doe, Jane\",\"456 Oak Ave\",LA\n\"Lee, Amy\",\"789 Pine Rd\",SF\n";
+ final FormatDetectionResult result = detector.detect(csv.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isIn(DetectedFormat.CSV, DetectedFormat.TEXT);
+ }
+
+ @Test
+ void doesNotDetectCsvForInconsistentDelimiters() {
+ final String text = "one,two,three\nfour\nfive,six\n";
+ final FormatDetectionResult result = detector.detect(text.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isNotEqualTo(DetectedFormat.CSV);
+ }
+ }
+
+ @Nested
+ class GzipIntegration {
+
+ @Test
+ void detectsGzippedNdjson() throws IOException {
+ final String ndjson = "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n{\"d\":4}\n{\"e\":5}\n";
+ final byte[] gzipped = gzip(ndjson.getBytes(StandardCharsets.UTF_8));
+ final FormatDetectionResult result = detector.detect(gzipped);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.GZIP);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ }
+
+ @Test
+ void detectsGzippedCsv() throws IOException {
+ final String csv = "a,b,c\n1,2,3\n4,5,6\n7,8,9\n";
+ final byte[] gzipped = gzip(csv.getBytes(StandardCharsets.UTF_8));
+ final FormatDetectionResult result = detector.detect(gzipped);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.GZIP);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.CSV);
+ }
+
+ @Test
+ void handlesCorruptGzip() {
+ final byte[] corrupt = new byte[]{0x1F, (byte) 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x7F};
+ final FormatDetectionResult result = detector.detect(corrupt);
+ assertThat(result.getCompression()).isEqualTo(DetectedCompression.GZIP);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.UNKNOWN);
+ }
+ }
+
+ @Nested
+ class EdgeCases {
+
+ @Test
+ void returnsUnknownForNull() {
+ final FormatDetectionResult result = detector.detect(null);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.UNKNOWN);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.LOW);
+ }
+
+ @Test
+ void returnsUnknownForEmptyArray() {
+ final FormatDetectionResult result = detector.detect(new byte[0]);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.UNKNOWN);
+ }
+
+ @Test
+ void returnsUnknownForTinyInput() {
+ final FormatDetectionResult result = detector.detect(new byte[]{0x00, 0x01});
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.UNKNOWN);
+ }
+
+ @Test
+ void fallsBackToTextForPlainContent() {
+ final String plain = "This is just a plain text log line.\nAnother line here.\nAnd another.\n";
+ final FormatDetectionResult result = detector.detect(plain.getBytes(StandardCharsets.UTF_8));
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.TEXT);
+ assertThat(result.getConfidence()).isEqualTo(Confidence.LOW);
+ }
+
+ @Test
+ void detectsBinaryGarbageAsUnknown() {
+ final byte[] garbage = new byte[512];
+ for (int i = 0; i < garbage.length; i++) {
+ garbage[i] = (byte) ((i * 37 + 13) % 256);
+ }
+ // Ensure it doesn't start with a known magic byte
+ garbage[0] = 0x03;
+ garbage[1] = 0x04;
+ garbage[2] = 0x05;
+ garbage[3] = 0x06;
+ final FormatDetectionResult result = detector.detect(garbage);
+ assertThat(result.getFormat()).isIn(DetectedFormat.UNKNOWN, DetectedFormat.IMAGE);
+ }
+
+ @Test
+ void stripsBomBeforeDetection() {
+ final byte[] bomJson = ("\uFEFF{\"event\":\"login\"}\n{\"event\":\"logout\"}\n")
+ .getBytes(StandardCharsets.UTF_8);
+ final FormatDetectionResult result = detector.detect(bomJson);
+ assertThat(result.getFormat()).isEqualTo(DetectedFormat.NDJSON);
+ }
+ }
+
+ // Helper
+ private byte[] gzip(final byte[] data) throws IOException {
+ final ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (final GZIPOutputStream gos = new GZIPOutputStream(bos)) {
+ gos.write(data);
+ }
+ return bos.toByteArray();
+ }
+}
diff --git a/data-prepper-plugins/format-detection/src/test/resources/samples/sample.csv b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.csv
new file mode 100644
index 0000000000..1cd8773601
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.csv
@@ -0,0 +1,6 @@
+id,name,email,age,city,active
+1,Alice,alice@example.com,30,New York,true
+2,Bob,bob@example.com,25,Los Angeles,false
+3,Charlie,charlie@example.com,35,Chicago,true
+4,Diana,diana@example.com,28,Houston,true
+5,Eve,eve@example.com,32,Phoenix,false
diff --git a/data-prepper-plugins/format-detection/src/test/resources/samples/sample.json b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.json
new file mode 100644
index 0000000000..4b8f7b36c3
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.json
@@ -0,0 +1,5 @@
+[
+ {"id": 1, "name": "Alice", "email": "alice@example.com", "active": true},
+ {"id": 2, "name": "Bob", "email": "bob@example.com", "active": false},
+ {"id": 3, "name": "Charlie", "email": "charlie@example.com", "active": true}
+]
diff --git a/data-prepper-plugins/format-detection/src/test/resources/samples/sample.log b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.log
new file mode 100644
index 0000000000..04bae0db94
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.log
@@ -0,0 +1,6 @@
+2026-06-17 10:00:00 INFO [main] Application starting up
+2026-06-17 10:00:01 DEBUG [main] Loading configuration from /etc/app/config.yml
+2026-06-17 10:00:02 INFO [main] Connected to database at localhost:5432
+2026-06-17 10:00:03 WARN [pool-1] Connection pool nearing capacity: 45/50
+2026-06-17 10:00:04 ERROR [handler-3] Failed to process request: timeout after 30s
+2026-06-17 10:00:05 INFO [main] Health check endpoint ready on :8080/health
diff --git a/data-prepper-plugins/format-detection/src/test/resources/samples/sample.ndjson b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.ndjson
new file mode 100644
index 0000000000..f31efeab63
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.ndjson
@@ -0,0 +1,6 @@
+{"timestamp":"2026-06-17T10:00:00Z","level":"INFO","service":"api","message":"Request received","duration_ms":12}
+{"timestamp":"2026-06-17T10:00:01Z","level":"DEBUG","service":"api","message":"Processing payload","duration_ms":45}
+{"timestamp":"2026-06-17T10:00:02Z","level":"INFO","service":"worker","message":"Task completed","duration_ms":230}
+{"timestamp":"2026-06-17T10:00:03Z","level":"WARN","service":"api","message":"Slow response","duration_ms":1500}
+{"timestamp":"2026-06-17T10:00:04Z","level":"ERROR","service":"worker","message":"Connection timeout","duration_ms":5000}
+{"timestamp":"2026-06-17T10:00:05Z","level":"INFO","service":"api","message":"Health check OK","duration_ms":2}
diff --git a/data-prepper-plugins/format-detection/src/test/resources/samples/sample.tsv b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.tsv
new file mode 100644
index 0000000000..58dd3448ed
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.tsv
@@ -0,0 +1,6 @@
+id name email age city active
+1 Alice alice@example.com 30 New York true
+2 Bob bob@example.com 25 Los Angeles false
+3 Charlie charlie@example.com 35 Chicago true
+4 Diana diana@example.com 28 Houston true
+5 Eve eve@example.com 32 Phoenix false
diff --git a/data-prepper-plugins/format-detection/src/test/resources/samples/sample.xml b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.xml
new file mode 100644
index 0000000000..06d5e3c111
--- /dev/null
+++ b/data-prepper-plugins/format-detection/src/test/resources/samples/sample.xml
@@ -0,0 +1,18 @@
+
+
+
+ Widget
+ 9.99
+ Tools
+
+
+ Gadget
+ 24.99
+ Electronics
+
+
+ Doohickey
+ 4.50
+ Misc
+
+
diff --git a/data-prepper-plugins/s3-source/build.gradle b/data-prepper-plugins/s3-source/build.gradle
index 59dd1e979c..f1f3f2614c 100644
--- a/data-prepper-plugins/s3-source/build.gradle
+++ b/data-prepper-plugins/s3-source/build.gradle
@@ -13,6 +13,7 @@ dependencies {
implementation project(':data-prepper-plugins:common')
implementation project(':data-prepper-plugins:sqs-common')
implementation project(':data-prepper-plugins:s3-common')
+ implementation project(':data-prepper-plugins:format-detection')
implementation libs.armeria.core
implementation 'io.micrometer:micrometer-core'
diff --git a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/AutoDetectCodecFactory.java b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/AutoDetectCodecFactory.java
new file mode 100644
index 0000000000..25829cad2d
--- /dev/null
+++ b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/AutoDetectCodecFactory.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.dataprepper.plugins.source.s3;
+
+import org.opensearch.dataprepper.model.codec.InputCodec;
+import org.opensearch.dataprepper.model.configuration.PluginSetting;
+import org.opensearch.dataprepper.model.plugin.PluginFactory;
+import org.opensearch.dataprepper.plugins.formatdetection.DetectedFormat;
+import org.opensearch.dataprepper.plugins.formatdetection.FormatDetectionResult;
+import org.opensearch.dataprepper.plugins.formatdetection.FormatDetector;
+import org.opensearch.dataprepper.plugins.s3.common.ownership.BucketOwnerProvider;
+import org.opensearch.dataprepper.plugins.s3.common.source.S3ObjectReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+/**
+ * A {@link Function} that, given an {@link S3ObjectReference}, reads a small byte-range sample
+ * from the object, auto-detects its format, and returns the appropriate {@link InputCodec}.
+ *
+ * Used when no codec is explicitly configured in the S3 source. It reads only the first
+ * {@code SAMPLE_SIZE} bytes via a ranged GET so it does not consume the object's full content
+ * or advance the stream used for processing.
+ */
+class AutoDetectCodecFactory implements Function {
+ private static final Logger LOG = LoggerFactory.getLogger(AutoDetectCodecFactory.class);
+ static final int SAMPLE_SIZE = 65536; // 64 KB
+
+ private final PluginFactory pluginFactory;
+ private final S3Client s3Client;
+ private final BucketOwnerProvider bucketOwnerProvider;
+ private final FormatDetector formatDetector;
+ private final Map codecCache;
+
+ AutoDetectCodecFactory(final PluginFactory pluginFactory,
+ final S3Client s3Client,
+ final BucketOwnerProvider bucketOwnerProvider) {
+ this.pluginFactory = pluginFactory;
+ this.s3Client = s3Client;
+ this.bucketOwnerProvider = bucketOwnerProvider;
+ this.formatDetector = new FormatDetector();
+ this.codecCache = new ConcurrentHashMap<>();
+ }
+
+ @Override
+ public InputCodec apply(final S3ObjectReference s3ObjectReference) {
+ final byte[] sample = readSample(s3ObjectReference);
+ final FormatDetectionResult detection = formatDetector.detect(sample);
+
+ LOG.debug("Auto-detected format for s3://{}/{}: format={}, compression={}, confidence={}",
+ s3ObjectReference.getBucketName(), s3ObjectReference.getKey(),
+ detection.getFormat(), detection.getCompression(), detection.getConfidence());
+
+ return getCodecForFormat(detection.getFormat());
+ }
+
+ private byte[] readSample(final S3ObjectReference s3ObjectReference) {
+ final GetObjectRequest.Builder requestBuilder = GetObjectRequest.builder()
+ .bucket(s3ObjectReference.getBucketName())
+ .key(s3ObjectReference.getKey())
+ .range(String.format("bytes=0-%d", SAMPLE_SIZE - 1));
+
+ final Optional bucketOwner = bucketOwnerProvider.getBucketOwner(s3ObjectReference.getBucketName());
+ bucketOwner.ifPresent(requestBuilder::expectedBucketOwner);
+
+ try (final ResponseInputStream stream = s3Client.getObject(requestBuilder.build())) {
+ return stream.readAllBytes();
+ } catch (final IOException | RuntimeException e) {
+ LOG.warn("Failed to read sample for format detection of s3://{}/{}: {}",
+ s3ObjectReference.getBucketName(), s3ObjectReference.getKey(), e.getMessage());
+ return new byte[0];
+ }
+ }
+
+ private InputCodec getCodecForFormat(final DetectedFormat format) {
+ return codecCache.computeIfAbsent(format, this::createCodec);
+ }
+
+ private InputCodec createCodec(final DetectedFormat format) {
+ final String pluginName = mapFormatToCodecPlugin(format);
+ if (pluginName == null) {
+ LOG.warn("No codec available for detected format: {}", format);
+ return null;
+ }
+
+ LOG.debug("Auto-detecting codec: {} -> {}", format, pluginName);
+ final PluginSetting pluginSetting = new PluginSetting(pluginName, Collections.emptyMap());
+ return pluginFactory.loadPlugin(InputCodec.class, pluginSetting);
+ }
+
+ private String mapFormatToCodecPlugin(final DetectedFormat format) {
+ if (format == DetectedFormat.NDJSON) {
+ return "ndjson";
+ }
+ if (format == DetectedFormat.JSON) {
+ return "json";
+ }
+ if (format == DetectedFormat.CSV || format == DetectedFormat.TSV) {
+ return "csv";
+ }
+ if (format == DetectedFormat.PARQUET) {
+ return "parquet";
+ }
+ if (format == DetectedFormat.AVRO) {
+ return "avro";
+ }
+ if (format == DetectedFormat.TEXT) {
+ return "newline";
+ }
+ return null;
+ }
+}
diff --git a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectRequest.java b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectRequest.java
index 10a679c036..62762e3134 100644
--- a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectRequest.java
+++ b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectRequest.java
@@ -21,6 +21,7 @@
import java.time.Duration;
import java.util.function.BiConsumer;
+import java.util.function.Function;
public class S3ObjectRequest {
private final Buffer> buffer;
@@ -34,6 +35,7 @@ public class S3ObjectRequest {
private final CompressionOption compressionOption;
private final BucketOwnerProvider bucketOwnerProvider;
private final InputCodec codec;
+ private final Function codecProvider;
private final BiConsumer eventConsumer;
private final S3Client s3Client;
private final CompressionType compressionType;
@@ -55,6 +57,7 @@ private S3ObjectRequest(Builder builder) {
this.compressionOption = builder.compressionOption;
this.bucketOwnerProvider = builder.bucketOwnerProvider;
this.codec = builder.codec;
+ this.codecProvider = builder.codecProvider;
this.eventConsumer = builder.eventConsumer;
this.s3Client = builder.s3Client;
this.compressionType = builder.compressionType;
@@ -107,6 +110,10 @@ public InputCodec getCodec() {
return codec;
}
+ public Function getCodecProvider() {
+ return codecProvider;
+ }
+
public BiConsumer getEventConsumer() {
return eventConsumer;
}
@@ -143,6 +150,7 @@ public static class Builder {
private S3SelectResponseHandlerFactory s3SelectResponseHandlerFactory;
private CompressionOption compressionOption;
private InputCodec codec;
+ private Function codecProvider;
private BiConsumer eventConsumer;
private S3Client s3Client;
private CompressionType compressionType;
@@ -195,6 +203,11 @@ public Builder codec(InputCodec codec) {
return this;
}
+ public Builder codecProvider(Function codecProvider) {
+ this.codecProvider = codecProvider;
+ return this;
+ }
+
public Builder eventConsumer(BiConsumer eventConsumer) {
this.eventConsumer = eventConsumer;
return this;
diff --git a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectWorker.java b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectWorker.java
index 2c96df07ee..8b6c0c88eb 100644
--- a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectWorker.java
+++ b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3ObjectWorker.java
@@ -35,6 +35,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiConsumer;
+import java.util.function.Function;
/**
* Class responsible for taking an {@link S3ObjectReference} and creating all the necessary {@link Event}
@@ -56,6 +57,7 @@ class S3ObjectWorker implements S3ObjectHandler {
private final CompressionOption compressionOption;
private final InputCodec codec;
+ private final Function codecProvider;
private final BucketOwnerProvider bucketOwnerProvider;
private final Duration bufferTimeout;
private final int numberOfRecordsToAccumulate;
@@ -67,6 +69,7 @@ public S3ObjectWorker(final S3ObjectRequest s3ObjectRequest) {
this.buffer = s3ObjectRequest.getBuffer();
this.compressionOption = s3ObjectRequest.getCompressionOption();
this.codec = s3ObjectRequest.getCodec();
+ this.codecProvider = s3ObjectRequest.getCodecProvider();
this.bucketOwnerProvider = s3ObjectRequest.getBucketOwnerProvider();
this.bufferTimeout = s3ObjectRequest.getBufferTimeout();
this.numberOfRecordsToAccumulate = s3ObjectRequest.getNumberOfRecordsToAccumulate();
@@ -158,8 +161,16 @@ public long consumeS3Object(final S3InputFile inputFile, final S3DataSelection d
final CompressionOption fileCompressionOption = compressionOption != CompressionOption.AUTOMATIC ?
compressionOption : CompressionOption.fromFileName(s3ObjectReference.getKey());
+ // Resolve the codec: use the fixed codec if configured, otherwise auto-detect via the provider.
+ final InputCodec resolvedCodec = codec != null ? codec : codecProvider.apply(s3ObjectReference);
+ if (resolvedCodec == null) {
+ s3ObjectPluginMetrics.getS3ObjectReadFailedCounter().increment();
+ throw new S3ReadFailedException(
+ new IllegalStateException("Unable to determine codec for object " + s3ObjectReference.getKey()));
+ }
+
try {
- codec.parse(inputFile, fileCompressionOption.getDecompressionEngine(), record -> {
+ resolvedCodec.parse(inputFile, fileCompressionOption.getDecompressionEngine(), record -> {
consumer.accept(record, dataSelection);
});
return inputFile.getLength();
@@ -214,7 +225,7 @@ private void doProcessObject(final AcknowledgementSet acknowledgementSet,
saveStateCounter.getAndIncrement();
}
} catch (final Exception e) {
- LOG.error("Failed writing S3 objects to buffer.", e);
+ LOG.error("Failed writing S3 objects to buffer due to: {}", e.getMessage());
}
});
diff --git a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3Source.java b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3Source.java
index ed4c4e03fc..3df7a2f609 100644
--- a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3Source.java
+++ b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3Source.java
@@ -29,15 +29,20 @@
import org.opensearch.dataprepper.plugins.source.s3.configuration.S3SelectOptions;
import org.opensearch.dataprepper.plugins.source.s3.ownership.ConfigBucketOwnerProviderFactory;
import org.opensearch.dataprepper.plugins.s3.common.ownership.BucketOwnerProvider;
+import org.opensearch.dataprepper.plugins.codec.CompressionOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.services.s3.model.CompressionType;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiConsumer;
+import java.util.function.Function;
@DataPrepperPlugin(name = "s3", pluginType = Source.class, pluginConfigurationType = S3SourceConfig.class)
public class S3Source implements Source>, UsesSourceCoordination {
+ private static final Logger LOG = LoggerFactory.getLogger(S3Source.class);
private final PluginMetrics pluginMetrics;
private final S3SourceConfig s3SourceConfig;
@@ -113,14 +118,26 @@ public void start(Buffer> buffer) {
s3Handler = new S3SelectObjectWorker(s3ObjectRequest);
} else {
final PluginModel codecConfiguration = s3SourceConfig.getCodec();
- final PluginSetting codecPluginSettings = new PluginSetting(codecConfiguration.getPluginName(), codecConfiguration.getPluginSettings());
- final InputCodec codec = pluginFactory.loadPlugin(InputCodec.class, codecPluginSettings);
+ final Function codecProvider;
+ final CompressionOption compressionOption;
+ if (codecConfiguration != null) {
+ final PluginSetting codecPluginSettings = new PluginSetting(codecConfiguration.getPluginName(), codecConfiguration.getPluginSettings());
+ final InputCodec codec = pluginFactory.loadPlugin(InputCodec.class, codecPluginSettings);
+ codecProvider = s3ObjectReference -> codec;
+ compressionOption = s3SourceConfig.getCompression();
+ } else {
+ // Auto-detect mode: no codec specified. Detect format per-object from a byte-range sample.
+ LOG.info("No codec configured - using auto-detect mode");
+ codecProvider = new AutoDetectCodecFactory(pluginFactory, s3ClientBuilderFactory.getS3Client(), bucketOwnerProvider);
+ compressionOption = CompressionOption.AUTOMATIC;
+ }
+
final S3ObjectRequest s3ObjectRequest = s3ObjectRequestBuilder
.bucketOwnerProvider(bucketOwnerProvider)
- .codec(codec)
+ .codecProvider(codecProvider)
.eventConsumer(eventMetadataModifier)
.s3Client(s3ClientBuilderFactory.getS3Client())
- .compressionOption(s3SourceConfig.getCompression())
+ .compressionOption(compressionOption)
.build();
s3Handler = new S3ObjectWorker(s3ObjectRequest);
}
diff --git a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfig.java b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfig.java
index 0827bbe1ef..5c63b05579 100644
--- a/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfig.java
+++ b/data-prepper-plugins/s3-source/src/main/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfig.java
@@ -110,10 +110,10 @@ public class S3SourceConfig {
@Valid
private Map filters;
- @AssertTrue(message = "A codec is required for reading objects.")
+ @AssertTrue(message = "A codec is required for reading objects unless using auto-detect mode.")
boolean isCodecProvidedWhenNeeded() {
if(s3SelectOptions == null)
- return codec != null;
+ return true; // codec is now optional — auto-detect when null
return true;
}
diff --git a/data-prepper-plugins/s3-source/src/test/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfigTest.java b/data-prepper-plugins/s3-source/src/test/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfigTest.java
index 5dce6638ff..1e46c9834a 100644
--- a/data-prepper-plugins/s3-source/src/test/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfigTest.java
+++ b/data-prepper-plugins/s3-source/src/test/java/org/opensearch/dataprepper/plugins/source/s3/S3SourceConfigTest.java
@@ -95,9 +95,10 @@ void isCodecProvidedWhenNeeded_returns_true_when_s3SelectOptions_is_null_and_cod
}
@Test
- void isCodecProvidedWhenNeeded_returns_false_when_s3SelectOptions_is_null_and_codec_is_null(){
+ void isCodecProvidedWhenNeeded_returns_true_when_s3SelectOptions_is_null_and_codec_is_null(){
+ // codec is optional — when null, the source uses auto-detect mode
final S3SourceConfig s3SourceConfig = new S3SourceConfig();
- assertFalse(s3SourceConfig.isCodecProvidedWhenNeeded());
+ assertTrue(s3SourceConfig.isCodecProvidedWhenNeeded());
}
@Test
diff --git a/settings.gradle b/settings.gradle
index 7f330e125a..08503f1e01 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -121,6 +121,7 @@ include 'data-prepper-plugins:common'
include 'data-prepper-plugins:armeria-common'
include 'data-prepper-plugins:anomaly-detector-processor'
include 'data-prepper-plugins:detect-format-processor'
+include 'data-prepper-plugins:format-detection'
include 'data-prepper-plugins:opensearch'
include 'data-prepper-plugins:opensearch-pull-kafka'
include 'data-prepper-plugins:ocsf'