diff --git a/exporters/common/build.gradle.kts b/exporters/common/build.gradle.kts index a589816d2a2..48f610282a3 100644 --- a/exporters/common/build.gradle.kts +++ b/exporters/common/build.gradle.kts @@ -7,7 +7,7 @@ plugins { description = "OpenTelemetry Exporter Common" otelJava.moduleName.set("io.opentelemetry.exporter.internal") -otelJava.osgiOptionalPackages.set(listOf("com.fasterxml.jackson.core", "com.google.common.io", "io.opentelemetry.api.incubator.config", "io.opentelemetry.sdk.autoconfigure.spi")) +otelJava.osgiOptionalPackages.set(listOf("com.google.common.io", "io.opentelemetry.api.incubator.config", "io.opentelemetry.sdk.autoconfigure.spi")) // sun.misc, io.grpc, and org.jspecify are not OSGi bundles and have no package versioning; must use unversioned optional. otelJava.osgiUnversionedOptionalPackages.set(listOf("sun.misc", "io.grpc", "org.jspecify.annotations")) // This bundle's exporters load sender implementations via SPI. @@ -69,9 +69,6 @@ dependencies { annotationProcessor("com.google.auto.value:auto-value") - // We include helpers shared by gRPC exporters but do not want to impose these - // dependency on all of our consumers. - compileOnly("com.fasterxml.jackson.core:jackson-core") // sun.misc.Unsafe from the JDK isn't found by the compiler, we provide our own trimmed down // version that we can compile against. compileOnly("io.grpc:grpc-stub") diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoder.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoder.java new file mode 100644 index 00000000000..b679893ed17 --- /dev/null +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoder.java @@ -0,0 +1,321 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.marshal; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +/** + * A minimal JSON encoder that serializes directly to an {@link OutputStream} as UTF-8, buffering + * writes and implementing only the subset of JSON generation {@link JsonSerializer} needs so OTLP + * JSON serialization has no third party dependency. Method names mirror the Jackson {@code + * JsonGenerator} that previously backed {@link JsonSerializer}. + * + *

The caller is responsible for structural validity (matching braces); this class only inserts + * separators between members. {@link #writeRaw(String)} writes verbatim without touching separator + * state, which {@link MarshalerUtil#preserializeJsonFields(Marshaler)} relies on. + * + *

Nesting depth is capped at {@value #MAX_NESTING_DEPTH}. Attempting to nest deeper throws + * {@link IOException} rather than {@link StackOverflowError}, which matters for recursive OTLP + * structures such as cyclic {@code Value} bodies that could otherwise DoS an exporter. The cap is + * intentionally conservative (Jackson 3's default is 500). Real OTLP payloads nest only a handful + * of levels, and it is easier to relax the cap later than to tighten it. + */ +final class JsonBufferedEncoder { + + static final int MAX_NESTING_DEPTH = 100; + + private static final byte[] TRUE = {'t', 'r', 'u', 'e'}; + private static final byte[] FALSE = {'f', 'a', 'l', 's', 'e'}; + private static final byte[] HEX = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + private final OutputStream out; + private final byte[] buffer = new byte[4096]; + private int pos; + + // Whether any member has been written at each nesting depth; depth 0 is the implicit root. The + // array grows on demand in push(), so it does not cap nesting depth. + private boolean[] hasMembers = new boolean[16]; + private int depth; + // True immediately after a field name has been written, meaning the next value is that field's + // value and must not be preceded by a comma. + private boolean afterKey; + + JsonBufferedEncoder(OutputStream out) { + this.out = out; + } + + void writeStartObject() throws IOException { + beforeValue(); + writeByte((byte) '{'); + push(); + } + + void writeEndObject() throws IOException { + pop(); + writeByte((byte) '}'); + } + + void writeObjectFieldStart(String name) throws IOException { + writeFieldName(name); + writeStartObject(); + } + + void writeArrayFieldStart(String name) throws IOException { + writeFieldName(name); + beforeValue(); + writeByte((byte) '['); + push(); + } + + void writeEndArray() throws IOException { + pop(); + writeByte((byte) ']'); + } + + void writeFieldName(String name) throws IOException { + if (hasMembers[depth]) { + writeByte((byte) ','); + } + hasMembers[depth] = true; + writeQuoted(name); + writeByte((byte) ':'); + afterKey = true; + } + + void writeStringField(String name, String value) throws IOException { + writeFieldName(name); + writeString(value); + } + + void writeBooleanField(String name, boolean value) throws IOException { + writeFieldName(name); + beforeValue(); + writeRawBytes(value ? TRUE : FALSE); + } + + void writeNumberField(String name, int value) throws IOException { + writeFieldName(name); + beforeValue(); + writeAscii(Integer.toString(value)); + } + + void writeNumberField(String name, double value) throws IOException { + writeFieldName(name); + writeNumber(value); + } + + void writeBinaryField(String name, byte[] value) throws IOException { + writeFieldName(name); + beforeValue(); + writeByte((byte) '"'); + writeRawBytes(Base64.getEncoder().encode(value)); + writeByte((byte) '"'); + } + + void writeString(String value) throws IOException { + beforeValue(); + writeQuoted(value); + } + + /** + * Writes a JSON string value from already UTF-8 encoded bytes, avoiding a decode-then-re-encode + * round trip. ASCII bytes are escaped as needed; multi-byte UTF-8 sequences pass through + * verbatim. + */ + void writeUtf8String(byte[] utf8Bytes) throws IOException { + beforeValue(); + writeByte((byte) '"'); + for (byte b : utf8Bytes) { + if (b >= 0) { + writeEscapedAscii(b); // ASCII, may need escaping + } else { + writeByte(b); // multi-byte UTF-8, never escapable + } + } + writeByte((byte) '"'); + } + + void writeNumber(double value) throws IOException { + beforeValue(); + // proto3 JSON encodes the non-finite values as quoted strings; a bare NaN/Infinity is not valid + // JSON. This matches io.opentelemetry.api.common.JsonEncoding. + if (Double.isNaN(value)) { + writeAscii("\"NaN\""); + } else if (Double.isInfinite(value)) { + writeAscii(value > 0 ? "\"Infinity\"" : "\"-Infinity\""); + } else { + writeAscii(Double.toString(value)); + } + } + + /** Writes pre-serialized JSON verbatim, without updating separator state. */ + void writeRaw(String raw) throws IOException { + writeRawBytes(raw.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Drains buffered bytes to the underlying stream. Like {@code ProtoSerializer}, it neither + * flushes nor closes the underlying stream; the caller owns its lifecycle. + */ + void close() throws IOException { + try { + if (pos > 0) { + out.write(buffer, 0, pos); + pos = 0; + } + } catch (IOException e) { + // In try-with-resources, draining may rethrow the same exception that failed the body; wrap + // it so re-throwing doesn't trigger an IllegalArgumentException from illegal + // self-suppression. + throw new IOException(e); + } + } + + private void beforeValue() throws IOException { + if (afterKey) { + afterKey = false; + return; + } + if (hasMembers[depth]) { + writeByte((byte) ','); + } + hasMembers[depth] = true; + } + + private void push() throws IOException { + if (depth == MAX_NESTING_DEPTH) { + throw new IOException( + "JSON nesting depth exceeds maximum allowed (" + MAX_NESTING_DEPTH + ")"); + } + depth++; + if (depth == hasMembers.length) { + hasMembers = Arrays.copyOf(hasMembers, hasMembers.length * 2); + } + hasMembers[depth] = false; + } + + private void pop() { + depth--; + } + + /** Writes a quoted, escaped JSON string, encoding {@code value} as UTF-8. */ + private void writeQuoted(String value) throws IOException { + writeByte((byte) '"'); + int length = value.length(); + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + if (c < 0x80) { + writeEscapedAscii((byte) c); + } else if (c < 0x800) { + writeByte((byte) (0xC0 | (c >> 6))); + writeByte((byte) (0x80 | (c & 0x3F))); + } else if (Character.isHighSurrogate(c) && i + 1 < length) { + char low = value.charAt(i + 1); + if (Character.isLowSurrogate(low)) { + int codePoint = Character.toCodePoint(c, low); + writeByte((byte) (0xF0 | (codePoint >> 18))); + writeByte((byte) (0x80 | ((codePoint >> 12) & 0x3F))); + writeByte((byte) (0x80 | ((codePoint >> 6) & 0x3F))); + writeByte((byte) (0x80 | (codePoint & 0x3F))); + i++; + } else { + writeByte((byte) '?'); + } + } else if (Character.isSurrogate(c)) { + // Unpaired surrogate; emit a replacement to keep the output valid UTF-8. + writeByte((byte) '?'); + } else { + writeByte((byte) (0xE0 | (c >> 12))); + writeByte((byte) (0x80 | ((c >> 6) & 0x3F))); + writeByte((byte) (0x80 | (c & 0x3F))); + } + } + writeByte((byte) '"'); + } + + /** Writes a single ASCII byte (0x00-0x7F), escaping it if required by JSON. */ + private void writeEscapedAscii(byte b) throws IOException { + switch (b) { + case '"': + writeByte((byte) '\\'); + writeByte((byte) '"'); + return; + case '\\': + writeByte((byte) '\\'); + writeByte((byte) '\\'); + return; + case '\b': + writeByte((byte) '\\'); + writeByte((byte) 'b'); + return; + case '\f': + writeByte((byte) '\\'); + writeByte((byte) 'f'); + return; + case '\n': + writeByte((byte) '\\'); + writeByte((byte) 'n'); + return; + case '\r': + writeByte((byte) '\\'); + writeByte((byte) 'r'); + return; + case '\t': + writeByte((byte) '\\'); + writeByte((byte) 't'); + return; + default: + if (b < 0x20) { + writeByte((byte) '\\'); + writeByte((byte) 'u'); + writeByte((byte) '0'); + writeByte((byte) '0'); + writeByte(HEX[(b >> 4) & 0xF]); + writeByte(HEX[b & 0xF]); + } else { + writeByte(b); + } + } + } + + /** Writes the bytes of an ASCII-only string such as a formatted number. */ + private void writeAscii(String value) throws IOException { + int length = value.length(); + for (int i = 0; i < length; i++) { + writeByte((byte) value.charAt(i)); + } + } + + private void writeRawBytes(byte[] bytes) throws IOException { + int offset = 0; + int remaining = bytes.length; + while (remaining > 0) { + if (pos == buffer.length) { + out.write(buffer, 0, pos); + pos = 0; + } + int chunk = Math.min(remaining, buffer.length - pos); + System.arraycopy(bytes, offset, buffer, pos, chunk); + pos += chunk; + offset += chunk; + remaining -= chunk; + } + } + + private void writeByte(byte b) throws IOException { + if (pos == buffer.length) { + out.write(buffer, 0, pos); + pos = 0; + } + buffer[pos++] = b; + } +} diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonSerializer.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonSerializer.java index 62b391c8905..1d89aeba02f 100644 --- a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonSerializer.java +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonSerializer.java @@ -5,201 +5,183 @@ package io.opentelemetry.exporter.internal.marshal; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonGenerator; import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.util.List; final class JsonSerializer extends Serializer { - private static final JsonFactory JSON_FACTORY = new JsonFactory(); + private final JsonBufferedEncoder encoder; - private final JsonGenerator generator; - - JsonSerializer(OutputStream output) throws IOException { - this(JSON_FACTORY.createGenerator(output)); - } - - JsonSerializer(JsonGenerator generator) { - this.generator = generator; + JsonSerializer(OutputStream output) { + this.encoder = new JsonBufferedEncoder(output); } @Override protected void writeTraceId(ProtoFieldInfo field, String traceId) throws IOException { - generator.writeStringField(field.getJsonName(), traceId); + encoder.writeStringField(field.getJsonName(), traceId); } @Override protected void writeSpanId(ProtoFieldInfo field, String spanId) throws IOException { - generator.writeStringField(field.getJsonName(), spanId); + encoder.writeStringField(field.getJsonName(), spanId); } @Override public void writeBool(ProtoFieldInfo field, boolean value) throws IOException { - generator.writeBooleanField(field.getJsonName(), value); + encoder.writeBooleanField(field.getJsonName(), value); } @Override protected void writeEnum(ProtoFieldInfo field, ProtoEnumInfo enumValue) throws IOException { - generator.writeNumberField(field.getJsonName(), enumValue.getEnumNumber()); + encoder.writeNumberField(field.getJsonName(), enumValue.getEnumNumber()); } @Override protected void writeUint32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + encoder.writeNumberField(field.getJsonName(), value); } @Override protected void writeSInt32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + encoder.writeNumberField(field.getJsonName(), value); } @Override protected void writeint32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + encoder.writeNumberField(field.getJsonName(), value); } @Override public void writeInt64(ProtoFieldInfo field, long value) throws IOException { - generator.writeStringField(field.getJsonName(), Long.toString(value)); + encoder.writeStringField(field.getJsonName(), Long.toString(value)); } @Override protected void writeFixed64(ProtoFieldInfo field, long value) throws IOException { - generator.writeStringField(field.getJsonName(), Long.toString(value)); + encoder.writeStringField(field.getJsonName(), Long.toString(value)); } @Override protected void writeFixed64Value(long value) throws IOException { - generator.writeString(Long.toString(value)); + encoder.writeString(Long.toString(value)); } @Override protected void writeUInt64Value(long value) throws IOException { - generator.writeString(Long.toString(value)); + encoder.writeString(Long.toString(value)); } @Override public void writeUInt64(ProtoFieldInfo field, long value) throws IOException { - generator.writeStringField(field.getJsonName(), Long.toString(value)); + encoder.writeStringField(field.getJsonName(), Long.toString(value)); } @Override protected void writeFixed32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + encoder.writeNumberField(field.getJsonName(), value); } @Override public void writeDouble(ProtoFieldInfo field, double value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + encoder.writeNumberField(field.getJsonName(), value); } @Override protected void writeDoubleValue(double value) throws IOException { - generator.writeNumber(value); + encoder.writeNumber(value); } @Override public void writeString(ProtoFieldInfo field, byte[] utf8Bytes) throws IOException { - generator.writeFieldName(field.getJsonName()); - // Marshalers encoded String into UTF-8 bytes to optimize for binary serialization where - // we are able to avoid the encoding process happening twice, one for size computation and one - // for actual writing. JsonGenerator actually has a writeUTF8String that would be able to accept - // this, but it only works when writing to an OutputStream, but not to a String like we do for - // writing to logs. It's wasteful to take a String, convert it to bytes, and convert back to - // the same String but we can see if this can be improved in the future. - generator.writeString(new String(utf8Bytes, StandardCharsets.UTF_8)); + encoder.writeFieldName(field.getJsonName()); + // Marshalers already encoded the String to UTF-8 bytes (binary serialization needs them for + // both size computation and writing), so write them directly rather than decoding and + // re-encoding. + encoder.writeUtf8String(utf8Bytes); } @Override public void writeString( ProtoFieldInfo field, String string, int utf8Length, MarshalerContext context) throws IOException { - generator.writeFieldName(field.getJsonName()); - generator.writeString(string); + encoder.writeFieldName(field.getJsonName()); + encoder.writeString(string); } @Override public void writeRepeatedString(ProtoFieldInfo field, byte[][] utf8Bytes) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); for (byte[] value : utf8Bytes) { - // Marshalers encoded String into UTF-8 bytes to optimize for binary serialization where - // we are able to avoid the encoding process happening twice, one for size computation and one - // for actual writing. JsonGenerator actually has a writeUTF8String that would be able to - // accept - // this, but it only works when writing to an OutputStream, but not to a String like we do for - // writing to logs. It's wasteful to take a String, convert it to bytes, and convert back to - // the same String but we can see if this can be improved in the future. - generator.writeString(new String(value, StandardCharsets.UTF_8)); + // See writeString(ProtoFieldInfo, byte[]): the bytes are already UTF-8, so write directly. + encoder.writeUtf8String(value); } - generator.writeEndArray(); + encoder.writeEndArray(); } @Override public void writeBytes(ProtoFieldInfo field, byte[] value) throws IOException { - generator.writeBinaryField(field.getJsonName(), value); + encoder.writeBinaryField(field.getJsonName(), value); } @Override public void writeByteBuffer(ProtoFieldInfo field, ByteBuffer value) throws IOException { byte[] data = new byte[value.capacity()]; ((ByteBuffer) value.duplicate().clear()).get(data); - generator.writeBinaryField(field.getJsonName(), data); + encoder.writeBinaryField(field.getJsonName(), data); } @Override protected void writeStartMessage(ProtoFieldInfo field, int protoMessageSize) throws IOException { - generator.writeObjectFieldStart(field.getJsonName()); + encoder.writeObjectFieldStart(field.getJsonName()); } @Override protected void writeEndMessage() throws IOException { - generator.writeEndObject(); + encoder.writeEndObject(); } @Override protected void writeStartRepeatedPrimitive( ProtoFieldInfo field, int protoSizePerElement, int numElements) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); } @Override protected void writeEndRepeatedPrimitive() throws IOException { - generator.writeEndArray(); + encoder.writeEndArray(); } @Override protected void writeStartRepeatedVarint(ProtoFieldInfo field, int payloadSize) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); } @Override protected void writeEndRepeatedVarint() throws IOException { - generator.writeEndArray(); + encoder.writeEndArray(); } @Override public void serializeRepeatedMessage(ProtoFieldInfo field, Marshaler[] repeatedMessage) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); for (Marshaler marshaler : repeatedMessage) { writeMessageValue(marshaler); } - generator.writeEndArray(); + encoder.writeEndArray(); } @Override public void serializeRepeatedMessage( ProtoFieldInfo field, List repeatedMessage) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); for (Marshaler marshaler : repeatedMessage) { writeMessageValue(marshaler); } - generator.writeEndArray(); + encoder.writeEndArray(); } @Override @@ -209,52 +191,52 @@ public void serializeRepeatedMessageWithContext( StatelessMarshaler marshaler, MarshalerContext context) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); for (int i = 0; i < messages.size(); i++) { T message = messages.get(i); - generator.writeStartObject(); + encoder.writeStartObject(); marshaler.writeTo(this, message, context); - generator.writeEndObject(); + encoder.writeEndObject(); } - generator.writeEndArray(); + encoder.writeEndArray(); } @Override public void writeStartRepeated(ProtoFieldInfo field) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + encoder.writeArrayFieldStart(field.getJsonName()); } @Override public void writeEndRepeated() throws IOException { - generator.writeEndArray(); + encoder.writeEndArray(); } @Override public void writeStartRepeatedElement(ProtoFieldInfo field, int protoMessageSize) throws IOException { - generator.writeStartObject(); + encoder.writeStartObject(); } @Override public void writeEndRepeatedElement() throws IOException { - generator.writeEndObject(); + encoder.writeEndObject(); } // Not a field. void writeMessageValue(Marshaler message) throws IOException { - generator.writeStartObject(); + encoder.writeStartObject(); message.writeTo(this); - generator.writeEndObject(); + encoder.writeEndObject(); } @Override public void writeSerializedMessage(byte[] protoSerialized, String jsonSerialized) throws IOException { - generator.writeRaw(jsonSerialized); + encoder.writeRaw(jsonSerialized); } @Override public void close() throws IOException { - generator.close(); + encoder.close(); } } diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/Marshaler.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/Marshaler.java index d8ea5a9e5f4..1c65e3303ce 100644 --- a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/Marshaler.java +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/Marshaler.java @@ -5,7 +5,6 @@ package io.opentelemetry.exporter.internal.marshal; -import com.fasterxml.jackson.core.JsonGenerator; import io.opentelemetry.sdk.common.export.MessageWriter; import java.io.IOException; import java.io.OutputStream; @@ -32,24 +31,6 @@ public final void writeJsonTo(OutputStream output) throws IOException { } } - /** Marshals into the {@link JsonGenerator} in proto JSON format. */ - // Intentionally not overloading writeJsonTo(OutputStream) in order to avoid compilation - // dependency on jackson when using writeJsonTo(OutputStream). See: - // https://github.com/open-telemetry/opentelemetry-java-contrib/pull/1551#discussion_r1849064365 - public final void writeJsonToGenerator(JsonGenerator output) throws IOException { - try (JsonSerializer serializer = new JsonSerializer(output)) { - serializer.writeMessageValue(this); - } - } - - /** Marshals into the {@link JsonGenerator} in proto JSON format and adds a newline. */ - public final void writeJsonWithNewline(JsonGenerator output) throws IOException { - try (JsonSerializer serializer = new JsonSerializer(output)) { - serializer.writeMessageValue(this); - output.writeRaw('\n'); - } - } - /** Returns the number of bytes this Marshaler will write in proto binary format. */ public abstract int getBinarySerializedSize(); diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/MarshalerUtil.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/MarshalerUtil.java index 494853723dc..9d7a10471f6 100644 --- a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/MarshalerUtil.java +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/MarshalerUtil.java @@ -35,19 +35,6 @@ public final class MarshalerUtil { private static final int SPAN_ID_VALUE_SIZE = CodedOutputStream.computeLengthDelimitedFieldSize(SpanId.getLength() / 2); - private static final boolean JSON_AVAILABLE; - - static { - boolean jsonAvailable = false; - try { - Class.forName("com.fasterxml.jackson.core.JsonFactory"); - jsonAvailable = true; - } catch (ClassNotFoundException e) { - // Not available - } - JSON_AVAILABLE = jsonAvailable; - } - private static final byte[] EMPTY_BYTES = new byte[0]; /** Groups SDK items by resource and instrumentation scope. */ @@ -73,10 +60,6 @@ Map>> groupByResourceAndScope( /** Preserialize into JSON format. */ public static String preserializeJsonFields(Marshaler marshaler) { - if (!MarshalerUtil.JSON_AVAILABLE) { - return ""; - } - ByteArrayOutputStream jsonBos = new ByteArrayOutputStream(); try { marshaler.writeJsonTo(jsonBos); @@ -85,11 +68,8 @@ public static String preserializeJsonFields(Marshaler marshaler) { "Serialization error, this is likely a bug in OpenTelemetry.", e); } - // We effectively cache `writeTo`, however Jackson would not allow us to only write out - // fields - // which is what writeTo does. So we need to write to an object but skip the object start - // / - // end. + // Strip the surrounding { } so the cached value can be embedded as the fields of an enclosing + // object. byte[] jsonBytes = jsonBos.toByteArray(); return new String(jsonBytes, 1, jsonBytes.length - 2, StandardCharsets.UTF_8); } diff --git a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoderTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoderTest.java new file mode 100644 index 00000000000..b4f9df3f16e --- /dev/null +++ b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoderTest.java @@ -0,0 +1,304 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.marshal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; +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; + +class JsonBufferedEncoderTest { + + @FunctionalInterface + private interface Body { + void accept(JsonBufferedEncoder encoder) throws IOException; + } + + @ParameterizedTest + @MethodSource("cases") + void writesExpectedJson(Body body, String expected) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + JsonBufferedEncoder encoder = new JsonBufferedEncoder(bos); + body.accept(encoder); + encoder.close(); + assertThat(new String(bos.toByteArray(), StandardCharsets.UTF_8)).isEqualTo(expected); + } + + // Writes a string whose UTF-8 encoding exceeds the encoder's 4096-byte internal buffer, forcing + // at least one intermediate flush to the underlying stream before close(). + @Test + void flushesWhenBufferFills() throws Exception { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 5000; i++) { + sb.append('a'); + } + String longValue = sb.toString(); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + JsonBufferedEncoder encoder = new JsonBufferedEncoder(bos); + encoder.writeString(longValue); + // Before close, the underlying stream must have received bytes from at least one mid-write + // flush; otherwise everything would still be sitting in the 4096-byte buffer. + assertThat(bos.size()).isGreaterThan(0); + encoder.close(); + assertThat(new String(bos.toByteArray(), StandardCharsets.UTF_8)) + .isEqualTo("\"" + longValue + "\""); + } + + // Nests more objects than the initial hasMembers array capacity (16), forcing the array to grow. + @Test + void growsWhenNestingExceedsInitialCapacity() throws Exception { + int depth = 20; + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + JsonBufferedEncoder encoder = new JsonBufferedEncoder(bos); + for (int i = 0; i < depth; i++) { + encoder.writeStartObject(); + encoder.writeFieldName("k" + i); + } + encoder.writeString("v"); + for (int i = 0; i < depth; i++) { + encoder.writeEndObject(); + } + encoder.close(); + + StringBuilder expected = new StringBuilder(); + for (int i = 0; i < depth; i++) { + expected.append("{\"k").append(i).append("\":"); + } + expected.append("\"v\""); + for (int i = 0; i < depth; i++) { + expected.append('}'); + } + assertThat(new String(bos.toByteArray(), StandardCharsets.UTF_8)) + .isEqualTo(expected.toString()); + } + + // Unpaired surrogates cannot be encoded as UTF-8; the encoder emits '?' in their place so the + // output remains valid UTF-8. + @Test + void invalidSurrogatesEmitReplacement() throws Exception { + // Lone low surrogate (never preceded by a high surrogate). + assertString(String.valueOf((char) 0xDC00), "\"?\""); + // High surrogate at end of string (no following char). + assertString(String.valueOf((char) 0xD800), "\"?\""); + // High surrogate followed by a non-low-surrogate BMP character (U+00E9 e-acute); the high + // surrogate is replaced and the following char is emitted normally. + assertString("" + (char) 0xD800 + (char) 0x00E9, "\"?" + (char) 0xE9 + "\""); + } + + @Test + void nestingBeyondMaxDepthThrows() throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + JsonBufferedEncoder encoder = new JsonBufferedEncoder(bos); + // MAX_NESTING_DEPTH successive writeStartObject calls are allowed; the next one must throw. + for (int i = 0; i < JsonBufferedEncoder.MAX_NESTING_DEPTH; i++) { + encoder.writeStartObject(); + encoder.writeFieldName("k"); + } + assertThatThrownBy(encoder::writeStartObject) + .isInstanceOf(IOException.class) + .hasMessageContaining("nesting depth"); + } + + // close() drains the buffer to the underlying stream; if that write throws, the exception is + // wrapped so it can propagate out of a try-with-resources without self-suppression issues. + @Test + void closeWrapsIoException() throws Exception { + IOException boom = new IOException("boom"); + OutputStream failing = + new OutputStream() { + @Override + public void write(int b) throws IOException { + throw boom; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw boom; + } + }; + JsonBufferedEncoder encoder = new JsonBufferedEncoder(failing); + // Write a single byte so there is buffered output for close() to attempt to flush. + encoder.writeString("x"); + assertThatThrownBy(encoder::close).isInstanceOf(IOException.class).hasCause(boom); + } + + private static void assertString(String input, String expected) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + JsonBufferedEncoder encoder = new JsonBufferedEncoder(bos); + encoder.writeString(input); + encoder.close(); + assertThat(new String(bos.toByteArray(), StandardCharsets.UTF_8)).isEqualTo(expected); + } + + static Stream cases() { + // Built with concatenation to keep the source ASCII: quote, backslash, slash, control-char + // shortcuts, NUL, U+001F, 'a', and the 2-/3-/4-byte UTF-8 chars U+00E9 (e-acute), U+4E16 (CJK), + // and U+1F600 (grinning face). Forward slash is not escaped, control characters use lowercase + // \\u00xx, and multi-byte UTF-8 passes through verbatim. + String escapeInput = + "\"" + + '\\' + + '/' + + '\b' + + '\f' + + '\n' + + '\r' + + '\t' + + (char) 0x00 + + (char) 0x1f + + 'a' + + (char) 0xe9 + + (char) 0x4e16 + + new String(Character.toChars(0x1f600)); + String escapeExpected = + "\"\\\"\\\\/\\b\\f\\n\\r\\t\\u0000\\u001fa" + + (char) 0xe9 + + (char) 0x4e16 + + new String(Character.toChars(0x1f600)) + + "\""; + + // 'a', TAB, then U+00E9 (e-acute) as its two UTF-8 bytes 0xC3 0xA9. + byte[] utf8 = {'a', '\t', (byte) 0xc3, (byte) 0xa9}; + String utf8Expected = "\"a\\t" + (char) 0xe9 + "\""; + + return Stream.of( + Arguments.argumentSet( + "empty object", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeEndObject(); + }, + "{}"), + Arguments.argumentSet( + "object inserts separators between fields", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeStringField("a", "b"); + encoder.writeNumberField("n", 1); + encoder.writeBooleanField("t", true); + encoder.writeBooleanField("f", false); + encoder.writeEndObject(); + }, + "{\"a\":\"b\",\"n\":1,\"t\":true,\"f\":false}"), + Arguments.argumentSet( + "nested objects and arrays", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeObjectFieldStart("o"); + encoder.writeStringField("k", "v"); + encoder.writeEndObject(); + encoder.writeArrayFieldStart("arr"); + encoder.writeString("x"); + encoder.writeNumber(2.5); + encoder.writeEndArray(); + encoder.writeEndObject(); + }, + "{\"o\":{\"k\":\"v\"},\"arr\":[\"x\",2.5]}"), + Arguments.argumentSet( + "string escaping", (Body) encoder -> encoder.writeString(escapeInput), escapeExpected), + Arguments.argumentSet( + "writeUtf8String escapes ascii and passes multi-byte through", + (Body) encoder -> encoder.writeUtf8String(utf8), + utf8Expected), + Arguments.argumentSet( + "integer boundaries", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeNumberField("min", Integer.MIN_VALUE); + encoder.writeNumberField("max", Integer.MAX_VALUE); + encoder.writeEndObject(); + }, + "{\"min\":-2147483648,\"max\":2147483647}"), + Arguments.argumentSet( + "exponent and signed-zero doubles", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeArrayFieldStart("d"); + encoder.writeNumber(1.0e7); + encoder.writeNumber(1.0e-4); + encoder.writeNumber(-0.0); + encoder.writeNumber(0.0); + encoder.writeEndArray(); + encoder.writeEndObject(); + }, + "{\"d\":[1.0E7,1.0E-4,-0.0,0.0]}"), + Arguments.argumentSet( + "empty base64", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeBinaryField("b", new byte[0]); + encoder.writeEndObject(); + }, + "{\"b\":\"\"}"), + Arguments.argumentSet( + "objects in array", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeArrayFieldStart("a"); + encoder.writeStartObject(); + encoder.writeNumberField("x", 1); + encoder.writeEndObject(); + encoder.writeStartObject(); + encoder.writeNumberField("y", 2); + encoder.writeEndObject(); + encoder.writeEndArray(); + encoder.writeEndObject(); + }, + "{\"a\":[{\"x\":1},{\"y\":2}]}"), + Arguments.argumentSet( + "non-finite doubles are quoted", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeArrayFieldStart("d"); + encoder.writeNumber(Double.NaN); + encoder.writeNumber(Double.POSITIVE_INFINITY); + encoder.writeNumber(Double.NEGATIVE_INFINITY); + encoder.writeNumber(1.5); + encoder.writeEndArray(); + encoder.writeEndObject(); + }, + "{\"d\":[\"NaN\",\"Infinity\",\"-Infinity\",1.5]}"), + Arguments.argumentSet( + "binary field is base64", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeBinaryField("b", new byte[] {1, 2, 3}); + encoder.writeEndObject(); + }, + "{\"b\":\"AQID\"}"), + // Mirrors MarshalerUtil#preserializeJsonFields: pre-serialized fields written raw into an + // otherwise empty object, without disturbing separator state. + Arguments.argumentSet( + "writeRaw embeds verbatim", + (Body) + encoder -> { + encoder.writeStartObject(); + encoder.writeObjectFieldStart("resource"); + encoder.writeRaw("\"key\":\"val\""); + encoder.writeEndObject(); + encoder.writeEndObject(); + }, + "{\"resource\":{\"key\":\"val\"}}")); + } +} diff --git a/exporters/logging-otlp/build.gradle.kts b/exporters/logging-otlp/build.gradle.kts index bdec906e21c..55219c9daee 100644 --- a/exporters/logging-otlp/build.gradle.kts +++ b/exporters/logging-otlp/build.gradle.kts @@ -24,8 +24,6 @@ dependencies { compileOnly(project(":api:incubator")) compileOnly(project(":sdk-extensions:autoconfigure-spi")) - implementation("com.fasterxml.jackson.core:jackson-core") - testImplementation(project(":api:incubator")) testImplementation(project(":sdk:testing")) testImplementation(project(":sdk-extensions:autoconfigure-spi")) diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/JsonUtil.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/JsonUtil.java deleted file mode 100644 index 0b74ed8a478..00000000000 --- a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/JsonUtil.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.opentelemetry.exporter.logging.otlp.internal.writer; - -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.io.SegmentedStringWriter; -import java.io.IOException; - -/** - * This class is internal and is hence not for public use. Its APIs are unstable and can change at - * any time. - */ -public final class JsonUtil { - - public static final JsonFactory JSON_FACTORY = new JsonFactory(); - - public static JsonGenerator create(SegmentedStringWriter stringWriter) { - try { - return JSON_FACTORY.createGenerator(stringWriter); - } catch (IOException e) { - throw new IllegalStateException("Unable to create in-memory JsonGenerator, can't happen.", e); - } - } - - private JsonUtil() {} -} diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriter.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriter.java index a95c5e0d2c1..a071dec98e3 100644 --- a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriter.java +++ b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriter.java @@ -5,13 +5,11 @@ package io.opentelemetry.exporter.logging.otlp.internal.writer; -import static io.opentelemetry.exporter.logging.otlp.internal.writer.JsonUtil.JSON_FACTORY; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.io.SegmentedStringWriter; import io.opentelemetry.exporter.internal.marshal.Marshaler; import io.opentelemetry.sdk.common.CompletableResultCode; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.logging.Level; import java.util.logging.Logger; @@ -29,23 +27,25 @@ public LoggerJsonWriter(Logger logger, String type) { this.type = type; } + // toString(String) decodes straight from the internal buffer, avoiding the array copy that + // new String(bos.toByteArray(), UTF_8) makes. The toString(Charset) overload errorprone wants is + // Java 10+, but this module targets Java 8. + @SuppressWarnings("JdkObsolete") @Override public CompletableResultCode write(Marshaler exportRequest) { - SegmentedStringWriter sw = new SegmentedStringWriter(JSON_FACTORY._getBufferRecycler()); - try (JsonGenerator gen = JsonUtil.create(sw)) { - exportRequest.writeJsonToGenerator(gen); - } catch (IOException e) { - logger.log(Level.WARNING, "Unable to write OTLP JSON " + type, e); - return CompletableResultCode.ofFailure(); - } - + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + String json; try { - logger.log(Level.INFO, sw.getAndClear()); - return CompletableResultCode.ofSuccess(); + exportRequest.writeJsonTo(bos); + // UTF-8 is always available; the declared UnsupportedEncodingException cannot occur. + json = bos.toString(StandardCharsets.UTF_8.name()); } catch (IOException e) { logger.log(Level.WARNING, "Unable to write OTLP JSON " + type, e); return CompletableResultCode.ofFailure(); } + + logger.log(Level.INFO, json); + return CompletableResultCode.ofSuccess(); } @Override diff --git a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriter.java b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriter.java index a3e4e262a7e..7e17363b58a 100644 --- a/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriter.java +++ b/exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriter.java @@ -5,8 +5,6 @@ package io.opentelemetry.exporter.logging.otlp.internal.writer; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonGenerator; import io.opentelemetry.exporter.internal.marshal.Marshaler; import io.opentelemetry.sdk.common.CompletableResultCode; import io.opentelemetry.sdk.common.internal.ThrottlingLogger; @@ -21,8 +19,6 @@ */ public class StreamJsonWriter implements JsonWriter { - public static final JsonFactory JSON_FACTORY = new JsonFactory(); - private static final Logger internalLogger = Logger.getLogger(StreamJsonWriter.class.getName()); private final ThrottlingLogger logger = new ThrottlingLogger(internalLogger); @@ -38,10 +34,9 @@ public StreamJsonWriter(OutputStream originalStream, String type) { @Override public CompletableResultCode write(Marshaler exportRequest) { try { - exportRequest.writeJsonWithNewline( - JSON_FACTORY - .createGenerator(outputStream) - .disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET)); + exportRequest.writeJsonTo(outputStream); + outputStream.write('\n'); + outputStream.flush(); return CompletableResultCode.ofSuccess(); } catch (IOException e) { logger.log(Level.WARNING, "Unable to write OTLP JSON " + type, e); diff --git a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriterTest.java b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriterTest.java index 680c7cc444d..566000443af 100644 --- a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriterTest.java +++ b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/LoggerJsonWriterTest.java @@ -9,11 +9,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import com.fasterxml.jackson.core.JsonGenerator; import io.github.netmikey.logunit.api.LogCapturer; import io.opentelemetry.exporter.internal.marshal.Marshaler; import io.opentelemetry.internal.testing.slf4j.SuppressLogger; import java.io.IOException; +import java.io.OutputStream; import java.util.logging.Logger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -34,9 +34,7 @@ void testToString() { @Test void error() throws IOException { Marshaler marshaler = mock(Marshaler.class); - Mockito.doThrow(new IOException("test")) - .when(marshaler) - .writeJsonToGenerator(any(JsonGenerator.class)); + Mockito.doThrow(new IOException("test")).when(marshaler).writeJsonTo(any(OutputStream.class)); Logger logger = Logger.getLogger(LoggerJsonWriter.class.getName()); diff --git a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java index 048fcab1c17..de88db42dec 100644 --- a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java +++ b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java @@ -9,7 +9,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import com.fasterxml.jackson.core.JsonGenerator; import io.github.netmikey.logunit.api.LogCapturer; import io.opentelemetry.exporter.internal.marshal.Marshaler; import io.opentelemetry.internal.testing.slf4j.SuppressLogger; @@ -48,9 +47,7 @@ void testToString() throws IOException { @Test void errorWriting() throws IOException { Marshaler marshaler = mock(Marshaler.class); - Mockito.doThrow(new IOException("test")) - .when(marshaler) - .writeJsonWithNewline(any(JsonGenerator.class)); + Mockito.doThrow(new IOException("test")).when(marshaler).writeJsonTo(any(OutputStream.class)); StreamJsonWriter writer = new StreamJsonWriter(System.out, "type"); writer.write(marshaler); diff --git a/exporters/otlp/common/build.gradle.kts b/exporters/otlp/common/build.gradle.kts index 9acc0125e7f..937f30785a3 100644 --- a/exporters/otlp/common/build.gradle.kts +++ b/exporters/otlp/common/build.gradle.kts @@ -30,14 +30,12 @@ dependencies { testImplementation(project(":sdk:logs")) testImplementation(project(":sdk:testing")) - testImplementation("com.fasterxml.jackson.core:jackson-databind") testImplementation("com.google.protobuf:protobuf-java-util") testImplementation("com.google.guava:guava") testImplementation("io.opentelemetry.proto:opentelemetry-proto") jmhImplementation(project(":api:incubator")) jmhImplementation(project(":sdk:testing")) - jmhImplementation("com.fasterxml.jackson.core:jackson-core") jmhImplementation("io.opentelemetry.proto:opentelemetry-proto") jmhImplementation("io.grpc:grpc-netty") } @@ -49,7 +47,6 @@ testing { implementation(project(":api:incubator")) implementation(project(":sdk:testing")) - implementation("com.fasterxml.jackson.core:jackson-databind") implementation("com.google.protobuf:protobuf-java-util") implementation("com.google.guava:guava") implementation("io.opentelemetry.proto:opentelemetry-proto") diff --git a/exporters/otlp/profiles/build.gradle.kts b/exporters/otlp/profiles/build.gradle.kts index bbed9ee6837..ccd965fba5f 100644 --- a/exporters/otlp/profiles/build.gradle.kts +++ b/exporters/otlp/profiles/build.gradle.kts @@ -20,7 +20,6 @@ dependencies { compileOnly("io.grpc:grpc-stub") testCompileOnly("com.google.guava:guava") - testImplementation("com.fasterxml.jackson.core:jackson-databind") testImplementation("com.google.protobuf:protobuf-java-util") testImplementation("io.opentelemetry.proto:opentelemetry-proto") testImplementation(project(":exporters:otlp:testing-internal")) diff --git a/exporters/sender/jdk/build.gradle.kts b/exporters/sender/jdk/build.gradle.kts index d7545fab832..26cd80a9b9c 100644 --- a/exporters/sender/jdk/build.gradle.kts +++ b/exporters/sender/jdk/build.gradle.kts @@ -15,6 +15,4 @@ dependencies { implementation(project(":exporters:common")) implementation(project(":sdk:common")) - - testImplementation("com.fasterxml.jackson.core:jackson-core") } diff --git a/exporters/sender/okhttp/build.gradle.kts b/exporters/sender/okhttp/build.gradle.kts index 29f1badf131..85aa30f3125 100644 --- a/exporters/sender/okhttp/build.gradle.kts +++ b/exporters/sender/okhttp/build.gradle.kts @@ -24,7 +24,6 @@ dependencies { implementation("com.squareup.okhttp3:okhttp") compileOnly("io.grpc:grpc-stub") - compileOnly("com.fasterxml.jackson.core:jackson-core") testImplementation("com.linecorp.armeria:armeria-junit5") }