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