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