Skip to content

perf: hand-write the OTLP JSON encoding - #18

Open
bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/perf
Open

bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/perf

Conversation

@bengl

@bengl bengl commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

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 produced null. A span that fails to serialize is still skipped on its own, so the rest of the batch exports.

The smaller wins. 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.

Performance. Measured on Node v26.12.1, main versus this PR:

Benchmark main This PR Change
Flush path, typical ASCII values 951 ns/span 709 ns/span 25% less time, 34% more spans/s
Flush path, BMP non-ASCII values 2183 ns/span 2427 ns/span 11% more time
Flush path, astral and lone-surrogate values 3675 ns/span 4113 ns/span 12% more time
Package CPU time, 50k traced http requests 193 ms, 226 ms 185 ms, 184 ms about 12% less
node:bench, http request (traced) 28.8k ops/s 29.5k ops/s +2%, within noise
node:bench, fetch request (traced) 19.2k ops/s 19.0k ops/s -1%, within noise
  • The flush-path rows time the flush with the network stubbed out, over 100-span batches of real Span objects 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 in url.path: BMP text such as CJK and U+2028, or text with emoji and a lone surrogate.
  • Non-ASCII values mostly arrive through exception messages and stack traces, since HTTP paths and headers rarely carry non-ASCII text.
  • The CPU-time row sums every package frame in a --cpu-prof profile, 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.
  • The node:bench rows are medians of three interleaved runs per side, and single runs varied by more than the gap between the medians. End-to-end throughput barely moves because Node's http stack dominates the per-request cost.

Equivalence. A differential harness ran 62 span-like and 38 real Span inputs 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.

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
bengl marked this pull request as ready for review October 30, 2026 17:02
@bengl
bengl requested a review from a team October 30, 2026 17:02

@AbhiPrasad AbhiPrasad left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to dig into the unicode slowness to see if we can improve that case

@legendecas legendecas left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants