Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions exporters/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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;
}
}
Loading
Loading