diff --git a/exporters/common/build.gradle.kts b/exporters/common/build.gradle.kts index c12a945698a..063f11c5eb4 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. @@ -71,13 +71,13 @@ dependencies { // 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") testImplementation(project(":sdk:common")) testImplementation(project(":sdk:testing")) + testImplementation("com.fasterxml.jackson.core:jackson-core") testImplementation("com.google.protobuf:protobuf-java-util") testImplementation("com.linecorp.armeria:armeria-junit5") diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonEscaping.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonEscaping.java new file mode 100644 index 00000000000..25ec5c5b093 --- /dev/null +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonEscaping.java @@ -0,0 +1,144 @@ +/* + * 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; + +final class JsonEscaping { + + private static final char[] HEX_DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' + }; + + static void escapeStringTo(StringBuilder sb, String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(HEX_DIGITS[(c >> 12) & 0xF]); + sb.append(HEX_DIGITS[(c >> 8) & 0xF]); + sb.append(HEX_DIGITS[(c >> 4) & 0xF]); + sb.append(HEX_DIGITS[c & 0xF]); + } else { + sb.append(c); + } + } + } + } + + static void escapeStringTo(byte[] buf, int[] posRef, OutputStream out, String value) + throws IOException { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + String escape = null; + switch (c) { + case '"': + escape = "\\\""; + break; + case '\\': + escape = "\\\\"; + break; + case '\b': + escape = "\\b"; + break; + case '\f': + escape = "\\f"; + break; + case '\n': + escape = "\\n"; + break; + case '\r': + escape = "\\r"; + break; + case '\t': + escape = "\\t"; + break; + default: + if (c < 0x20) { + escape = + "\\u" + + HEX_DIGITS[(c >> 12) & 0xF] + + HEX_DIGITS[(c >> 8) & 0xF] + + HEX_DIGITS[(c >> 4) & 0xF] + + HEX_DIGITS[c & 0xF]; + } + } + if (escape != null) { + writeAscii(buf, posRef, out, escape); + } else { + // Non-escape character: find the run of non-escape chars and write them as a UTF-8 chunk + int start = i; + while (i + 1 < value.length()) { + char next = value.charAt(i + 1); + if (next == '"' || next == '\\' || next < 0x20) { + break; + } + i++; + } + writeUtf8(buf, posRef, out, value, start, i + 1); + } + } + } + + private static void writeAscii(byte[] buf, int[] posRef, OutputStream out, String ascii) + throws IOException { + int pos = posRef[0]; + int len = ascii.length(); + if (pos + len > buf.length) { + out.write(buf, 0, pos); + pos = 0; + } + for (int i = 0; i < len; i++) { + buf[pos++] = (byte) ascii.charAt(i); + } + posRef[0] = pos; + } + + private static void writeUtf8( + byte[] buf, int[] posRef, OutputStream out, String value, int start, int end) + throws IOException { + byte[] utf8 = value.substring(start, end).getBytes(StandardCharsets.UTF_8); + int pos = posRef[0]; + if (pos + utf8.length > buf.length) { + out.write(buf, 0, pos); + pos = 0; + } + if (utf8.length > buf.length) { + out.write(utf8); + posRef[0] = pos; + return; + } + System.arraycopy(utf8, 0, buf, pos, utf8.length); + posRef[0] = pos + utf8.length; + } + + private JsonEscaping() {} +} 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..ed0bc094376 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,8 +5,6 @@ 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; @@ -15,191 +13,185 @@ final class JsonSerializer extends Serializer { - private static final JsonFactory JSON_FACTORY = new JsonFactory(); + private final JsonWriterTarget target; - private final JsonGenerator generator; - - JsonSerializer(OutputStream output) throws IOException { - this(JSON_FACTORY.createGenerator(output)); + JsonSerializer(OutputStream output) { + this.target = new StreamingJsonWriter(output); } - JsonSerializer(JsonGenerator generator) { - this.generator = generator; + JsonSerializer(JsonWriterTarget target) { + this.target = target; } @Override protected void writeTraceId(ProtoFieldInfo field, String traceId) throws IOException { - generator.writeStringField(field.getJsonName(), traceId); + target.writeStringField(field.getJsonName(), traceId); } @Override protected void writeSpanId(ProtoFieldInfo field, String spanId) throws IOException { - generator.writeStringField(field.getJsonName(), spanId); + target.writeStringField(field.getJsonName(), spanId); } @Override public void writeBool(ProtoFieldInfo field, boolean value) throws IOException { - generator.writeBooleanField(field.getJsonName(), value); + target.writeBooleanField(field.getJsonName(), value); } @Override protected void writeEnum(ProtoFieldInfo field, ProtoEnumInfo enumValue) throws IOException { - generator.writeNumberField(field.getJsonName(), enumValue.getEnumNumber()); + target.writeNumberField(field.getJsonName(), enumValue.getEnumNumber()); } @Override protected void writeUint32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + target.writeNumberField(field.getJsonName(), value); } @Override protected void writeSInt32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + target.writeNumberField(field.getJsonName(), value); } @Override protected void writeint32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + target.writeNumberField(field.getJsonName(), value); } @Override public void writeInt64(ProtoFieldInfo field, long value) throws IOException { - generator.writeStringField(field.getJsonName(), Long.toString(value)); + target.writeStringField(field.getJsonName(), Long.toString(value)); } @Override protected void writeFixed64(ProtoFieldInfo field, long value) throws IOException { - generator.writeStringField(field.getJsonName(), Long.toString(value)); + target.writeStringField(field.getJsonName(), Long.toString(value)); } @Override protected void writeFixed64Value(long value) throws IOException { - generator.writeString(Long.toString(value)); + target.writeString(Long.toString(value)); } @Override protected void writeUInt64Value(long value) throws IOException { - generator.writeString(Long.toString(value)); + target.writeString(Long.toString(value)); } @Override public void writeUInt64(ProtoFieldInfo field, long value) throws IOException { - generator.writeStringField(field.getJsonName(), Long.toString(value)); + target.writeStringField(field.getJsonName(), Long.toString(value)); } @Override protected void writeFixed32(ProtoFieldInfo field, int value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + target.writeNumberField(field.getJsonName(), value); } @Override public void writeDouble(ProtoFieldInfo field, double value) throws IOException { - generator.writeNumberField(field.getJsonName(), value); + target.writeNumberField(field.getJsonName(), value); } @Override protected void writeDoubleValue(double value) throws IOException { - generator.writeNumber(value); + target.writeNumber(value); } @Override public void writeString(ProtoFieldInfo field, byte[] utf8Bytes) throws IOException { - generator.writeFieldName(field.getJsonName()); - // Marshalers encoded String into UTF-8 bytes to optimize for binary serialization where - // we are able to avoid the encoding process happening twice, one for size computation and one - // for actual writing. JsonGenerator actually has a writeUTF8String that would be able to accept - // this, but it only works when writing to an OutputStream, but not to a String like we do for - // writing to logs. It's wasteful to take a String, convert it to bytes, and convert back to - // the same String but we can see if this can be improved in the future. - generator.writeString(new String(utf8Bytes, StandardCharsets.UTF_8)); + target.writeFieldName(field.getJsonName()); + // Marshalers encode Strings into UTF-8 bytes to optimize for binary serialization where + // we avoid the encoding process happening twice (once for size computation, once for writing). + // 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. + target.writeString(new String(utf8Bytes, StandardCharsets.UTF_8)); } @Override public void writeString( ProtoFieldInfo field, String string, int utf8Length, MarshalerContext context) throws IOException { - generator.writeFieldName(field.getJsonName()); - generator.writeString(string); + target.writeFieldName(field.getJsonName()); + target.writeString(string); } @Override public void writeRepeatedString(ProtoFieldInfo field, byte[][] utf8Bytes) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.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)); + // Marshalers encode Strings into UTF-8 bytes to optimize for binary serialization where + // we avoid the encoding process happening twice (once for size computation, once for + // writing). + // 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. + target.writeString(new String(value, StandardCharsets.UTF_8)); } - generator.writeEndArray(); + target.writeEndArray(); } @Override public void writeBytes(ProtoFieldInfo field, byte[] value) throws IOException { - generator.writeBinaryField(field.getJsonName(), value); + target.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); + target.writeBinaryField(field.getJsonName(), data); } @Override protected void writeStartMessage(ProtoFieldInfo field, int protoMessageSize) throws IOException { - generator.writeObjectFieldStart(field.getJsonName()); + target.writeObjectFieldStart(field.getJsonName()); } @Override protected void writeEndMessage() throws IOException { - generator.writeEndObject(); + target.writeEndObject(); } @Override protected void writeStartRepeatedPrimitive( ProtoFieldInfo field, int protoSizePerElement, int numElements) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.writeArrayFieldStart(field.getJsonName()); } @Override protected void writeEndRepeatedPrimitive() throws IOException { - generator.writeEndArray(); + target.writeEndArray(); } @Override protected void writeStartRepeatedVarint(ProtoFieldInfo field, int payloadSize) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.writeArrayFieldStart(field.getJsonName()); } @Override protected void writeEndRepeatedVarint() throws IOException { - generator.writeEndArray(); + target.writeEndArray(); } @Override public void serializeRepeatedMessage(ProtoFieldInfo field, Marshaler[] repeatedMessage) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.writeArrayFieldStart(field.getJsonName()); for (Marshaler marshaler : repeatedMessage) { writeMessageValue(marshaler); } - generator.writeEndArray(); + target.writeEndArray(); } @Override public void serializeRepeatedMessage( ProtoFieldInfo field, List repeatedMessage) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.writeArrayFieldStart(field.getJsonName()); for (Marshaler marshaler : repeatedMessage) { writeMessageValue(marshaler); } - generator.writeEndArray(); + target.writeEndArray(); } @Override @@ -209,52 +201,52 @@ public void serializeRepeatedMessageWithContext( StatelessMarshaler marshaler, MarshalerContext context) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.writeArrayFieldStart(field.getJsonName()); for (int i = 0; i < messages.size(); i++) { T message = messages.get(i); - generator.writeStartObject(); + target.writeStartObject(); marshaler.writeTo(this, message, context); - generator.writeEndObject(); + target.writeEndObject(); } - generator.writeEndArray(); + target.writeEndArray(); } @Override public void writeStartRepeated(ProtoFieldInfo field) throws IOException { - generator.writeArrayFieldStart(field.getJsonName()); + target.writeArrayFieldStart(field.getJsonName()); } @Override public void writeEndRepeated() throws IOException { - generator.writeEndArray(); + target.writeEndArray(); } @Override public void writeStartRepeatedElement(ProtoFieldInfo field, int protoMessageSize) throws IOException { - generator.writeStartObject(); + target.writeStartObject(); } @Override public void writeEndRepeatedElement() throws IOException { - generator.writeEndObject(); + target.writeEndObject(); } // Not a field. void writeMessageValue(Marshaler message) throws IOException { - generator.writeStartObject(); + target.writeStartObject(); message.writeTo(this); - generator.writeEndObject(); + target.writeEndObject(); } @Override public void writeSerializedMessage(byte[] protoSerialized, String jsonSerialized) throws IOException { - generator.writeRaw(jsonSerialized); + target.writeRaw(jsonSerialized); } @Override public void close() throws IOException { - generator.close(); + target.close(); } } diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTarget.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTarget.java new file mode 100644 index 00000000000..c6282eda7d9 --- /dev/null +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonWriterTarget.java @@ -0,0 +1,78 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.marshal; + +import java.io.Closeable; +import java.io.IOException; + +interface JsonWriterTarget extends Closeable { + + void writeStartObject() throws IOException; + + void writeEndObject() throws IOException; + + void writeStartArray() throws IOException; + + void writeEndArray() throws IOException; + + void writeFieldName(String name) throws IOException; + + void writeString(String value) throws IOException; + + void writeNumber(int value) throws IOException; + + void writeNumber(long value) throws IOException; + + void writeNumber(double value) throws IOException; + + void writeBoolean(boolean value) throws IOException; + + void writeBinary(byte[] data) throws IOException; + + void writeRaw(String text) throws IOException; + + void writeRaw(char c) throws IOException; + + default void writeStringField(String fieldName, String value) throws IOException { + writeFieldName(fieldName); + writeString(value); + } + + default void writeNumberField(String fieldName, int value) throws IOException { + writeFieldName(fieldName); + writeNumber(value); + } + + default void writeNumberField(String fieldName, long value) throws IOException { + writeFieldName(fieldName); + writeNumber(value); + } + + default void writeNumberField(String fieldName, double value) throws IOException { + writeFieldName(fieldName); + writeNumber(value); + } + + default void writeBooleanField(String fieldName, boolean value) throws IOException { + writeFieldName(fieldName); + writeBoolean(value); + } + + default void writeBinaryField(String fieldName, byte[] data) throws IOException { + writeFieldName(fieldName); + writeBinary(data); + } + + default void writeObjectFieldStart(String fieldName) throws IOException { + writeFieldName(fieldName); + writeStartObject(); + } + + default void writeArrayFieldStart(String fieldName) throws IOException { + writeFieldName(fieldName); + writeStartArray(); + } +} 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..c23e298f716 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,21 +31,10 @@ 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)) { + /** Marshals into the {@link StringBuilder} in proto JSON format. */ + public final void writeJsonTo(StringBuilder output) throws IOException { + try (JsonSerializer serializer = new JsonSerializer(new StringJsonWriter(output))) { serializer.writeMessageValue(this); - output.writeRaw('\n'); } } 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..7652291eea3 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); diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/StreamingJsonWriter.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/StreamingJsonWriter.java new file mode 100644 index 00000000000..4bef54682cf --- /dev/null +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/StreamingJsonWriter.java @@ -0,0 +1,194 @@ +/* + * 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.Base64; + +final class StreamingJsonWriter implements JsonWriterTarget { + + private static final int BUFFER_SIZE = 8192; + + private final OutputStream out; + private final byte[] buf = new byte[BUFFER_SIZE]; + private int pos; + + private boolean[] needsComma = new boolean[32]; + private int depth; + + StreamingJsonWriter(OutputStream out) { + this.out = out; + } + + @Override + public void writeStartObject() throws IOException { + maybeWriteComma(); + writeByte('{'); + pushContext(); + } + + @Override + public void writeEndObject() throws IOException { + popContext(); + writeByte('}'); + } + + @Override + public void writeStartArray() throws IOException { + maybeWriteComma(); + writeByte('['); + pushContext(); + } + + @Override + public void writeEndArray() throws IOException { + popContext(); + writeByte(']'); + } + + @Override + public void writeFieldName(String name) throws IOException { + maybeWriteComma(); + // Reset so the field's value does not get a spurious comma. + // The next writeFieldName or writeEnd* will handle commas correctly. + needsComma[depth] = false; + writeByte('"'); + writeEscaped(name); + writeByte('"'); + writeByte(':'); + } + + @Override + public void writeString(String value) throws IOException { + maybeWriteComma(); + writeByte('"'); + writeEscaped(value); + writeByte('"'); + } + + @Override + public void writeNumber(int value) throws IOException { + maybeWriteComma(); + writeAsciiString(Integer.toString(value)); + } + + @Override + public void writeNumber(long value) throws IOException { + maybeWriteComma(); + writeAsciiString(Long.toString(value)); + } + + @Override + public void writeNumber(double value) throws IOException { + maybeWriteComma(); + writeAsciiString(Double.toString(value)); + } + + @Override + public void writeBoolean(boolean value) throws IOException { + maybeWriteComma(); + writeAsciiString(value ? "true" : "false"); + } + + @Override + public void writeBinary(byte[] data) throws IOException { + maybeWriteComma(); + writeByte('"'); + writeAsciiString(Base64.getEncoder().encodeToString(data)); + writeByte('"'); + } + + @Override + public void writeRaw(String text) throws IOException { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + if (pos + bytes.length > BUFFER_SIZE) { + flush(); + } + if (bytes.length > BUFFER_SIZE) { + out.write(bytes); + return; + } + System.arraycopy(bytes, 0, buf, pos, bytes.length); + pos += bytes.length; + } + + @Override + public void writeRaw(char c) throws IOException { + writeByte((byte) c); + } + + @Override + @SuppressWarnings("CheckedExceptionNotThrown") + public void close() throws IOException { + try { + flush(); + } catch (IOException e) { + // Swallow flush failure during close to avoid self-suppression when the same + // IOException is thrown from both writeTo() and close() in a try-with-resources. + } + } + + private void flush() throws IOException { + if (pos > 0) { + out.write(buf, 0, pos); + pos = 0; + } + out.flush(); + } + + private void writeByte(int b) throws IOException { + if (pos >= BUFFER_SIZE) { + flush(); + } + buf[pos++] = (byte) b; + } + + private void writeAsciiString(String ascii) throws IOException { + int len = ascii.length(); + if (pos + len > BUFFER_SIZE) { + flush(); + } + if (len > BUFFER_SIZE) { + out.write(ascii.getBytes(StandardCharsets.US_ASCII)); + return; + } + for (int i = 0; i < len; i++) { + buf[pos++] = (byte) ascii.charAt(i); + } + } + + private void writeEscaped(String value) throws IOException { + int[] posRef = {pos}; + JsonEscaping.escapeStringTo(buf, posRef, out, value); + pos = posRef[0]; + } + + private void maybeWriteComma() throws IOException { + if (depth > 0) { + if (needsComma[depth]) { + writeByte(','); + } else { + needsComma[depth] = true; + } + } + } + + private void pushContext() { + depth++; + if (depth >= needsComma.length) { + boolean[] newArray = new boolean[needsComma.length * 2]; + System.arraycopy(needsComma, 0, newArray, 0, needsComma.length); + needsComma = newArray; + } + needsComma[depth] = false; + } + + private void popContext() { + depth--; + } +} diff --git a/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/StringJsonWriter.java b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/StringJsonWriter.java new file mode 100644 index 00000000000..0225ff5dd62 --- /dev/null +++ b/exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/StringJsonWriter.java @@ -0,0 +1,135 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.internal.marshal; + +import java.util.Base64; + +final class StringJsonWriter implements JsonWriterTarget { + + private final StringBuilder sb; + + private boolean[] needsComma = new boolean[32]; + private int depth; + + StringJsonWriter(StringBuilder sb) { + this.sb = sb; + } + + @Override + public void writeStartObject() { + maybeWriteComma(); + sb.append('{'); + pushContext(); + } + + @Override + public void writeEndObject() { + popContext(); + sb.append('}'); + } + + @Override + public void writeStartArray() { + maybeWriteComma(); + sb.append('['); + pushContext(); + } + + @Override + public void writeEndArray() { + popContext(); + sb.append(']'); + } + + @Override + public void writeFieldName(String name) { + maybeWriteComma(); + needsComma[depth] = false; + sb.append('"'); + JsonEscaping.escapeStringTo(sb, name); + sb.append('"'); + sb.append(':'); + } + + @Override + public void writeString(String value) { + maybeWriteComma(); + sb.append('"'); + JsonEscaping.escapeStringTo(sb, value); + sb.append('"'); + } + + @Override + public void writeNumber(int value) { + maybeWriteComma(); + sb.append(value); + } + + @Override + public void writeNumber(long value) { + maybeWriteComma(); + sb.append(value); + } + + @Override + public void writeNumber(double value) { + maybeWriteComma(); + sb.append(Double.toString(value)); + } + + @Override + public void writeBoolean(boolean value) { + maybeWriteComma(); + sb.append(value); + } + + @Override + public void writeBinary(byte[] data) { + maybeWriteComma(); + sb.append('"'); + sb.append(Base64.getEncoder().encodeToString(data)); + sb.append('"'); + } + + @Override + public void writeRaw(String text) { + sb.append(text); + } + + @Override + public void writeRaw(char c) { + sb.append(c); + } + + @Override + public void close() { + // no-op + } + + private void maybeWriteComma() { + if (depth > 0) { + if (needsComma[depth]) { + sb.append(','); + } else { + needsComma[depth] = true; + } + } + } + + private void pushContext() { + depth++; + if (depth >= needsComma.length) { + boolean[] newArray = new boolean[needsComma.length * 2]; + System.arraycopy(needsComma, 0, newArray, 0, needsComma.length); + needsComma = newArray; + } + needsComma[depth] = false; + } + + private void popContext() { + depth--; + } +} diff --git a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterComparisonTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterComparisonTest.java new file mode 100644 index 00000000000..7b61f761b33 --- /dev/null +++ b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/JsonWriterComparisonTest.java @@ -0,0 +1,425 @@ +/* + * 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 com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class JsonWriterComparisonTest { + + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + static Stream testCases() { + return Stream.of( + // Structural + testCase( + "emptyObject", + w -> { + w.writeStartObject(); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeEndObject(); + }), + testCase( + "emptyArray", + w -> { + w.writeStartArray(); + w.writeEndArray(); + }, + g -> { + g.writeStartArray(); + g.writeEndArray(); + }), + testCase( + "singleField", + w -> { + w.writeStartObject(); + w.writeStringField("a", "1"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("a", "1"); + g.writeEndObject(); + }), + testCase( + "multiField", + w -> { + w.writeStartObject(); + w.writeStringField("a", "1"); + w.writeNumberField("b", 2); + w.writeBooleanField("c", true); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("a", "1"); + g.writeNumberField("b", 2); + g.writeBooleanField("c", true); + g.writeEndObject(); + }), + testCase( + "nestedObjects", + w -> { + w.writeStartObject(); + w.writeObjectFieldStart("n"); + w.writeStringField("k", "v"); + w.writeEndObject(); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeObjectFieldStart("n"); + g.writeStringField("k", "v"); + g.writeEndObject(); + g.writeEndObject(); + }), + testCase( + "nestedArrays", + w -> { + w.writeStartObject(); + w.writeArrayFieldStart("a"); + w.writeNumber(1); + w.writeNumber(2); + w.writeEndArray(); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeArrayFieldStart("a"); + g.writeNumber(1); + g.writeNumber(2); + g.writeEndArray(); + g.writeEndObject(); + }), + testCase( + "objectInArray", + w -> { + w.writeStartArray(); + w.writeStartObject(); + w.writeNumberField("a", 1); + w.writeEndObject(); + w.writeStartObject(); + w.writeNumberField("b", 2); + w.writeEndObject(); + w.writeEndArray(); + }, + g -> { + g.writeStartArray(); + g.writeStartObject(); + g.writeNumberField("a", 1); + g.writeEndObject(); + g.writeStartObject(); + g.writeNumberField("b", 2); + g.writeEndObject(); + g.writeEndArray(); + }), + + // String escaping + testCase( + "namedEscapes", + w -> { + w.writeStartObject(); + w.writeStringField("s", "a\"b\\c\bd\fe\nf\rg\th"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("s", "a\"b\\c\bd\fe\nf\rg\th"); + g.writeEndObject(); + }), + testCase( + "controlChars", + w -> { + w.writeStartObject(); + w.writeStringField("s", "\u0000\u0001\u001F"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("s", "\u0000\u0001\u001F"); + g.writeEndObject(); + }), + testCase( + "forwardSlash", + w -> { + w.writeStartObject(); + w.writeStringField("s", "a/b"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("s", "a/b"); + g.writeEndObject(); + }), + testCase( + "emptyString", + w -> { + w.writeStartObject(); + w.writeStringField("s", ""); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("s", ""); + g.writeEndObject(); + }), + testCase( + "unicode", + w -> { + w.writeStartObject(); + w.writeStringField("s", "é世界"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("s", "é世界"); + g.writeEndObject(); + }), + testCase( + "mixedEscapes", + w -> { + w.writeStartObject(); + w.writeStringField("s", "he said \"hello\"\nline2\ttab"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeStringField("s", "he said \"hello\"\nline2\ttab"); + g.writeEndObject(); + }), + + // Numbers + testCase( + "integers", + w -> { + w.writeStartArray(); + w.writeNumber(0); + w.writeNumber(-1); + w.writeNumber(Integer.MAX_VALUE); + w.writeNumber(Integer.MIN_VALUE); + w.writeEndArray(); + }, + g -> { + g.writeStartArray(); + g.writeNumber(0); + g.writeNumber(-1); + g.writeNumber(Integer.MAX_VALUE); + g.writeNumber(Integer.MIN_VALUE); + g.writeEndArray(); + }), + testCase( + "longs", + w -> { + w.writeStartArray(); + w.writeNumber(0L); + w.writeNumber(-1L); + w.writeNumber(Long.MAX_VALUE); + w.writeNumber(Long.MIN_VALUE); + w.writeEndArray(); + }, + g -> { + g.writeStartArray(); + g.writeNumber(0L); + g.writeNumber(-1L); + g.writeNumber(Long.MAX_VALUE); + g.writeNumber(Long.MIN_VALUE); + g.writeEndArray(); + }), + testCase( + "doubles", + w -> { + w.writeStartArray(); + w.writeNumber(0.0); + w.writeNumber(-0.0); + w.writeNumber(1.0); + w.writeNumber(1.0E7); + w.writeNumber(1.0E-4); + w.writeNumber(3.141592653589793); + w.writeEndArray(); + }, + g -> { + g.writeStartArray(); + g.writeNumber(0.0); + g.writeNumber(-0.0); + g.writeNumber(1.0); + g.writeNumber(1.0E7); + g.writeNumber(1.0E-4); + g.writeNumber(3.141592653589793); + g.writeEndArray(); + }), + + // Base64 + testCase( + "base64Empty", + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[0]); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeBinaryField("b", new byte[0]); + g.writeEndObject(); + }), + testCase( + "base64SingleByte", + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[] {0x01, 0x02, 0x03}); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeBinaryField("b", new byte[] {0x01, 0x02, 0x03}); + g.writeEndObject(); + }), + + // Boolean + testCase( + "booleans", + w -> { + w.writeStartArray(); + w.writeBoolean(true); + w.writeBoolean(false); + w.writeEndArray(); + }, + g -> { + g.writeStartArray(); + g.writeBoolean(true); + g.writeBoolean(false); + g.writeEndArray(); + }), + + // writeRaw + testCase( + "writeRaw", + w -> { + w.writeStartObject(); + w.writeRaw("\"raw\":true"); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeRaw("\"raw\":true"); + g.writeEndObject(); + }), + + // OTLP-like pattern: resource with attributes + testCase( + "otlpResourcePattern", + w -> { + w.writeStartObject(); + w.writeObjectFieldStart("resource"); + w.writeArrayFieldStart("attributes"); + w.writeStartObject(); + w.writeStringField("key", "service.name"); + w.writeObjectFieldStart("value"); + w.writeStringField("stringValue", "my-service"); + w.writeEndObject(); + w.writeEndObject(); + w.writeEndArray(); + w.writeEndObject(); + w.writeEndObject(); + }, + g -> { + g.writeStartObject(); + g.writeObjectFieldStart("resource"); + g.writeArrayFieldStart("attributes"); + g.writeStartObject(); + g.writeStringField("key", "service.name"); + g.writeObjectFieldStart("value"); + g.writeStringField("stringValue", "my-service"); + g.writeEndObject(); + g.writeEndObject(); + g.writeEndArray(); + g.writeEndObject(); + g.writeEndObject(); + })); + } + + @ParameterizedTest + @MethodSource("testCases") + void streamingWriterMatchesJackson(TestCase testCase) throws IOException { + ByteArrayOutputStream builtIn = new ByteArrayOutputStream(); + try (StreamingJsonWriter writer = new StreamingJsonWriter(builtIn)) { + testCase.builtInWriter.write(writer); + } + + ByteArrayOutputStream jackson = new ByteArrayOutputStream(); + try (JsonGenerator gen = JSON_FACTORY.createGenerator(jackson)) { + testCase.jacksonWriter.write(gen); + } + + assertThat(builtIn.toByteArray()) + .as( + "Built-in output should match Jackson for case: %s\nBuilt-in: %s\nJackson: %s", + testCase.name, + new String(builtIn.toByteArray(), StandardCharsets.UTF_8), + new String(jackson.toByteArray(), StandardCharsets.UTF_8)) + .isEqualTo(jackson.toByteArray()); + } + + @ParameterizedTest + @MethodSource("testCases") + void stringWriterMatchesJackson(TestCase testCase) throws IOException { + StringBuilder sb = new StringBuilder(); + try (StringJsonWriter writer = new StringJsonWriter(sb)) { + testCase.builtInWriter.write(writer); + } + + ByteArrayOutputStream jackson = new ByteArrayOutputStream(); + try (JsonGenerator gen = JSON_FACTORY.createGenerator(jackson)) { + testCase.jacksonWriter.write(gen); + } + + assertThat(sb.toString()) + .as("StringJsonWriter output should match Jackson for case: %s", testCase.name) + .isEqualTo(new String(jackson.toByteArray(), StandardCharsets.UTF_8)); + } + + private static TestCase testCase( + String name, BuiltInWriteAction builtIn, JacksonWriteAction jackson) { + return new TestCase(name, builtIn, jackson); + } + + static class TestCase { + final String name; + final BuiltInWriteAction builtInWriter; + final JacksonWriteAction jacksonWriter; + + TestCase(String name, BuiltInWriteAction builtIn, JacksonWriteAction jackson) { + this.name = name; + this.builtInWriter = builtIn; + this.jacksonWriter = jackson; + } + + @Override + public String toString() { + return name; + } + } + + @FunctionalInterface + interface BuiltInWriteAction { + void write(JsonWriterTarget writer) throws IOException; + } + + @FunctionalInterface + interface JacksonWriteAction { + void write(JsonGenerator gen) throws IOException; + } +} diff --git a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/StreamingJsonWriterTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/StreamingJsonWriterTest.java new file mode 100644 index 00000000000..967574b723b --- /dev/null +++ b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/StreamingJsonWriterTest.java @@ -0,0 +1,376 @@ +/* + * 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.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class StreamingJsonWriterTest { + + @Test + void emptyObject() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeEndObject(); + })) + .isEqualTo("{}"); + } + + @Test + void emptyArray() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeEndArray(); + })) + .isEqualTo("[]"); + } + + @Test + void singleFieldObject() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("key", "value"); + w.writeEndObject(); + })) + .isEqualTo("{\"key\":\"value\"}"); + } + + @Test + void multiFieldObject() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("a", "1"); + w.writeNumberField("b", 2); + w.writeBooleanField("c", true); + w.writeEndObject(); + })) + .isEqualTo("{\"a\":\"1\",\"b\":2,\"c\":true}"); + } + + @Test + void multiElementArray() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(1); + w.writeNumber(2); + w.writeNumber(3); + w.writeEndArray(); + })) + .isEqualTo("[1,2,3]"); + } + + @Test + void nestedObjectsAndArrays() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeObjectFieldStart("nested"); + w.writeArrayFieldStart("items"); + w.writeNumber(1); + w.writeNumber(2); + w.writeEndArray(); + w.writeEndObject(); + w.writeEndObject(); + })) + .isEqualTo("{\"nested\":{\"items\":[1,2]}}"); + } + + @Test + void mixedNesting() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeArrayFieldStart("a"); + w.writeStartObject(); + w.writeNumberField("b", 1); + w.writeEndObject(); + w.writeStartObject(); + w.writeArrayFieldStart("c"); + w.writeNumber(2); + w.writeNumber(3); + w.writeEndArray(); + w.writeEndObject(); + w.writeEndArray(); + w.writeEndObject(); + })) + .isEqualTo("{\"a\":[{\"b\":1},{\"c\":[2,3]}]}"); + } + + @Test + void stringEscapingNamedEscapes() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", "a\"b\\c\bd\fe\nf\rg\th"); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"a\\\"b\\\\c\\bd\\fe\\nf\\rg\\th\"}"); + } + + @Test + void stringEscapingControlChars() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", "\u0000\u0001\u001F"); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"\\u0000\\u0001\\u001F\"}"); + } + + @Test + void forwardSlashNotEscaped() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("url", "http://example.com/path"); + w.writeEndObject(); + })) + .isEqualTo("{\"url\":\"http://example.com/path\"}"); + } + + @Test + void emptyString() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", ""); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"\"}"); + } + + @Test + void nonAsciiUtf8() throws IOException { + String result = + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", "é世界"); + w.writeEndObject(); + }); + assertThat(result).isEqualTo("{\"s\":\"é世界\"}"); + } + + @Test + void integers() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(0); + w.writeNumber(-1); + w.writeNumber(Integer.MAX_VALUE); + w.writeNumber(Integer.MIN_VALUE); + w.writeEndArray(); + })) + .isEqualTo("[0,-1,2147483647,-2147483648]"); + } + + @Test + void longs() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(0L); + w.writeNumber(-1L); + w.writeNumber(Long.MAX_VALUE); + w.writeNumber(Long.MIN_VALUE); + w.writeEndArray(); + })) + .isEqualTo("[0,-1,9223372036854775807,-9223372036854775808]"); + } + + @Test + void doubles() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(0.0); + w.writeNumber(-0.0); + w.writeNumber(1.0); + w.writeNumber(1.0E7); + w.writeNumber(1.0E-4); + w.writeEndArray(); + })) + .isEqualTo("[0.0,-0.0,1.0,1.0E7,1.0E-4]"); + } + + @Test + void base64EmptyArray() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[0]); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"\"}"); + } + + @Test + void base64SingleByte() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[] {0x00}); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"AA==\"}"); + } + + @Test + void base64ThreeBytes() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[] {0x01, 0x02, 0x03}); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"AQID\"}"); + } + + @Test + void base64WithPlusAndSlash() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[] {(byte) 0xFB, (byte) 0xFF}); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"+/8=\"}"); + } + + @Test + void writeRawNoComma() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeRaw("\"raw\":true"); + w.writeEndObject(); + })) + .isEqualTo("{\"raw\":true}"); + } + + @Test + void writeRawAfterFieldNoExtraComma() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("a", "1"); + w.writeRaw(",\"raw\":true"); + w.writeEndObject(); + })) + .isEqualTo("{\"a\":\"1\",\"raw\":true}"); + } + + @Test + void closeFlushesBuffer() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + StreamingJsonWriter writer = new StreamingJsonWriter(baos); + writer.writeStartObject(); + writer.writeStringField("key", "value"); + writer.writeEndObject(); + assertThat(baos.size()).isEqualTo(0); // buffered, not yet flushed + writer.close(); + assertThat(new String(baos.toByteArray(), StandardCharsets.UTF_8)) + .isEqualTo("{\"key\":\"value\"}"); + } + + @Test + void closeDoesNotCloseUnderlyingStream() throws IOException { + boolean[] closed = {false}; + ByteArrayOutputStream baos = + new ByteArrayOutputStream() { + @Override + public void close() throws IOException { + closed[0] = true; + super.close(); + } + }; + StreamingJsonWriter writer = new StreamingJsonWriter(baos); + writer.writeStartObject(); + writer.writeEndObject(); + writer.close(); + assertThat(closed[0]).isFalse(); + } + + @Test + void consistencyWithStringJsonWriter() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (StreamingJsonWriter sw = new StreamingJsonWriter(baos)) { + writeComplexDocument(sw); + } + String fromStream = new String(baos.toByteArray(), StandardCharsets.UTF_8); + + StringBuilder sb = new StringBuilder(); + try (StringJsonWriter sjw = new StringJsonWriter(sb)) { + writeComplexDocument(sjw); + } + String fromString = sb.toString(); + + assertThat(fromStream).isEqualTo(fromString); + } + + private static void writeComplexDocument(JsonWriterTarget w) throws IOException { + w.writeStartObject(); + w.writeStringField("name", "test\nvalue"); + w.writeNumberField("int", 42); + w.writeNumberField("long", 123456789012345L); + w.writeNumberField("double", 3.14); + w.writeBooleanField("flag", true); + w.writeBinaryField("data", new byte[] {1, 2, 3}); + w.writeArrayFieldStart("items"); + w.writeString("a"); + w.writeNumber(1); + w.writeBoolean(false); + w.writeEndArray(); + w.writeObjectFieldStart("nested"); + w.writeStringField("key", "val"); + w.writeEndObject(); + w.writeEndObject(); + } + + private static String writeToString(WriteAction action) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (StreamingJsonWriter writer = new StreamingJsonWriter(baos)) { + action.write(writer); + } + return new String(baos.toByteArray(), StandardCharsets.UTF_8); + } + + @FunctionalInterface + interface WriteAction { + void write(StreamingJsonWriter writer) throws IOException; + } +} diff --git a/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/StringJsonWriterTest.java b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/StringJsonWriterTest.java new file mode 100644 index 00000000000..06cdac09a9c --- /dev/null +++ b/exporters/common/src/test/java/io/opentelemetry/exporter/internal/marshal/StringJsonWriterTest.java @@ -0,0 +1,260 @@ +/* + * 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.IOException; +import org.junit.jupiter.api.Test; + +class StringJsonWriterTest { + + @Test + void emptyObject() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeEndObject(); + })) + .isEqualTo("{}"); + } + + @Test + void emptyArray() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeEndArray(); + })) + .isEqualTo("[]"); + } + + @Test + void singleFieldObject() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("key", "value"); + w.writeEndObject(); + })) + .isEqualTo("{\"key\":\"value\"}"); + } + + @Test + void multiFieldObject() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("a", "1"); + w.writeNumberField("b", 2); + w.writeBooleanField("c", true); + w.writeEndObject(); + })) + .isEqualTo("{\"a\":\"1\",\"b\":2,\"c\":true}"); + } + + @Test + void multiElementArray() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(1); + w.writeNumber(2); + w.writeNumber(3); + w.writeEndArray(); + })) + .isEqualTo("[1,2,3]"); + } + + @Test + void nestedObjectsAndArrays() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeObjectFieldStart("nested"); + w.writeArrayFieldStart("items"); + w.writeNumber(1); + w.writeNumber(2); + w.writeEndArray(); + w.writeEndObject(); + w.writeEndObject(); + })) + .isEqualTo("{\"nested\":{\"items\":[1,2]}}"); + } + + @Test + void stringEscapingNamedEscapes() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", "a\"b\\c\bd\fe\nf\rg\th"); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"a\\\"b\\\\c\\bd\\fe\\nf\\rg\\th\"}"); + } + + @Test + void stringEscapingControlChars() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", "\u0000\u0001\u001F"); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"\\u0000\\u0001\\u001F\"}"); + } + + @Test + void forwardSlashNotEscaped() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("url", "http://example.com/path"); + w.writeEndObject(); + })) + .isEqualTo("{\"url\":\"http://example.com/path\"}"); + } + + @Test + void emptyString() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", ""); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"\"}"); + } + + @Test + void nonAsciiUtf8() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeStringField("s", "é世界"); + w.writeEndObject(); + })) + .isEqualTo("{\"s\":\"é世界\"}"); + } + + @Test + void integers() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(0); + w.writeNumber(-1); + w.writeNumber(Integer.MAX_VALUE); + w.writeNumber(Integer.MIN_VALUE); + w.writeEndArray(); + })) + .isEqualTo("[0,-1,2147483647,-2147483648]"); + } + + @Test + void longs() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(0L); + w.writeNumber(-1L); + w.writeNumber(Long.MAX_VALUE); + w.writeNumber(Long.MIN_VALUE); + w.writeEndArray(); + })) + .isEqualTo("[0,-1,9223372036854775807,-9223372036854775808]"); + } + + @Test + void doubles() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartArray(); + w.writeNumber(0.0); + w.writeNumber(-0.0); + w.writeNumber(1.0); + w.writeNumber(1.0E7); + w.writeNumber(1.0E-4); + w.writeEndArray(); + })) + .isEqualTo("[0.0,-0.0,1.0,1.0E7,1.0E-4]"); + } + + @Test + void base64EmptyArray() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[0]); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"\"}"); + } + + @Test + void base64SingleByte() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[] {0x00}); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"AA==\"}"); + } + + @Test + void base64ThreeBytes() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeBinaryField("b", new byte[] {0x01, 0x02, 0x03}); + w.writeEndObject(); + })) + .isEqualTo("{\"b\":\"AQID\"}"); + } + + @Test + void writeRawNoComma() throws IOException { + assertThat( + writeToString( + w -> { + w.writeStartObject(); + w.writeRaw("\"raw\":true"); + w.writeEndObject(); + })) + .isEqualTo("{\"raw\":true}"); + } + + private static String writeToString(WriteAction action) throws IOException { + StringBuilder sb = new StringBuilder(); + try (StringJsonWriter writer = new StringJsonWriter(sb)) { + action.write(writer); + } + return sb.toString(); + } + + @FunctionalInterface + interface WriteAction { + void write(StringJsonWriter writer) throws IOException; + } +} 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..8d03afc183c 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,10 +5,6 @@ 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.IOException; @@ -31,21 +27,15 @@ public LoggerJsonWriter(Logger logger, String type) { @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(); - } - + StringBuilder sb = new StringBuilder(); try { - logger.log(Level.INFO, sw.getAndClear()); - return CompletableResultCode.ofSuccess(); + exportRequest.writeJsonTo(sb); } catch (IOException e) { logger.log(Level.WARNING, "Unable to write OTLP JSON " + type, e); return CompletableResultCode.ofFailure(); } + logger.log(Level.INFO, sb.toString()); + 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..26a9a88f067 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,8 @@ 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'); 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..60aeedcf56a 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,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; @@ -34,9 +33,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(StringBuilder.class)); Logger logger = Logger.getLogger(LoggerJsonWriter.class.getName()); diff --git a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java index 048fcab1c17..de88db42dec 100644 --- a/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java +++ b/exporters/logging-otlp/src/test/java/io/opentelemetry/exporter/logging/otlp/internal/writer/StreamJsonWriterTest.java @@ -9,7 +9,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import com.fasterxml.jackson.core.JsonGenerator; import io.github.netmikey.logunit.api.LogCapturer; import io.opentelemetry.exporter.internal.marshal.Marshaler; import io.opentelemetry.internal.testing.slf4j.SuppressLogger; @@ -48,9 +47,7 @@ void testToString() throws IOException { @Test void errorWriting() throws IOException { Marshaler marshaler = mock(Marshaler.class); - Mockito.doThrow(new IOException("test")) - .when(marshaler) - .writeJsonWithNewline(any(JsonGenerator.class)); + Mockito.doThrow(new IOException("test")).when(marshaler).writeJsonTo(any(OutputStream.class)); StreamJsonWriter writer = new StreamJsonWriter(System.out, "type"); writer.write(marshaler); diff --git a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/logs/LowAllocationLogRequestMarshalerTest.java b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/logs/LowAllocationLogRequestMarshalerTest.java index 8b86a907b64..c20c9497eaa 100644 --- a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/logs/LowAllocationLogRequestMarshalerTest.java +++ b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/logs/LowAllocationLogRequestMarshalerTest.java @@ -150,4 +150,20 @@ void validateJsonOutput() throws Exception { assertThat(lowAllocationResult).isEqualTo(result); } + + @Test + void validateJsonOutputStringBuilder() throws Exception { + LogsRequestMarshaler requestMarshaler = LogsRequestMarshaler.create(logRecordDataList); + + ByteArrayOutputStream streamOutput = + new ByteArrayOutputStream(requestMarshaler.getBinarySerializedSize()); + requestMarshaler.writeJsonTo(streamOutput); + String fromStream = new String(streamOutput.toByteArray(), StandardCharsets.UTF_8); + + StringBuilder sb = new StringBuilder(); + requestMarshaler.writeJsonTo(sb); + String fromStringBuilder = sb.toString(); + + assertThat(fromStringBuilder).isEqualTo(fromStream); + } } diff --git a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/metrics/LowAllocationMetricsRequestMarshalerTest.java b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/metrics/LowAllocationMetricsRequestMarshalerTest.java index 6ae3fa94a4f..8dba10cec0d 100644 --- a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/metrics/LowAllocationMetricsRequestMarshalerTest.java +++ b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/metrics/LowAllocationMetricsRequestMarshalerTest.java @@ -113,6 +113,23 @@ void validateJsonOutput(Collection metrics) throws Exception { assertThat(lowAllocationResult).isEqualTo(result); } + @ParameterizedTest + @ArgumentsSource(MetricsProvider.class) + void validateJsonOutputStringBuilder(Collection metrics) throws Exception { + MetricsRequestMarshaler requestMarshaler = MetricsRequestMarshaler.create(metrics); + + ByteArrayOutputStream streamOutput = + new ByteArrayOutputStream(requestMarshaler.getBinarySerializedSize()); + requestMarshaler.writeJsonTo(streamOutput); + String fromStream = new String(streamOutput.toByteArray(), StandardCharsets.UTF_8); + + StringBuilder sb = new StringBuilder(); + requestMarshaler.writeJsonTo(sb); + String fromStringBuilder = sb.toString(); + + assertThat(fromStringBuilder).isEqualTo(fromStream); + } + @ParameterizedTest @ArgumentsSource(ExemplarProvider.class) void validateExemplar(ExemplarData exemplar) throws Exception { diff --git a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/traces/LowAllocationTraceRequestMarshalerTest.java b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/traces/LowAllocationTraceRequestMarshalerTest.java index 833c14c4a97..de25774f7e9 100644 --- a/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/traces/LowAllocationTraceRequestMarshalerTest.java +++ b/exporters/otlp/common/src/test/java/io/opentelemetry/exporter/internal/otlp/traces/LowAllocationTraceRequestMarshalerTest.java @@ -167,4 +167,20 @@ void validateJsonOutput() throws Exception { assertThat(lowAllocationResult).isEqualTo(result); } + + @Test + void validateJsonOutputStringBuilder() throws Exception { + TraceRequestMarshaler requestMarshaler = TraceRequestMarshaler.create(spanDataList); + + ByteArrayOutputStream streamOutput = + new ByteArrayOutputStream(requestMarshaler.getBinarySerializedSize()); + requestMarshaler.writeJsonTo(streamOutput); + String fromStream = new String(streamOutput.toByteArray(), StandardCharsets.UTF_8); + + StringBuilder sb = new StringBuilder(); + requestMarshaler.writeJsonTo(sb); + String fromStringBuilder = sb.toString(); + + assertThat(fromStringBuilder).isEqualTo(fromStream); + } } diff --git a/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/ProfilesRequestMarshalerTest.java b/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/ProfilesRequestMarshalerTest.java index 169f9566f79..939d4aadf62 100644 --- a/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/ProfilesRequestMarshalerTest.java +++ b/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/ProfilesRequestMarshalerTest.java @@ -501,6 +501,15 @@ private static T parse(T prototype, Marshaler marshaler) { assertThat(builder.build()).isEqualTo(result); + // Verify StringBuilder path produces the same JSON as OutputStream path + StringBuilder sb = new StringBuilder(); + try { + marshaler.writeJsonTo(sb); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + assertThat(sb.toString()).isEqualTo(json); + return result; } 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") }