Skip to content

Commit 7f3d549

Browse files
authored
Replace jackson OTLP json serialization with handrolled version (#8545)
1 parent 91cbb32 commit 7f3d549

16 files changed

Lines changed: 701 additions & 185 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")
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
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 encoder that serializes directly to an {@link OutputStream} as UTF-8, buffering
16+
* writes and implementing only the subset of JSON generation {@link JsonSerializer} needs so OTLP
17+
* JSON serialization has no third party dependency. Method names mirror the Jackson {@code
18+
* JsonGenerator} that previously 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+
* <p>Nesting depth is capped at {@value #MAX_NESTING_DEPTH}. Attempting to nest deeper throws
25+
* {@link IOException} rather than {@link StackOverflowError}, which matters for recursive OTLP
26+
* structures such as cyclic {@code Value} bodies that could otherwise DoS an exporter. The cap is
27+
* intentionally conservative (Jackson 3's default is 500). Real OTLP payloads nest only a handful
28+
* of levels, and it is easier to relax the cap later than to tighten it.
29+
*/
30+
final class JsonBufferedEncoder {
31+
32+
static final int MAX_NESTING_DEPTH = 100;
33+
34+
private static final byte[] TRUE = {'t', 'r', 'u', 'e'};
35+
private static final byte[] FALSE = {'f', 'a', 'l', 's', 'e'};
36+
private static final byte[] HEX = {
37+
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
38+
};
39+
40+
private final OutputStream out;
41+
private final byte[] buffer = new byte[4096];
42+
private int pos;
43+
44+
// Whether any member has been written at each nesting depth; depth 0 is the implicit root. The
45+
// array grows on demand in push(), so it does not cap nesting depth.
46+
private boolean[] hasMembers = new boolean[16];
47+
private int depth;
48+
// True immediately after a field name has been written, meaning the next value is that field's
49+
// value and must not be preceded by a comma.
50+
private boolean afterKey;
51+
52+
JsonBufferedEncoder(OutputStream out) {
53+
this.out = out;
54+
}
55+
56+
void writeStartObject() throws IOException {
57+
beforeValue();
58+
writeByte((byte) '{');
59+
push();
60+
}
61+
62+
void writeEndObject() throws IOException {
63+
pop();
64+
writeByte((byte) '}');
65+
}
66+
67+
void writeObjectFieldStart(String name) throws IOException {
68+
writeFieldName(name);
69+
writeStartObject();
70+
}
71+
72+
void writeArrayFieldStart(String name) throws IOException {
73+
writeFieldName(name);
74+
beforeValue();
75+
writeByte((byte) '[');
76+
push();
77+
}
78+
79+
void writeEndArray() throws IOException {
80+
pop();
81+
writeByte((byte) ']');
82+
}
83+
84+
void writeFieldName(String name) throws IOException {
85+
if (hasMembers[depth]) {
86+
writeByte((byte) ',');
87+
}
88+
hasMembers[depth] = true;
89+
writeQuoted(name);
90+
writeByte((byte) ':');
91+
afterKey = true;
92+
}
93+
94+
void writeStringField(String name, String value) throws IOException {
95+
writeFieldName(name);
96+
writeString(value);
97+
}
98+
99+
void writeBooleanField(String name, boolean value) throws IOException {
100+
writeFieldName(name);
101+
beforeValue();
102+
writeRawBytes(value ? TRUE : FALSE);
103+
}
104+
105+
void writeNumberField(String name, int value) throws IOException {
106+
writeFieldName(name);
107+
beforeValue();
108+
writeAscii(Integer.toString(value));
109+
}
110+
111+
void writeNumberField(String name, double value) throws IOException {
112+
writeFieldName(name);
113+
writeNumber(value);
114+
}
115+
116+
void writeBinaryField(String name, byte[] value) throws IOException {
117+
writeFieldName(name);
118+
beforeValue();
119+
writeByte((byte) '"');
120+
writeRawBytes(Base64.getEncoder().encode(value));
121+
writeByte((byte) '"');
122+
}
123+
124+
void writeString(String value) throws IOException {
125+
beforeValue();
126+
writeQuoted(value);
127+
}
128+
129+
/**
130+
* Writes a JSON string value from already UTF-8 encoded bytes, avoiding a decode-then-re-encode
131+
* round trip. ASCII bytes are escaped as needed; multi-byte UTF-8 sequences pass through
132+
* verbatim.
133+
*/
134+
void writeUtf8String(byte[] utf8Bytes) throws IOException {
135+
beforeValue();
136+
writeByte((byte) '"');
137+
for (byte b : utf8Bytes) {
138+
if (b >= 0) {
139+
writeEscapedAscii(b); // ASCII, may need escaping
140+
} else {
141+
writeByte(b); // multi-byte UTF-8, never escapable
142+
}
143+
}
144+
writeByte((byte) '"');
145+
}
146+
147+
void writeNumber(double value) throws IOException {
148+
beforeValue();
149+
// proto3 JSON encodes the non-finite values as quoted strings; a bare NaN/Infinity is not valid
150+
// JSON. This matches io.opentelemetry.api.common.JsonEncoding.
151+
if (Double.isNaN(value)) {
152+
writeAscii("\"NaN\"");
153+
} else if (Double.isInfinite(value)) {
154+
writeAscii(value > 0 ? "\"Infinity\"" : "\"-Infinity\"");
155+
} else {
156+
writeAscii(Double.toString(value));
157+
}
158+
}
159+
160+
/** Writes pre-serialized JSON verbatim, without updating separator state. */
161+
void writeRaw(String raw) throws IOException {
162+
writeRawBytes(raw.getBytes(StandardCharsets.UTF_8));
163+
}
164+
165+
/**
166+
* Drains buffered bytes to the underlying stream. Like {@code ProtoSerializer}, it neither
167+
* flushes nor closes the underlying stream; the caller owns its lifecycle.
168+
*/
169+
void close() throws IOException {
170+
try {
171+
if (pos > 0) {
172+
out.write(buffer, 0, pos);
173+
pos = 0;
174+
}
175+
} catch (IOException e) {
176+
// In try-with-resources, draining may rethrow the same exception that failed the body; wrap
177+
// it so re-throwing doesn't trigger an IllegalArgumentException from illegal
178+
// self-suppression.
179+
throw new IOException(e);
180+
}
181+
}
182+
183+
private void beforeValue() throws IOException {
184+
if (afterKey) {
185+
afterKey = false;
186+
return;
187+
}
188+
if (hasMembers[depth]) {
189+
writeByte((byte) ',');
190+
}
191+
hasMembers[depth] = true;
192+
}
193+
194+
private void push() throws IOException {
195+
if (depth == MAX_NESTING_DEPTH) {
196+
throw new IOException(
197+
"JSON nesting depth exceeds maximum allowed (" + MAX_NESTING_DEPTH + ")");
198+
}
199+
depth++;
200+
if (depth == hasMembers.length) {
201+
hasMembers = Arrays.copyOf(hasMembers, hasMembers.length * 2);
202+
}
203+
hasMembers[depth] = false;
204+
}
205+
206+
private void pop() {
207+
depth--;
208+
}
209+
210+
/** Writes a quoted, escaped JSON string, encoding {@code value} as UTF-8. */
211+
private void writeQuoted(String value) throws IOException {
212+
writeByte((byte) '"');
213+
int length = value.length();
214+
for (int i = 0; i < length; i++) {
215+
char c = value.charAt(i);
216+
if (c < 0x80) {
217+
writeEscapedAscii((byte) c);
218+
} else if (c < 0x800) {
219+
writeByte((byte) (0xC0 | (c >> 6)));
220+
writeByte((byte) (0x80 | (c & 0x3F)));
221+
} else if (Character.isHighSurrogate(c) && i + 1 < length) {
222+
char low = value.charAt(i + 1);
223+
if (Character.isLowSurrogate(low)) {
224+
int codePoint = Character.toCodePoint(c, low);
225+
writeByte((byte) (0xF0 | (codePoint >> 18)));
226+
writeByte((byte) (0x80 | ((codePoint >> 12) & 0x3F)));
227+
writeByte((byte) (0x80 | ((codePoint >> 6) & 0x3F)));
228+
writeByte((byte) (0x80 | (codePoint & 0x3F)));
229+
i++;
230+
} else {
231+
writeByte((byte) '?');
232+
}
233+
} else if (Character.isSurrogate(c)) {
234+
// Unpaired surrogate; emit a replacement to keep the output valid UTF-8.
235+
writeByte((byte) '?');
236+
} else {
237+
writeByte((byte) (0xE0 | (c >> 12)));
238+
writeByte((byte) (0x80 | ((c >> 6) & 0x3F)));
239+
writeByte((byte) (0x80 | (c & 0x3F)));
240+
}
241+
}
242+
writeByte((byte) '"');
243+
}
244+
245+
/** Writes a single ASCII byte (0x00-0x7F), escaping it if required by JSON. */
246+
private void writeEscapedAscii(byte b) throws IOException {
247+
switch (b) {
248+
case '"':
249+
writeByte((byte) '\\');
250+
writeByte((byte) '"');
251+
return;
252+
case '\\':
253+
writeByte((byte) '\\');
254+
writeByte((byte) '\\');
255+
return;
256+
case '\b':
257+
writeByte((byte) '\\');
258+
writeByte((byte) 'b');
259+
return;
260+
case '\f':
261+
writeByte((byte) '\\');
262+
writeByte((byte) 'f');
263+
return;
264+
case '\n':
265+
writeByte((byte) '\\');
266+
writeByte((byte) 'n');
267+
return;
268+
case '\r':
269+
writeByte((byte) '\\');
270+
writeByte((byte) 'r');
271+
return;
272+
case '\t':
273+
writeByte((byte) '\\');
274+
writeByte((byte) 't');
275+
return;
276+
default:
277+
if (b < 0x20) {
278+
writeByte((byte) '\\');
279+
writeByte((byte) 'u');
280+
writeByte((byte) '0');
281+
writeByte((byte) '0');
282+
writeByte(HEX[(b >> 4) & 0xF]);
283+
writeByte(HEX[b & 0xF]);
284+
} else {
285+
writeByte(b);
286+
}
287+
}
288+
}
289+
290+
/** Writes the bytes of an ASCII-only string such as a formatted number. */
291+
private void writeAscii(String value) throws IOException {
292+
int length = value.length();
293+
for (int i = 0; i < length; i++) {
294+
writeByte((byte) value.charAt(i));
295+
}
296+
}
297+
298+
private void writeRawBytes(byte[] bytes) throws IOException {
299+
int offset = 0;
300+
int remaining = bytes.length;
301+
while (remaining > 0) {
302+
if (pos == buffer.length) {
303+
out.write(buffer, 0, pos);
304+
pos = 0;
305+
}
306+
int chunk = Math.min(remaining, buffer.length - pos);
307+
System.arraycopy(bytes, offset, buffer, pos, chunk);
308+
pos += chunk;
309+
offset += chunk;
310+
remaining -= chunk;
311+
}
312+
}
313+
314+
private void writeByte(byte b) throws IOException {
315+
if (pos == buffer.length) {
316+
out.write(buffer, 0, pos);
317+
pos = 0;
318+
}
319+
buffer[pos++] = b;
320+
}
321+
}

0 commit comments

Comments
 (0)