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/3] 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/3] 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")
}
From 3ac38d96cdfe93c42b535cb16f5a13e76abdb080 Mon Sep 17 00:00:00 2001
From: Jack Berg <34418638+jack-berg@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:36:42 -0500
Subject: [PATCH 3/3] PR feedback
---
...onWriter.java => JsonBufferedEncoder.java} | 45 ++-
.../internal/marshal/JsonSerializer.java | 96 +++---
.../marshal/JsonBufferedEncoderTest.java | 304 ++++++++++++++++++
.../internal/marshal/JsonWriterTest.java | 199 ------------
4 files changed, 381 insertions(+), 263 deletions(-)
rename exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/{JsonWriter.java => JsonBufferedEncoder.java} (83%)
create mode 100644 exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoderTest.java
delete mode 100644 exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
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/JsonBufferedEncoder.java
similarity index 83%
rename from exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriter.java
rename to exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoder.java
index 3d7596aa00f..b679893ed17 100644
--- a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriter.java
+++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonBufferedEncoder.java
@@ -12,16 +12,24 @@
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}.
+ * 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 JsonWriter {
+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'};
@@ -33,14 +41,15 @@ final class JsonWriter {
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];
+ // 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;
- JsonWriter(OutputStream out) {
+ JsonBufferedEncoder(OutputStream out) {
this.out = out;
}
@@ -73,10 +82,10 @@ void writeEndArray() throws IOException {
}
void writeFieldName(String name) throws IOException {
- if (counts[depth] > 0) {
+ if (hasMembers[depth]) {
writeByte((byte) ',');
}
- counts[depth]++;
+ hasMembers[depth] = true;
writeQuoted(name);
writeByte((byte) ':');
afterKey = true;
@@ -176,18 +185,22 @@ private void beforeValue() throws IOException {
afterKey = false;
return;
}
- if (counts[depth] > 0) {
+ if (hasMembers[depth]) {
writeByte((byte) ',');
}
- counts[depth]++;
+ hasMembers[depth] = true;
}
- private void push() {
+ 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 == counts.length) {
- counts = Arrays.copyOf(counts, counts.length * 2);
+ if (depth == hasMembers.length) {
+ hasMembers = Arrays.copyOf(hasMembers, hasMembers.length * 2);
}
- counts[depth] = 0;
+ hasMembers[depth] = false;
}
private void pop() {
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 9a3c0169cb9..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
@@ -12,176 +12,176 @@
final class JsonSerializer extends Serializer {
- private final JsonWriter generator;
+ private final JsonBufferedEncoder encoder;
JsonSerializer(OutputStream output) {
- this.generator = new JsonWriter(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());
+ 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.
- generator.writeUtf8String(utf8Bytes);
+ 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) {
// See writeString(ProtoFieldInfo, byte[]): the bytes are already UTF-8, so write directly.
- generator.writeUtf8String(value);
+ 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 extends Marshaler> repeatedMessage) throws IOException {
- generator.writeArrayFieldStart(field.getJsonName());
+ encoder.writeArrayFieldStart(field.getJsonName());
for (Marshaler marshaler : repeatedMessage) {
writeMessageValue(marshaler);
}
- generator.writeEndArray();
+ encoder.writeEndArray();
}
@Override
@@ -191,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/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/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
deleted file mode 100644
index 32c9b5a4803..00000000000
--- a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTest.java
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * 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', 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)
- 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(
- "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)
- 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\"}}"));
- }
-}