diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e1687e75c4..ca489aa1a44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -95,6 +95,8 @@ exporter.
([#8609](https://github.com/open-telemetry/opentelemetry-java/pull/8609))
* Logging: Include aggregation temporality in `LoggingMetricExporter` `toString`
([#8623](https://github.com/open-telemetry/opentelemetry-java/pull/8623))
+* Logging: Support the `output_stream` option when declaratively configuring `otlp_file/development`
+ ([#8676](https://github.com/open-telemetry/opentelemetry-java/pull/8676))
#### Extensions
diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/OutputStreamConfigUtil.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/OutputStreamConfigUtil.java
new file mode 100644
index 00000000000..76576ee7ef4
--- /dev/null
+++ b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/OutputStreamConfigUtil.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.exporter.logging.otlp.internal;
+
+import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
+import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.function.Consumer;
+
+/**
+ * Utilities for configuring the output stream of the OTLP file exporters.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public final class OutputStreamConfigUtil {
+
+ private static final String STDOUT = "stdout";
+ private static final String FILE_SCHEME = "file";
+
+ /**
+ * Invoke the {@code outputStreamConsumer} with the configured output stream.
+ *
+ *
Recognized values are {@code stdout} and a file URI such as {@code
+ * file:///path/to/file.jsonl}. Missing parent directories of the file are created, and the file
+ * is appended to if it already exists.
+ */
+ @SuppressWarnings("SystemOut")
+ public static void configureOutputStream(
+ DeclarativeConfigProperties config, Consumer outputStreamConsumer) {
+ String outputStream = config.getString("output_stream");
+ if (outputStream == null) {
+ return;
+ }
+ if (STDOUT.equalsIgnoreCase(outputStream)) {
+ outputStreamConsumer.accept(System.out);
+ return;
+ }
+ Path path = filePath(outputStream);
+ try {
+ Path parent = path.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+ outputStreamConsumer.accept(
+ new BufferedOutputStream(
+ Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND)));
+ } catch (IOException e) {
+ throw new ConfigurationException("Unable to open output_stream: " + outputStream, e);
+ }
+ }
+
+ private static Path filePath(String outputStream) {
+ URI uri;
+ try {
+ uri = new URI(outputStream);
+ } catch (URISyntaxException e) {
+ throw new ConfigurationException("Unrecognized output_stream: " + outputStream, e);
+ }
+ if (!FILE_SCHEME.equalsIgnoreCase(uri.getScheme())) {
+ throw new ConfigurationException("Unrecognized output_stream: " + outputStream);
+ }
+ try {
+ return Paths.get(uri);
+ } catch (IllegalArgumentException | FileSystemNotFoundException e) {
+ throw new ConfigurationException("Unrecognized output_stream: " + outputStream, e);
+ }
+ }
+
+ private OutputStreamConfigUtil() {}
+}
diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/logs/OtlpStdoutLogRecordExporterComponentProvider.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/logs/OtlpStdoutLogRecordExporterComponentProvider.java
index 3feebf33811..c2a943f2f17 100644
--- a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/logs/OtlpStdoutLogRecordExporterComponentProvider.java
+++ b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/logs/OtlpStdoutLogRecordExporterComponentProvider.java
@@ -7,6 +7,7 @@
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
import io.opentelemetry.exporter.internal.IncubatingExporterBuilderUtil;
+import io.opentelemetry.exporter.logging.otlp.internal.OutputStreamConfigUtil;
import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider;
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
@@ -32,6 +33,7 @@ public String getName() {
public LogRecordExporter create(DeclarativeConfigProperties config) {
OtlpStdoutLogRecordExporterBuilder builder = OtlpStdoutLogRecordExporter.builder();
IncubatingExporterBuilderUtil.configureExporterMemoryMode(config, builder::setMemoryMode);
+ OutputStreamConfigUtil.configureOutputStream(config, builder::setOutput);
return builder.build();
}
}
diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/metrics/OtlpStdoutMetricExporterComponentProvider.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/metrics/OtlpStdoutMetricExporterComponentProvider.java
index bf8d8a73be9..2d62bf105f1 100644
--- a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/metrics/OtlpStdoutMetricExporterComponentProvider.java
+++ b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/metrics/OtlpStdoutMetricExporterComponentProvider.java
@@ -7,6 +7,7 @@
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
import io.opentelemetry.exporter.internal.IncubatingExporterBuilderUtil;
+import io.opentelemetry.exporter.logging.otlp.internal.OutputStreamConfigUtil;
import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider;
import io.opentelemetry.sdk.metrics.export.MetricExporter;
@@ -36,6 +37,7 @@ public MetricExporter create(DeclarativeConfigProperties config) {
config, builder::setAggregationTemporalitySelector);
IncubatingExporterBuilderUtil.configureOtlpHistogramDefaultAggregation(
config, builder::setDefaultAggregationSelector);
+ OutputStreamConfigUtil.configureOutputStream(config, builder::setOutput);
return builder.build();
}
}
diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/traces/OtlpStdoutSpanExporterComponentProvider.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/traces/OtlpStdoutSpanExporterComponentProvider.java
index 969b804f863..9d51f2b9fac 100644
--- a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/traces/OtlpStdoutSpanExporterComponentProvider.java
+++ b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/traces/OtlpStdoutSpanExporterComponentProvider.java
@@ -7,6 +7,7 @@
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
import io.opentelemetry.exporter.internal.IncubatingExporterBuilderUtil;
+import io.opentelemetry.exporter.logging.otlp.internal.OutputStreamConfigUtil;
import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider;
import io.opentelemetry.sdk.trace.export.SpanExporter;
@@ -32,6 +33,7 @@ public String getName() {
public SpanExporter create(DeclarativeConfigProperties config) {
OtlpStdoutSpanExporterBuilder builder = OtlpStdoutSpanExporter.builder();
IncubatingExporterBuilderUtil.configureExporterMemoryMode(config, builder::setMemoryMode);
+ OutputStreamConfigUtil.configureOutputStream(config, builder::setOutput);
return builder.build();
}
}
diff --git a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/AbstractOtlpStdoutExporterTest.java b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/AbstractOtlpStdoutExporterTest.java
index dd6d3eb3de2..942277bb529 100644
--- a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/AbstractOtlpStdoutExporterTest.java
+++ b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/AbstractOtlpStdoutExporterTest.java
@@ -16,6 +16,7 @@
import io.github.netmikey.logunit.api.LogCapturer;
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
+import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException;
import io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider;
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;
import io.opentelemetry.sdk.common.export.MemoryMode;
@@ -30,6 +31,7 @@
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.annotation.Nullable;
@@ -302,6 +304,92 @@ void componentProviderConfig() {
.isEqualTo(MemoryMode.REUSABLE_DATA);
}
+ static Stream outputStreamStdoutTestCases() {
+ return Stream.of(
+ Arguments.argumentSet("stdout", "stdout"), Arguments.argumentSet("upper case", "STDOUT"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("outputStreamStdoutTestCases")
+ void componentProviderConfigOutputStreamStdout(String value) {
+ DeclarativeConfigProperties properties = spy(DeclarativeConfigProperties.empty());
+ when(properties.getString("output_stream")).thenReturn(value);
+
+ assertThat(exporterFromComponentProvider(properties))
+ .extracting("jsonWriter")
+ .extracting(Object::toString)
+ .isEqualTo("StreamJsonWriter{outputStream=stdout}");
+ }
+
+ static Stream outputStreamFileTestCases() {
+ return Stream.of(
+ Arguments.argumentSet("file uri", "test.jsonl", (UnaryOperator) uri -> uri),
+ Arguments.argumentSet(
+ "authority-less file uri with upper case scheme",
+ "test.jsonl",
+ (UnaryOperator) uri -> uri.replaceFirst("^file://", "FILE:")),
+ Arguments.argumentSet(
+ "missing parent directory", "missing/test.jsonl", (UnaryOperator) uri -> uri));
+ }
+
+ @ParameterizedTest
+ @MethodSource("outputStreamFileTestCases")
+ void componentProviderConfigOutputStreamFile(String relativePath, UnaryOperator uriForm)
+ throws Exception {
+ Path file = tempDir.resolve(relativePath);
+ DeclarativeConfigProperties properties = spy(DeclarativeConfigProperties.empty());
+ when(properties.getString("output_stream")).thenReturn(uriForm.apply(file.toUri().toString()));
+
+ T exporter = exporterFromComponentProvider(properties);
+ testDataExporter.export(exporter);
+
+ String output = new String(Files.readAllBytes(file), StandardCharsets.UTF_8).trim();
+ JSONAssert.assertEquals(
+ "Got \n" + output,
+ testDataExporter.getExpectedJson(/* withWrapper= */ true),
+ output,
+ false);
+
+ // an exporter created later for the same path appends instead of truncating
+ testDataExporter.shutdown(exporter);
+ T secondExporter = exporterFromComponentProvider(properties);
+ testDataExporter.export(secondExporter);
+ testDataExporter.shutdown(secondExporter);
+ assertThat(new String(Files.readAllBytes(file), StandardCharsets.UTF_8).trim().split("\n"))
+ .hasSize(2);
+ }
+
+ static Stream outputStreamUnrecognizedTestCases() {
+ return Stream.of(
+ Arguments.argumentSet("no scheme", "not-a-stream"),
+ Arguments.argumentSet("unsupported scheme", "http://example.com/traces.jsonl"),
+ Arguments.argumentSet("relative file uri", "file://traces.jsonl"),
+ Arguments.argumentSet("malformed file uri", "file:///with space.jsonl"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("outputStreamUnrecognizedTestCases")
+ void componentProviderConfigOutputStreamUnrecognized(String value) {
+ DeclarativeConfigProperties properties = spy(DeclarativeConfigProperties.empty());
+ when(properties.getString("output_stream")).thenReturn(value);
+
+ assertThatExceptionOfType(ConfigurationException.class)
+ .isThrownBy(() -> exporterFromComponentProvider(properties))
+ .withMessage("Unrecognized output_stream: " + value);
+ }
+
+ @Test
+ void componentProviderConfigOutputStreamNotOpenable() {
+ DeclarativeConfigProperties properties = spy(DeclarativeConfigProperties.empty());
+ // the path exists but is a directory, so it cannot be opened for writing
+ when(properties.getString("output_stream")).thenReturn(tempDir.toUri().toString());
+
+ assertThatExceptionOfType(ConfigurationException.class)
+ .isThrownBy(() -> exporterFromComponentProvider(properties))
+ .withMessageStartingWith("Unable to open output_stream: ")
+ .withCauseInstanceOf(IOException.class);
+ }
+
@SuppressWarnings("unchecked")
protected T exporterFromComponentProvider(DeclarativeConfigProperties properties) {
return (T)
diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java
index 053953a5a94..e6ff645f216 100644
--- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java
+++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfigurationCreateTest.java
@@ -96,6 +96,11 @@ void parseAndCreate_Examples(File example, @TempDir Path tempDir)
"cert_file: "
+ clientCertificatePath.replace("\\", "\\\\")
+ System.lineSeparator())
+ // Snippets write to file:///var/log/*.jsonl, which is not writable in tests. The
+ // value can be the last line of the file, so the line terminator is not matched.
+ .replaceAll(
+ "output_stream: file:.*",
+ "output_stream: " + tempDir.resolve("output.jsonl").toUri())
// A snippet references a custom id generator named my_custom_id_generator. Replace with
// one named test, which we provide via SPI
.replace("my_custom_id_generator", "test");