Skip to content

Commit 68ef0db

Browse files
committed
Replace jackson OTLP json serialization with handrolled version
1 parent 0225a99 commit 68ef0db

13 files changed

Lines changed: 487 additions & 135 deletions

File tree

exporters/common/build.gradle.kts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ plugins {
77

88
description = "OpenTelemetry Exporter Common"
99
otelJava.moduleName.set("io.opentelemetry.exporter.internal")
10-
otelJava.osgiOptionalPackages.set(listOf("com.fasterxml.jackson.core", "com.google.common.io", "io.opentelemetry.api.incubator.config", "io.opentelemetry.sdk.autoconfigure.spi"))
10+
otelJava.osgiOptionalPackages.set(listOf("com.google.common.io", "io.opentelemetry.api.incubator.config", "io.opentelemetry.sdk.autoconfigure.spi"))
1111
// sun.misc, io.grpc, and org.jspecify are not OSGi bundles and have no package versioning; must use unversioned optional.
1212
otelJava.osgiUnversionedOptionalPackages.set(listOf("sun.misc", "io.grpc", "org.jspecify.annotations"))
1313
// This bundle's exporters load sender implementations via SPI.
@@ -69,9 +69,6 @@ dependencies {
6969

7070
annotationProcessor("com.google.auto.value:auto-value")
7171

72-
// We include helpers shared by gRPC exporters but do not want to impose these
73-
// dependency on all of our consumers.
74-
compileOnly("com.fasterxml.jackson.core:jackson-core")
7572
// sun.misc.Unsafe from the JDK isn't found by the compiler, we provide our own trimmed down
7673
// version that we can compile against.
7774
compileOnly("io.grpc:grpc-stub")

exporters/common/src/main/java/io/opentelemetry/exporter/internal/marshal/JsonSerializer.java

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,17 @@
55

66
package io.opentelemetry.exporter.internal.marshal;
77

8-
import com.fasterxml.jackson.core.JsonFactory;
9-
import com.fasterxml.jackson.core.JsonGenerator;
108
import java.io.IOException;
119
import java.io.OutputStream;
1210
import java.nio.ByteBuffer;
13-
import java.nio.charset.StandardCharsets;
1411
import java.util.List;
1512

1613
final class JsonSerializer extends Serializer {
1714

18-
private static final JsonFactory JSON_FACTORY = new JsonFactory();
15+
private final JsonWriter generator;
1916

20-
private final JsonGenerator generator;
21-
22-
JsonSerializer(OutputStream output) throws IOException {
23-
this(JSON_FACTORY.createGenerator(output));
24-
}
25-
26-
JsonSerializer(JsonGenerator generator) {
27-
this.generator = generator;
17+
JsonSerializer(OutputStream output) {
18+
this.generator = new JsonWriter(output);
2819
}
2920

3021
@Override
@@ -105,13 +96,10 @@ protected void writeDoubleValue(double value) throws IOException {
10596
@Override
10697
public void writeString(ProtoFieldInfo field, byte[] utf8Bytes) throws IOException {
10798
generator.writeFieldName(field.getJsonName());
108-
// Marshalers encoded String into UTF-8 bytes to optimize for binary serialization where
109-
// we are able to avoid the encoding process happening twice, one for size computation and one
110-
// for actual writing. JsonGenerator actually has a writeUTF8String that would be able to accept
111-
// this, but it only works when writing to an OutputStream, but not to a String like we do for
112-
// writing to logs. It's wasteful to take a String, convert it to bytes, and convert back to
113-
// the same String but we can see if this can be improved in the future.
114-
generator.writeString(new String(utf8Bytes, StandardCharsets.UTF_8));
99+
// Marshalers already encoded the String to UTF-8 bytes (binary serialization needs them for
100+
// both size computation and writing), so write them directly rather than decoding and
101+
// re-encoding.
102+
generator.writeUtf8String(utf8Bytes);
115103
}
116104

117105
@Override
@@ -126,14 +114,8 @@ public void writeString(
126114
public void writeRepeatedString(ProtoFieldInfo field, byte[][] utf8Bytes) throws IOException {
127115
generator.writeArrayFieldStart(field.getJsonName());
128116
for (byte[] value : utf8Bytes) {
129-
// Marshalers encoded String into UTF-8 bytes to optimize for binary serialization where
130-
// we are able to avoid the encoding process happening twice, one for size computation and one
131-
// for actual writing. JsonGenerator actually has a writeUTF8String that would be able to
132-
// accept
133-
// this, but it only works when writing to an OutputStream, but not to a String like we do for
134-
// writing to logs. It's wasteful to take a String, convert it to bytes, and convert back to
135-
// the same String but we can see if this can be improved in the future.
136-
generator.writeString(new String(value, StandardCharsets.UTF_8));
117+
// See writeString(ProtoFieldInfo, byte[]): the bytes are already UTF-8, so write directly.
118+
generator.writeUtf8String(value);
137119
}
138120
generator.writeEndArray();
139121
}
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.exporter.internal.marshal;
7+
8+
import java.io.IOException;
9+
import java.io.OutputStream;
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.Arrays;
12+
import java.util.Base64;
13+
14+
/**
15+
* A minimal JSON writer that serializes directly to an {@link OutputStream} as UTF-8, implementing
16+
* only the subset of JSON generation {@link JsonSerializer} needs so OTLP JSON serialization has no
17+
* third party dependency. Method names mirror the Jackson {@code JsonGenerator} that previously
18+
* backed {@link JsonSerializer}.
19+
*
20+
* <p>The caller is responsible for structural validity (matching braces); this class only inserts
21+
* separators between members. {@link #writeRaw(String)} writes verbatim without touching separator
22+
* state, which {@link MarshalerUtil#preserializeJsonFields(Marshaler)} relies on.
23+
*/
24+
final class JsonWriter {
25+
26+
private static final byte[] TRUE = {'t', 'r', 'u', 'e'};
27+
private static final byte[] FALSE = {'f', 'a', 'l', 's', 'e'};
28+
private static final byte[] HEX = {
29+
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
30+
};
31+
32+
private final OutputStream out;
33+
private final byte[] buffer = new byte[4096];
34+
private int pos;
35+
36+
// Number of members already written at each nesting depth; depth 0 is the implicit root.
37+
private int[] counts = new int[16];
38+
private int depth;
39+
// True immediately after a field name has been written, meaning the next value is that field's
40+
// value and must not be preceded by a comma.
41+
private boolean afterKey;
42+
43+
JsonWriter(OutputStream out) {
44+
this.out = out;
45+
}
46+
47+
void writeStartObject() throws IOException {
48+
beforeValue();
49+
writeByte((byte) '{');
50+
push();
51+
}
52+
53+
void writeEndObject() throws IOException {
54+
pop();
55+
writeByte((byte) '}');
56+
}
57+
58+
void writeObjectFieldStart(String name) throws IOException {
59+
writeFieldName(name);
60+
writeStartObject();
61+
}
62+
63+
void writeArrayFieldStart(String name) throws IOException {
64+
writeFieldName(name);
65+
beforeValue();
66+
writeByte((byte) '[');
67+
push();
68+
}
69+
70+
void writeEndArray() throws IOException {
71+
pop();
72+
writeByte((byte) ']');
73+
}
74+
75+
void writeFieldName(String name) throws IOException {
76+
if (counts[depth] > 0) {
77+
writeByte((byte) ',');
78+
}
79+
counts[depth]++;
80+
writeQuoted(name);
81+
writeByte((byte) ':');
82+
afterKey = true;
83+
}
84+
85+
void writeStringField(String name, String value) throws IOException {
86+
writeFieldName(name);
87+
writeString(value);
88+
}
89+
90+
void writeBooleanField(String name, boolean value) throws IOException {
91+
writeFieldName(name);
92+
beforeValue();
93+
writeRawBytes(value ? TRUE : FALSE);
94+
}
95+
96+
void writeNumberField(String name, int value) throws IOException {
97+
writeFieldName(name);
98+
beforeValue();
99+
writeAscii(Integer.toString(value));
100+
}
101+
102+
void writeNumberField(String name, double value) throws IOException {
103+
writeFieldName(name);
104+
writeNumber(value);
105+
}
106+
107+
void writeBinaryField(String name, byte[] value) throws IOException {
108+
writeFieldName(name);
109+
beforeValue();
110+
writeByte((byte) '"');
111+
writeRawBytes(Base64.getEncoder().encode(value));
112+
writeByte((byte) '"');
113+
}
114+
115+
void writeString(String value) throws IOException {
116+
beforeValue();
117+
writeQuoted(value);
118+
}
119+
120+
/**
121+
* Writes a JSON string value from already UTF-8 encoded bytes, avoiding a decode-then-re-encode
122+
* round trip. ASCII bytes are escaped as needed; multi-byte UTF-8 sequences pass through
123+
* verbatim.
124+
*/
125+
void writeUtf8String(byte[] utf8Bytes) throws IOException {
126+
beforeValue();
127+
writeByte((byte) '"');
128+
for (byte b : utf8Bytes) {
129+
if (b >= 0) {
130+
writeEscapedAscii(b); // ASCII, may need escaping
131+
} else {
132+
writeByte(b); // multi-byte UTF-8, never escapable
133+
}
134+
}
135+
writeByte((byte) '"');
136+
}
137+
138+
void writeNumber(double value) throws IOException {
139+
beforeValue();
140+
// proto3 JSON encodes the non-finite values as quoted strings; a bare NaN/Infinity is not valid
141+
// JSON. This matches io.opentelemetry.api.common.JsonEncoding.
142+
if (Double.isNaN(value)) {
143+
writeAscii("\"NaN\"");
144+
} else if (Double.isInfinite(value)) {
145+
writeAscii(value > 0 ? "\"Infinity\"" : "\"-Infinity\"");
146+
} else {
147+
writeAscii(Double.toString(value));
148+
}
149+
}
150+
151+
/** Writes pre-serialized JSON verbatim, without updating separator state. */
152+
void writeRaw(String raw) throws IOException {
153+
writeRawBytes(raw.getBytes(StandardCharsets.UTF_8));
154+
}
155+
156+
/**
157+
* Drains buffered bytes to the underlying stream. Like {@code ProtoSerializer}, it neither
158+
* flushes nor closes the underlying stream; the caller owns its lifecycle.
159+
*/
160+
void close() throws IOException {
161+
try {
162+
if (pos > 0) {
163+
out.write(buffer, 0, pos);
164+
pos = 0;
165+
}
166+
} catch (IOException e) {
167+
// In try-with-resources, draining may rethrow the same exception that failed the body; wrap
168+
// it so re-throwing doesn't trigger an IllegalArgumentException from illegal
169+
// self-suppression.
170+
throw new IOException(e);
171+
}
172+
}
173+
174+
private void beforeValue() throws IOException {
175+
if (afterKey) {
176+
afterKey = false;
177+
return;
178+
}
179+
if (counts[depth] > 0) {
180+
writeByte((byte) ',');
181+
}
182+
counts[depth]++;
183+
}
184+
185+
private void push() {
186+
depth++;
187+
if (depth == counts.length) {
188+
counts = Arrays.copyOf(counts, counts.length * 2);
189+
}
190+
counts[depth] = 0;
191+
}
192+
193+
private void pop() {
194+
depth--;
195+
}
196+
197+
/** Writes a quoted, escaped JSON string, encoding {@code value} as UTF-8. */
198+
private void writeQuoted(String value) throws IOException {
199+
writeByte((byte) '"');
200+
int length = value.length();
201+
for (int i = 0; i < length; i++) {
202+
char c = value.charAt(i);
203+
if (c < 0x80) {
204+
writeEscapedAscii((byte) c);
205+
} else if (c < 0x800) {
206+
writeByte((byte) (0xC0 | (c >> 6)));
207+
writeByte((byte) (0x80 | (c & 0x3F)));
208+
} else if (Character.isHighSurrogate(c) && i + 1 < length) {
209+
char low = value.charAt(i + 1);
210+
if (Character.isLowSurrogate(low)) {
211+
int codePoint = Character.toCodePoint(c, low);
212+
writeByte((byte) (0xF0 | (codePoint >> 18)));
213+
writeByte((byte) (0x80 | ((codePoint >> 12) & 0x3F)));
214+
writeByte((byte) (0x80 | ((codePoint >> 6) & 0x3F)));
215+
writeByte((byte) (0x80 | (codePoint & 0x3F)));
216+
i++;
217+
} else {
218+
writeByte((byte) '?');
219+
}
220+
} else if (Character.isSurrogate(c)) {
221+
// Unpaired surrogate; emit a replacement to keep the output valid UTF-8.
222+
writeByte((byte) '?');
223+
} else {
224+
writeByte((byte) (0xE0 | (c >> 12)));
225+
writeByte((byte) (0x80 | ((c >> 6) & 0x3F)));
226+
writeByte((byte) (0x80 | (c & 0x3F)));
227+
}
228+
}
229+
writeByte((byte) '"');
230+
}
231+
232+
/** Writes a single ASCII byte (0x00-0x7F), escaping it if required by JSON. */
233+
private void writeEscapedAscii(byte b) throws IOException {
234+
switch (b) {
235+
case '"':
236+
writeByte((byte) '\\');
237+
writeByte((byte) '"');
238+
return;
239+
case '\\':
240+
writeByte((byte) '\\');
241+
writeByte((byte) '\\');
242+
return;
243+
case '\b':
244+
writeByte((byte) '\\');
245+
writeByte((byte) 'b');
246+
return;
247+
case '\f':
248+
writeByte((byte) '\\');
249+
writeByte((byte) 'f');
250+
return;
251+
case '\n':
252+
writeByte((byte) '\\');
253+
writeByte((byte) 'n');
254+
return;
255+
case '\r':
256+
writeByte((byte) '\\');
257+
writeByte((byte) 'r');
258+
return;
259+
case '\t':
260+
writeByte((byte) '\\');
261+
writeByte((byte) 't');
262+
return;
263+
default:
264+
if (b < 0x20) {
265+
writeByte((byte) '\\');
266+
writeByte((byte) 'u');
267+
writeByte((byte) '0');
268+
writeByte((byte) '0');
269+
writeByte(HEX[(b >> 4) & 0xF]);
270+
writeByte(HEX[b & 0xF]);
271+
} else {
272+
writeByte(b);
273+
}
274+
}
275+
}
276+
277+
/** Writes the bytes of an ASCII-only string such as a formatted number. */
278+
private void writeAscii(String value) throws IOException {
279+
int length = value.length();
280+
for (int i = 0; i < length; i++) {
281+
writeByte((byte) value.charAt(i));
282+
}
283+
}
284+
285+
private void writeRawBytes(byte[] bytes) throws IOException {
286+
int offset = 0;
287+
int remaining = bytes.length;
288+
while (remaining > 0) {
289+
if (pos == buffer.length) {
290+
out.write(buffer, 0, pos);
291+
pos = 0;
292+
}
293+
int chunk = Math.min(remaining, buffer.length - pos);
294+
System.arraycopy(bytes, offset, buffer, pos, chunk);
295+
pos += chunk;
296+
offset += chunk;
297+
remaining -= chunk;
298+
}
299+
}
300+
301+
private void writeByte(byte b) throws IOException {
302+
if (pos == buffer.length) {
303+
out.write(buffer, 0, pos);
304+
pos = 0;
305+
}
306+
buffer[pos++] = b;
307+
}
308+
}

0 commit comments

Comments
 (0)