From 68ef0dbaf7934868d00c9ff8d97500fe037102ae Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:43:17 -0500
Subject: [PATCH 1/2] Replace jackson OTLP json serialization with handrolled
version
---
exporters/common/build.gradle.kts | 5 +-
.../internal/marshal/JsonSerializer.java | 36 +-
.../exporter/internal/marshal/JsonWriter.java | 308 ++++++++++++++++++
.../exporter/internal/marshal/Marshaler.java | 19 --
.../internal/marshal/MarshalerUtil.java | 24 +-
.../internal/marshal/JsonWriterTest.java | 147 +++++++++
exporters/logging-otlp/build.gradle.kts | 2 -
.../otlp/internal/writer/JsonUtil.java | 30 --
.../internal/writer/LoggerJsonWriter.java | 28 +-
.../internal/writer/StreamJsonWriter.java | 11 +-
.../internal/writer/LoggerJsonWriterTest.java | 6 +-
.../internal/writer/StreamJsonWriterTest.java | 5 +-
exporters/sender/okhttp/build.gradle.kts | 1 -
13 files changed, 487 insertions(+), 135 deletions(-)
create mode 100644 exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriter.java
create mode 100644 exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
delete mode 100644 exporters/logging-otlp/src/main/java/io/opentelemetry/exporter/logging/otlp/internal/writer/JsonUtil.java
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/JsonSerializer.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonSerializer.java
index 62b391c8905..9a3c0169cb9 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,26 +5,17 @@
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 JsonWriter generator;
- private final JsonGenerator generator;
-
- JsonSerializer(OutputStream output) throws IOException {
- this(JSON_FACTORY.createGenerator(output));
- }
-
- JsonSerializer(JsonGenerator generator) {
- this.generator = generator;
+ JsonSerializer(OutputStream output) {
+ this.generator = new JsonWriter(output);
}
@Override
@@ -105,13 +96,10 @@ protected void writeDoubleValue(double value) throws IOException {
@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));
+ // 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.
+ generator.writeUtf8String(utf8Bytes);
}
@Override
@@ -126,14 +114,8 @@ public void writeString(
public void writeRepeatedString(ProtoFieldInfo field, byte[][] utf8Bytes) throws IOException {
generator.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.
+ generator.writeUtf8String(value);
}
generator.writeEndArray();
}
diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriter.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriter.java
new file mode 100644
index 00000000000..3d7596aa00f
--- /dev/null
+++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriter.java
@@ -0,0 +1,308 @@
+/*
+ * 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 writer that serializes directly to an {@link OutputStream} as UTF-8, 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.
+ */
+final class JsonWriter {
+
+ 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;
+
+ // Number of members already written at each nesting depth; depth 0 is the implicit root.
+ private int[] counts = new int[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;
+
+ JsonWriter(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 (counts[depth] > 0) {
+ writeByte((byte) ',');
+ }
+ counts[depth]++;
+ 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 (counts[depth] > 0) {
+ writeByte((byte) ',');
+ }
+ counts[depth]++;
+ }
+
+ private void push() {
+ depth++;
+ if (depth == counts.length) {
+ counts = Arrays.copyOf(counts, counts.length * 2);
+ }
+ counts[depth] = 0;
+ }
+
+ 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/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/JsonWriterTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
new file mode 100644
index 00000000000..77271a7198b
--- /dev/null
+++ b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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 java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class JsonWriterTest {
+
+ @FunctionalInterface
+ private interface Body {
+ void accept(JsonWriter writer) throws IOException;
+ }
+
+ @ParameterizedTest
+ @MethodSource("cases")
+ void writesExpectedJson(Body body, String expected) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ JsonWriter writer = new JsonWriter(bos);
+ try {
+ body.accept(writer);
+ writer.close();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ 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', U+00E9 (e-acute), and U+1F600 (grinning face). Forward slash is
+ // not escaped, control characters use lowercase \\u00xx, and multi-byte UTF-8 passes through.
+ String escapeInput =
+ "\""
+ + '\\'
+ + '/'
+ + '\b'
+ + '\f'
+ + '\n'
+ + '\r'
+ + '\t'
+ + (char) 0x00
+ + (char) 0x1f
+ + 'a'
+ + (char) 0xe9
+ + new String(Character.toChars(0x1f600));
+ String escapeExpected =
+ "\"\\\"\\\\/\\b\\f\\n\\r\\t\\u0000\\u001fa"
+ + (char) 0xe9
+ + 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)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeEndObject();
+ },
+ "{}"),
+ Arguments.argumentSet(
+ "object inserts separators between fields",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeStringField("a", "b");
+ writer.writeNumberField("n", 1);
+ writer.writeBooleanField("t", true);
+ writer.writeBooleanField("f", false);
+ writer.writeEndObject();
+ },
+ "{\"a\":\"b\",\"n\":1,\"t\":true,\"f\":false}"),
+ Arguments.argumentSet(
+ "nested objects and arrays",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeObjectFieldStart("o");
+ writer.writeStringField("k", "v");
+ writer.writeEndObject();
+ writer.writeArrayFieldStart("arr");
+ writer.writeString("x");
+ writer.writeNumber(2.5);
+ writer.writeEndArray();
+ writer.writeEndObject();
+ },
+ "{\"o\":{\"k\":\"v\"},\"arr\":[\"x\",2.5]}"),
+ Arguments.argumentSet(
+ "string escaping", (Body) writer -> writer.writeString(escapeInput), escapeExpected),
+ Arguments.argumentSet(
+ "writeUtf8String escapes ascii and passes multi-byte through",
+ (Body) writer -> writer.writeUtf8String(utf8),
+ utf8Expected),
+ Arguments.argumentSet(
+ "non-finite doubles are quoted",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeArrayFieldStart("d");
+ writer.writeNumber(Double.NaN);
+ writer.writeNumber(Double.POSITIVE_INFINITY);
+ writer.writeNumber(Double.NEGATIVE_INFINITY);
+ writer.writeNumber(1.5);
+ writer.writeEndArray();
+ writer.writeEndObject();
+ },
+ "{\"d\":[\"NaN\",\"Infinity\",\"-Infinity\",1.5]}"),
+ Arguments.argumentSet(
+ "binary field is base64",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeBinaryField("b", new byte[] {1, 2, 3});
+ writer.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)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeObjectFieldStart("resource");
+ writer.writeRaw("\"key\":\"val\"");
+ writer.writeEndObject();
+ writer.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/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")
}
From f176aefbb8160aea9f14e9423a07501ae40bfd26 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Fri, 26 Jun 2026 13:45:59 -0500
Subject: [PATCH 2/2] Add more test coverage, remove straggler jackson
dependencies
---
.../internal/marshal/JsonWriterTest.java | 56 ++++++++++++++++++-
exporters/otlp/common/build.gradle.kts | 3 -
exporters/otlp/profiles/build.gradle.kts | 1 -
exporters/sender/jdk/build.gradle.kts | 2 -
4 files changed, 54 insertions(+), 8 deletions(-)
diff --git a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
index 77271a7198b..32c9b5a4803 100644
--- a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
+++ b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
@@ -39,8 +39,9 @@ void writesExpectedJson(Body body, String expected) {
static Stream cases() {
// Built with concatenation to keep the source ASCII: quote, backslash, slash, control-char
- // shortcuts, NUL, U+001F, 'a', U+00E9 (e-acute), and U+1F600 (grinning face). Forward slash is
- // not escaped, control characters use lowercase \\u00xx, and multi-byte UTF-8 passes through.
+ // 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 =
"\""
+ '\\'
@@ -54,10 +55,12 @@ static Stream cases() {
+ (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))
+ "\"";
@@ -107,6 +110,55 @@ static Stream cases() {
"writeUtf8String escapes ascii and passes multi-byte through",
(Body) writer -> writer.writeUtf8String(utf8),
utf8Expected),
+ Arguments.argumentSet(
+ "integer boundaries",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeNumberField("min", Integer.MIN_VALUE);
+ writer.writeNumberField("max", Integer.MAX_VALUE);
+ writer.writeEndObject();
+ },
+ "{\"min\":-2147483648,\"max\":2147483647}"),
+ Arguments.argumentSet(
+ "exponent and signed-zero doubles",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeArrayFieldStart("d");
+ writer.writeNumber(1.0e7);
+ writer.writeNumber(1.0e-4);
+ writer.writeNumber(-0.0);
+ writer.writeNumber(0.0);
+ writer.writeEndArray();
+ writer.writeEndObject();
+ },
+ "{\"d\":[1.0E7,1.0E-4,-0.0,0.0]}"),
+ Arguments.argumentSet(
+ "empty base64",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeBinaryField("b", new byte[0]);
+ writer.writeEndObject();
+ },
+ "{\"b\":\"\"}"),
+ Arguments.argumentSet(
+ "objects in array",
+ (Body)
+ writer -> {
+ writer.writeStartObject();
+ writer.writeArrayFieldStart("a");
+ writer.writeStartObject();
+ writer.writeNumberField("x", 1);
+ writer.writeEndObject();
+ writer.writeStartObject();
+ writer.writeNumberField("y", 2);
+ writer.writeEndObject();
+ writer.writeEndArray();
+ writer.writeEndObject();
+ },
+ "{\"a\":[{\"x\":1},{\"y\":2}]}"),
Arguments.argumentSet(
"non-finite doubles are quoted",
(Body)
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")
}