Repository navigation
Conversation
Serializing spans built an intermediate object tree per span, every
attribute wrapped in a {key, value: {stringValue}} object, which
JSON.stringify then walked and discarded. The encoder now writes the
JSON string directly. A fast path skips JSON.stringify for strings that
need no escaping, the encoded prefix of each attribute key is cached,
and the fixed resource and scope envelope is built once. Names, keys,
attribute values, and status messages all go through the escape check,
so the payload stays valid JSON for any input. Non-finite doubles,
which have no JSON number form, are written as the ProtoJSON strings
"NaN", "Infinity", and "-Infinity", where JSON.stringify produced
null. A span that fails to serialize is still skipped on its own, so
the rest of the batch exports.
timeToUnixNano appends six zeros for ordinary epoch milliseconds
instead of doing BigInt math, and falls back to the BigInt path for
anything else, so its output always matches. splitAuthority memoizes
the last authority seen, since consecutive requests usually share one.
On the flush path in isolation, with typical ASCII span values,
serialization goes from about 950 to 710 nanoseconds per span, roughly
34 percent more spans per second. Values heavy in non-ASCII text are
11 to 12 percent slower per span. In CPU profiles of 50k traced http
requests, the package's own CPU time drops about 12 percent, and
end-to-end throughput moves only within noise, because the http stack
dominates the cost.
The test that forced a JSON.stringify failure is replaced, since the
encoder no longer calls JSON.stringify on spans. New tests cover
escaping in names, keys, and values, non-ASCII and surrogate round
trips, non-finite doubles, timestamp edge cases, and a span that fails
to serialize in the middle of a batch.
Signed-off-by: Bryan English <bryan@bryanenglish.com>
bengl
marked this pull request as ready for review
October 30, 2026 17:02
This was referenced Oct 30, 2026
AbhiPrasad
approved these changes
Oct 31, 2026
AbhiPrasad
left a comment
Member
There was a problem hiding this comment.
I'll try to dig into the unicode slowness to see if we can improve that case
legendecas
requested changes
Oct 31, 2026
Member
There was a problem hiding this comment.
Similar to #23, I think we need proof that a non-trivial hand rolled implementation does give us the performance improvements, and comparing to @opentelemetry/otlp-transformer, in particular in a realistic batched case.
batches/s main 6397 PR 4100 PR/main 0.64x
batches/s main 6251 PR 4135 PR/main 0.66x
batches/s main 6379 PR 4134 PR/main 0.65x
batches/s PR 4074 otlp-transformer 6150 PR/otlp-transformer 0.66x
batches/s PR 4024 otlp-transformer 6264 PR/otlp-transformer 0.64x
batches/s PR 4120 otlp-transformer 6105 PR/otlp-transformer 0.67x
(thoughput, the higher the better)
Details
There is no an exported serialize API so this stubs HTTP module to call into flush.
// json-encode-vs-main.js
const { execFileSync } = require('node:child_process');
const http = require('node:http');
const path = require('node:path');
// Each checkout runs in its own process, so neither sees the other's heap
// state or JIT feedback.
if (process.argv[3] === undefined) {
const run = (root) =>
Number(execFileSync(process.execPath, [__filename, 'child', root]));
const main = run(path.resolve(process.argv[2]));
const pr = run(path.join(__dirname, '..'));
console.log(`batches/s main ${main} PR ${pr} PR/main ${(pr / main).toFixed(2)}x`);
process.exit(0);
}
const root = process.argv[3];
http.request = () => ({ on() {}, end: (body) => Buffer.from(body) });
require(root).init({ endpoint: 'http://127.0.0.1:9' });
const { Span } = require(path.join(root, 'lib/span'));
const { addSpan, flush } = require(path.join(root, 'lib/flush'));
const spans = Array.from({ length: 100 }, (_, i) => {
const span = new Span('GET', 3);
span.setAttribute('http.request.method', 'GET');
span.setAttribute('url.full', `https://example.com/search?i=${i}&q=${'ab'.repeat(150)}`);
span.setAttribute('url.path', `/商品/${i}/日本語-💥`);
span.setAttribute('server.address', 'example.com');
span.setAttribute('server.port', 443);
span.setAttribute('http.response.status_code', 200);
span.endTime = span.startTime + 10; // Not end(): that queues an export.
return span;
});
// Median batches/s of 30 samples of 200 batches, after warmup.
const rates = [];
for (let i = 0; i < 50; i++) {
const start = process.hrtime.bigint();
for (let j = 0; j < 200; j++) {
for (const span of spans) addSpan(span);
flush();
}
rates.push(200e9 / Number(process.hrtime.bigint() - start));
}
console.log(Math.round(rates.slice(20).sort((a, b) => a - b)[15]));
process.exit(0); // Each flush leaves an export-timeout timer pending.// json-encode-vs-otlp-transformer.js
const { execFileSync } = require('node:child_process');
const http = require('node:http');
// Each side runs in its own process. Heap state left by one encoder skews
// the other by up to 30%, depending on which runs first.
if (process.argv[2] === undefined) {
const run = (side) => Number(execFileSync(process.execPath, [__filename, side]));
const pr = run('pr');
const transformer = run('otlp-transformer');
console.log(
`batches/s PR ${pr} otlp-transformer ${transformer} ` +
`PR/otlp-transformer ${(pr / transformer).toFixed(2)}x`,
);
process.exit(0);
}
const attributes = (i) => ({
'http.request.method': 'GET',
'url.full': `https://example.com/search?i=${i}&q=${'ab'.repeat(150)}`,
'url.path': `/商品/${i}/日本語-💥`,
'server.address': 'example.com',
'server.port': 443,
'http.response.status_code': 200,
});
function microtel() {
http.request = () => ({ on() {}, end: (body) => Buffer.from(body) });
require('..').init({ endpoint: 'http://127.0.0.1:9' });
const { Span } = require('../lib/span');
const { addSpan, flush } = require('../lib/flush');
const spans = Array.from({ length: 100 }, (_, i) => {
const span = new Span('GET', 3);
for (const [key, value] of Object.entries(attributes(i)))
span.setAttribute(key, value);
span.endTime = span.startTime + 10; // Not end(): that queues an export.
return span;
});
return () => {
for (const span of spans) addSpan(span);
flush();
};
}
// Hand-built ReadableSpan-shaped input with microtel's resource and scope
// content. Sharing them by identity puts all spans in one ResourceSpans.
function otlpTransformer() {
const { JsonTraceSerializer } = require('@opentelemetry/otlp-transformer');
const resource = {
attributes: {
'service.name': 'unknown_service:node',
'telemetry.sdk.name': 'microtel',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.version': '0.0.0',
'process.runtime.name': 'nodejs',
'process.runtime.version': process.version,
'process.pid': process.pid,
},
};
const instrumentationScope = { name: 'microtel', version: '0.0.0' };
const context = { traceId: 'ab'.repeat(16), spanId: 'cd'.repeat(8), traceFlags: 1 };
const spans = Array.from({ length: 100 }, (_, i) => ({
name: 'GET',
kind: 2, // SpanKind.CLIENT
spanContext: () => context,
startTime: [1_700_000_000, 0],
endTime: [1_700_000_000, 10_000_000],
attributes: attributes(i),
events: [],
links: [],
status: { code: 0 },
droppedAttributesCount: 0,
droppedEventsCount: 0,
droppedLinksCount: 0,
resource,
instrumentationScope,
}));
return () => JsonTraceSerializer.serializeRequest(spans);
}
// Median batches/s of 30 samples of 200 batches, after warmup.
function measure(encode) {
const rates = [];
for (let i = 0; i < 50; i++) {
const start = process.hrtime.bigint();
for (let j = 0; j < 200; j++) encode();
rates.push(200e9 / Number(process.hrtime.bigint() - start));
}
return Math.round(rates.slice(20).sort((a, b) => a - b)[15]);
}
console.log(measure(process.argv[2] === 'pr' ? microtel() : otlpTransformer()));
process.exit(0); // Each flush leaves an export-timeout timer pending.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the hand-written JSON encoding that the TODO in lib/flush.js called for, plus two smaller hot-path wins.
The encoding. Serialization built an intermediate object tree per span, every attribute wrapped in a
{key, value: {stringValue}}object, which JSON.stringify then walked and discarded. The encoder now writes the JSON string directly. A fast path skips JSON.stringify for strings that need no escaping, the encoded prefix of each attribute key is cached, and the fixed resource and scope envelope is built once. Names, keys, attribute values, and status messages all go through the escape check, so the payload stays valid JSON for any input. Non-finite doubles, which have no JSON number form, are written as the ProtoJSON strings"NaN","Infinity", and"-Infinity", where JSON.stringify producednull. A span that fails to serialize is still skipped on its own, so the rest of the batch exports.The smaller wins.
timeToUnixNanoappends six zeros for ordinary epoch milliseconds instead of doing BigInt math, and falls back to the BigInt path for anything else, so its output always matches.splitAuthoritymemoizes the last authority seen, since consecutive requests usually share one.Performance. Measured on Node v26.12.1, main versus this PR:
Spanobjects carrying the seven attributes of an http server span. Each is the median of five interleaved runs per side. The non-ASCII rows put a value of several hundred characters inurl.path: BMP text such as CJK and U+2028, or text with emoji and a lone surrogate.--cpu-profprofile, and shows both profiles per side. Serialization is only part of the package's per-request work, so this moves less than the flush path.Equivalence. A differential harness ran 62 span-like and 38 real
Spaninputs through this branch and main and compared the parsed payloads. For real spans, the only differences are the intentional ProtoJSON spellings for non-finite doubles. The other span-like differences need inputs real spans cannot produce: corrupted trace or span IDs, a non-numeric kind or status code, or Symbol and BigInt names.Tests. The test that forced a JSON.stringify failure is replaced, since the encoder no longer calls JSON.stringify on spans. New tests cover escaping in names, keys, and values, non-ASCII and surrogate round trips, non-finite doubles, timestamp edge cases, and a span that fails to serialize in the middle of a batch.