Skip to content

perf(encode): consolidate the msgpack hot path#8504

Merged
bengl merged 16 commits into
masterfrom
BridgeAR/2026-05-14-perf-encode-stringmap
May 29, 2026
Merged

perf(encode): consolidate the msgpack hot path#8504
bengl merged 16 commits into
masterfrom
BridgeAR/2026-05-14-perf-encode-stringmap

Conversation

@BridgeAR

@BridgeAR BridgeAR commented May 16, 2026

Copy link
Copy Markdown
Member

A series of related perf wins on the trace encoder's msgpack path.
Independent commits, but each one builds on the last, so reviewing
them in order is the easy way in.

The realistic-trace bench fixture this PR is benched against lives
in #8668. While that's open, the sirun bench-comparison comment on
this PR compares the new fixture (on this branch) against the old
30-clones-of-one-span fixture (on master), which inflates every
delta on the encoder hot path because the new fixture does roughly
5x the encode work per iteration. The local apples-to-apples
numbers below use the new fixture on both checkouts.

  • tighten msgpack dispatch and debug-log allocation: reorders the
    type switch so the string arm matches the typical tag value first,
    emits map keys via encodeString directly (Object.keys only yields
    strings), and hoists the encode-debug hex-dump formatter to a
    module-level function so it no longer allocates a closure per encode.
    memoizedLogDebug switches to printf-style so the value substitution
    only runs when the debug channel has subscribers.
  • fold msgpack primitives onto MsgpackChunk: removes the wrapper
    MsgpackEncoder class. Encoder code writes straight into the chunk
    (bytes.writeX) instead of routing through this._encodeX -> base
    wrapper -> MsgpackEncoder. The free encode(value) function moves
    to ../msgpack so DataStreamsWriter keeps the recursive dispatcher
    it imports.
  • right-size MsgpackChunk: initial buffer drops from 2 MiB to
    1 MiB. The previous default cost every tracer-loaded process a fixed
    4 - 6 MiB regardless of payload size. Growth doubles on overflow so
    worst-case bursts reach the larger sizes in fewer copies. 32
    consecutive low-usage flushes halve the buffer toward minSize, so
    long-lived encoders give memory back during quiet periods.
  • pre-fuse error: 0 / 1: precomputed [KEY_ERROR, fixint]
    constants emit the common error value via one bytes.set instead
    of routing through writeIntOrFloat's magnitude branches. The else
    branch keeps the variable-value path so rare non-binary error flags
    still pick the shortest valid encoding.
  • bypass the string cache for values over 1 KiB: long unique
    strings (stringified span_events, stack traces, full SQL bodies)
    stream through bytes.write directly on the v0.4 wire. The cache
    hit rate stays near zero on them - they're unique per span - so
    the per-emit lookup was paid for nothing. The same bypass does
    not extend to v0.5 because the v0.5 wire ships strings as indices,
    and a measurement showed the added per-call branch costs more on
    v0.5 than the skipped map insertion saves.
  • exercise agentless-JSON soft-limit flush via constructor:
    follow-up that adds a softLimit constructor parameter to
    AgentlessJSONEncoder (mirroring the existing v0.4/v0.5 hook). The
    soft-limit branch is now reachable from public API; production
    behavior is unchanged when the parameter is omitted.
  • extend per-span block past service into the start/duration keys:
    when error: 0/1 AND start >= 2^32 (almost every real span), the
    v0.4 encoder fuses the error key+value, the start key + 0xCF type
    byte + 8-byte timestamp, and the duration key into the same
    bytes.reserve that already covers the map header, optional type,
    the three IDs, and name/resource/service. Synthetic inputs with
    small starts or unusual error flags drop back to the per-field
    emits so each integer still picks the shortest msgpack encoding.
  • inline the fixint fast path in #encodeMetaEntries: speculate
    the fixint case at the v0.4 meta-map call site - reserve
    keyEntryLen + 1 up front, copy the key, write the value byte
    directly. Misses rewind the speculative byte and fall back to the
    full writer so the wire still picks the shortest valid encoding.
  • drop dead writeFixMap and writeFixArray default: ten-line
    cleanup of a method this branch added with no caller; the = 0
    default on writeFixArray is dead in the same way (its only
    caller passes the length explicitly).
  • cover msgpack chunk primitives and the 0.4 fallback branch:
    pin the patch lines codecov flagged as uncovered - the
    MsgpackChunk writeX surface (bin32, int8, NaN coercion), the
    msgpack/encode dispatcher arms (null, boolean, symbol, the
    default arm for function / undefined, and array.length >= 16),
    and the AgentEncoder._encode non-fuseTail fallback for
    error === 1 and unusual error flags.
  • cap MsgpackChunk growth at the agent intake limit: master
    grew the backing buffer without an upper bound (it rounded up to
    multiples of minSize); this branch's doubling inherited the same
    property. A pathological single span (multi-MB stack trace,
    unsanitized meta tag the size of a media file) used to grow the
    chunk until allocation failed and took the host process with it.
    reserve now refuses growth past 50 MiB - the agent's intake
    ceiling - with a tagged RangeError. AgentEncoder.encode
    catches it, drops the in-flight payload (rolling back partial
    state would leave the string cache pointing at orphaned offsets),
    and logs via log.error. The hot path stays untouched: the cap
    check only fires inside the existing overflow branch.
  • fuse the per-span head block on the v0.5 wire: every v0.5
    span used to pay seven separate reserves for its fixed-size prefix
    (0x9C marker, three string indices, three uint64 IDs). The v0.5
    wire is positional, so the fuse is structurally simpler than the
    v0.4 equivalent -- no key bytes to concatenate, and the 43-byte head
    block tiles exactly. _cacheString is refactored to return the
    resolved index so the head fuse and _encodeString can share the
    resolve-or-cache shape.
  • speculate the fixint fast path in v0.5 _encodeMap: mirrors
    the v0.4 meta-map move on the v0.5 wire. String entries collapse
    onto one 10-byte reserve (both halves are uint32 indices). Numeric
    entries speculate the positive-fixint case (5 + 1 bytes) and rewind
    the speculative byte on miss so the wire still picks the shortest
    valid encoding.

Bench (benchmark/sirun/encoding/index.js, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials with best+worst dropped,
Node 24.15.0; both runs use the new fixture from #8668, only the
encoder source differs):

scenario master head delta
encoder=0.4 events=none 313 ms 130 ms -59 %
encoder=0.5 events=none 94 ms 74 ms -22 %
encoder=0.4 events=legacy 961 ms 425 ms -56 %
encoder=0.5 events=legacy 387 ms 354 ms -8 %

The v0.5 wire gains less than v0.4 because every string still has to
go through the indexed string table; the v0.4 cache-bypass shortcut
can't apply. The v0.5 events=legacy case is the slowest hot path
because the multi-KiB stringified meta.events value lands in
_stringBytes per span -- that's a future memoization target (see
the docs commit's TODO), not picked up here because the per-array
identity changes between encodes on every realistic call site.

Wire output is unchanged. 0.4.spec.js / 0.5.spec.js cover the
whole hot path against master.

@dd-octo-sts

dd-octo-sts Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Overall package size

Self size: 6.07 MB
Deduped: 7.1 MB
No deduping: 7.1 MB

Dependency sizes | name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.0.1 | 82.56 kB | 817.39 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |

🤖 This report was automatically generated by heaviest-objects-in-the-universe

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented May 16, 2026

Copy link
Copy Markdown

Pipelines  Tests

Fix all issues with BitsAI

⚠️ Warnings

🚦 1 Pipeline job failed

All Green | all-green   View in Datadog   GitHub Actions

🔄 Retry job. This looks flaky and may succeed on retry. State is still pending after 20 retries. Cancelling workflow run due to timeout.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 692ce3b | Docs | Datadog PR Page | Give us feedback!

@codecov

codecov Bot commented May 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.24%. Comparing base (130446e) to head (692ce3b).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8504      +/-   ##
==========================================
+ Coverage   92.69%   93.24%   +0.54%     
==========================================
  Files         860      860              
  Lines       48832    48918      +86     
  Branches     9242     9257      +15     
==========================================
+ Hits        45267    45615     +348     
+ Misses       3565     3303     -262     
Flag Coverage Δ
aiguard-integration-active 41.68% <24.29%> (-0.02%) ⬇️
aiguard-integration-latest ?
aiguard-integration-maintenance 41.73% <24.29%> (-0.02%) ⬇️
aiguard-macos 33.38% <26.93%> (-0.09%) ⬇️
aiguard-ubuntu 33.47% <26.93%> (-0.09%) ⬇️
aiguard-windows 33.20% <26.93%> (?)
apm-capabilities-tracing-macos 48.40% <97.90%> (+0.20%) ⬆️
apm-capabilities-tracing-ubuntu-active 48.40% <97.90%> (+0.20%) ⬆️
apm-capabilities-tracing-ubuntu-latest 48.40% <97.90%> (?)
apm-capabilities-tracing-ubuntu-maintenance 48.60% <97.90%> (?)
apm-capabilities-tracing-ubuntu-oldest 48.43% <97.90%> (+0.20%) ⬆️
apm-capabilities-tracing-windows 48.21% <97.90%> (+0.21%) ⬆️
apm-integrations-aerospike-18-gte.5.2.0 33.24% <24.14%> (-0.13%) ⬇️
apm-integrations-aerospike-20-gte.5.5.0 ?
apm-integrations-aerospike-22-gte.5.12.1 33.27% <24.14%> (-0.13%) ⬇️
apm-integrations-aerospike-22-gte.6.0.0 33.27% <24.14%> (-0.13%) ⬇️
apm-integrations-aerospike-eol- 33.17% <24.14%> (?)
apm-integrations-child-process 34.19% <25.69%> (-0.10%) ⬇️
apm-integrations-confluentinc-kafka-javascript-18 40.27% <49.22%> (-0.07%) ⬇️
apm-integrations-confluentinc-kafka-javascript-20 40.29% <49.22%> (-0.07%) ⬇️
apm-integrations-confluentinc-kafka-javascript-22 40.30% <49.22%> (-0.07%) ⬇️
apm-integrations-confluentinc-kafka-javascript-24 40.24% <49.22%> (-0.07%) ⬇️
apm-integrations-couchbase-18 33.43% <24.14%> (-0.13%) ⬇️
apm-integrations-couchbase-eol 33.31% <25.69%> (-0.09%) ⬇️
apm-integrations-dns 33.17% <24.14%> (-0.13%) ⬇️
apm-integrations-elasticsearch 34.26% <25.69%> (-0.09%) ⬇️
apm-integrations-http-latest 41.56% <31.88%> (-0.02%) ⬇️
apm-integrations-http-maintenance 41.60% <31.88%> (-0.02%) ⬇️
apm-integrations-http-oldest 41.53% <27.86%> (-0.10%) ⬇️
apm-integrations-http2 38.36% <24.14%> (-0.11%) ⬇️
apm-integrations-kafkajs-latest 40.43% <53.25%> (+0.02%) ⬆️
apm-integrations-kafkajs-oldest 40.47% <53.25%> (+0.11%) ⬆️
apm-integrations-net 33.88% <24.14%> (?)
apm-integrations-next-11.1.4 20.07% <ø> (ø)
apm-integrations-next-12.3.7 20.07% <ø> (ø)
apm-integrations-next-13.0.0 29.21% <6.81%> (-0.07%) ⬇️
apm-integrations-next-13.2.0 29.21% <6.81%> (-0.07%) ⬇️
apm-integrations-next-13.5.11 29.35% <6.81%> (-0.07%) ⬇️
apm-integrations-next-14.0.0 29.27% <6.81%> (-0.07%) ⬇️
apm-integrations-next-14.2.35 29.27% <6.81%> (-0.07%) ⬇️
apm-integrations-next-14.2.6 29.27% <6.81%> (-0.07%) ⬇️
apm-integrations-next-14.2.7 29.31% <6.81%> (-0.03%) ⬇️
apm-integrations-next-15.0.0 29.27% <6.81%> (-0.07%) ⬇️
apm-integrations-next-15.4.0 29.35% <6.81%> (-0.07%) ⬇️
apm-integrations-oracledb 34.09% <25.69%> (?)
apm-integrations-prisma-18-gte.6.16.0.and.lt.7.0.0 34.60% <25.69%> (-0.10%) ⬇️
apm-integrations-prisma-latest-all 34.35% <25.69%> (-0.09%) ⬇️
apm-integrations-restify 35.29% <24.14%> (-0.13%) ⬇️
apm-integrations-sharedb 32.64% <25.69%> (-0.09%) ⬇️
apm-integrations-tedious 33.57% <25.69%> (?)
appsec-express 51.38% <30.65%> (-0.07%) ⬇️
appsec-fastify 48.08% <30.65%> (-0.09%) ⬇️
appsec-graphql 48.07% <30.03%> (-0.08%) ⬇️
appsec-integration-active 36.23% <29.90%> (+<0.01%) ⬆️
appsec-integration-latest 36.23% <29.90%> (+<0.01%) ⬆️
appsec-integration-maintenance 36.26% <29.90%> (+<0.01%) ⬆️
appsec-integration-oldest 36.26% <29.90%> (+<0.01%) ⬆️
appsec-kafka 40.53% <27.86%> (-0.11%) ⬇️
appsec-ldapjs 39.89% <27.86%> (-0.11%) ⬇️
appsec-lodash ?
appsec-macos 57.34% <28.48%> (-0.12%) ⬇️
appsec-mongodb-core 44.20% <27.86%> (-0.11%) ⬇️
appsec-mongoose 45.04% <27.86%> (?)
appsec-mysql 47.28% <28.48%> (-0.11%) ⬇️
appsec-next-latest-11.1.4 27.46% <6.81%> (-0.06%) ⬇️
appsec-next-latest-12.3.7 27.69% <0.00%> (ø)
appsec-next-latest-13.0.0 29.26% <6.81%> (?)
appsec-next-latest-13.2.0 29.29% <6.81%> (-0.07%) ⬇️
appsec-next-latest-13.5.11 29.38% <6.81%> (-0.07%) ⬇️
appsec-next-latest-14.0.0 29.31% <6.81%> (-0.07%) ⬇️
appsec-next-latest-14.2.35 29.31% <6.81%> (-0.07%) ⬇️
appsec-next-latest-14.2.6 29.31% <6.81%> (?)
appsec-next-latest-14.2.7 29.31% <6.81%> (-0.07%) ⬇️
appsec-next-latest-15.0.0 29.31% <6.81%> (-0.07%) ⬇️
appsec-next-latest-latest 29.31% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-11.1.4 27.53% <6.81%> (+0.01%) ⬆️
appsec-next-oldest-12.3.7 ?
appsec-next-oldest-13.0.0 29.26% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-13.2.0 29.53% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-13.5.11 29.63% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-14.0.0 29.56% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-14.2.35 29.56% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-14.2.6 29.56% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-14.2.7 29.56% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-15.0.0 29.56% <6.81%> (-0.07%) ⬇️
appsec-next-oldest-latest 27.79% <0.00%> (ø)
appsec-node-serialize 39.23% <27.86%> (-0.11%) ⬇️
appsec-passport 42.89% <26.31%> (-0.13%) ⬇️
appsec-postgres 47.00% <30.95%> (-0.08%) ⬇️
appsec-sourcing 38.68% <24.14%> (-0.12%) ⬇️
appsec-stripe 40.58% <26.31%> (-0.13%) ⬇️
appsec-template 39.45% <27.86%> (-0.11%) ⬇️
appsec-ubuntu 57.41% <28.48%> (-0.12%) ⬇️
appsec-windows 57.26% <28.48%> (-0.14%) ⬇️
debugger-ubuntu-active 43.81% <28.34%> (+0.02%) ⬆️
debugger-ubuntu-latest ?
debugger-ubuntu-maintenance 43.88% <28.34%> (+0.02%) ⬆️
debugger-ubuntu-oldest 44.19% <28.34%> (+0.02%) ⬆️
instrumentations-instrumentation-ai 32.35% <ø> (ø)
instrumentations-instrumentation-aws-sdk 35.50% <ø> (ø)
instrumentations-instrumentation-bluebird 27.59% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-body-parser 35.79% <24.14%> (-0.13%) ⬇️
instrumentations-instrumentation-child_process 33.56% <25.69%> (-0.10%) ⬇️
instrumentations-instrumentation-cookie-parser 29.51% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-couchbase-18 36.81% <ø> (ø)
instrumentations-instrumentation-couchbase-eol 36.81% <ø> (ø)
instrumentations-instrumentation-crypto 27.67% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-express ?
instrumentations-instrumentation-express-mongo-sanitize 29.62% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-express-multi-version 20.97% <ø> (ø)
instrumentations-instrumentation-express-session 35.55% <24.14%> (-0.12%) ⬇️
instrumentations-instrumentation-fastify 39.70% <ø> (ø)
instrumentations-instrumentation-fetch 33.05% <ø> (ø)
instrumentations-instrumentation-fs 27.32% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-generic-pool 27.11% <0.00%> (ø)
instrumentations-instrumentation-hono 28.81% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-http 35.29% <25.69%> (-0.09%) ⬇️
instrumentations-instrumentation-http-client-options 37.79% <24.14%> (-0.13%) ⬇️
instrumentations-instrumentation-kafkajs 48.79% <0.00%> (ø)
instrumentations-instrumentation-knex ?
instrumentations-instrumentation-light-my-request 35.40% <24.14%> (-0.12%) ⬇️
instrumentations-instrumentation-mongoose 28.70% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-multer 35.47% <24.14%> (-0.12%) ⬇️
instrumentations-instrumentation-mysql2 33.52% <24.14%> (-0.13%) ⬇️
instrumentations-instrumentation-openai-aiguard 42.77% <ø> (ø)
instrumentations-instrumentation-otel-sdk-trace 25.23% <0.00%> (ø)
instrumentations-instrumentation-passport 39.29% <26.31%> (-0.13%) ⬇️
instrumentations-instrumentation-passport-http 39.00% <26.31%> (-0.13%) ⬇️
instrumentations-instrumentation-passport-local 39.46% <26.31%> (-0.13%) ⬇️
instrumentations-instrumentation-pg 33.23% <26.62%> (-0.09%) ⬇️
instrumentations-instrumentation-promise 27.54% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-promise-js 27.54% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-q 27.57% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-router 34.98% <ø> (ø)
instrumentations-instrumentation-stripe 28.08% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-url 27.50% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-when 27.55% <6.81%> (-0.07%) ⬇️
instrumentations-instrumentation-zlib 27.55% <6.81%> (-0.07%) ⬇️
instrumentations-integration-esbuild-0.16.12-active 18.50% <0.00%> (ø)
instrumentations-integration-esbuild-0.16.12-latest 18.50% <0.00%> (ø)
instrumentations-integration-esbuild-0.16.12-maintenance 18.52% <0.00%> (ø)
instrumentations-integration-esbuild-0.16.12-oldest 18.51% <0.00%> (ø)
instrumentations-integration-esbuild-latest-active 18.50% <0.00%> (ø)
instrumentations-integration-esbuild-latest-latest 18.50% <0.00%> (ø)
instrumentations-integration-esbuild-latest-maintenance ?
instrumentations-integration-esbuild-latest-oldest 18.51% <0.00%> (ø)
llmobs-ai 36.37% <24.14%> (-0.12%) ⬇️
llmobs-anthropic 36.68% <24.14%> (-0.13%) ⬇️
llmobs-bedrock 35.39% <24.14%> (-0.11%) ⬇️
llmobs-google-genai 35.73% <24.14%> (-0.12%) ⬇️
llmobs-langchain 34.79% <25.69%> (-0.07%) ⬇️
llmobs-openai-latest ?
llmobs-openai-oldest 39.19% <24.14%> (-0.12%) ⬇️
llmobs-sdk-active 43.56% <25.69%> (-0.11%) ⬇️
llmobs-sdk-latest 43.56% <25.69%> (-0.11%) ⬇️
llmobs-sdk-maintenance 43.60% <25.69%> (-0.11%) ⬇️
llmobs-sdk-oldest 43.59% <25.69%> (-0.11%) ⬇️
llmobs-vertex-ai 35.72% <24.14%> (-0.12%) ⬇️
master-coverage 93.24% <100.00%> (?)
openfeature-macos 37.67% <23.98%> (-0.05%) ⬇️
openfeature-ubuntu 37.75% <23.98%> (-0.05%) ⬇️
openfeature-unit-active 50.65% <0.00%> (ø)
openfeature-unit-latest 50.65% <0.00%> (ø)
openfeature-unit-maintenance 50.77% <0.00%> (ø)
openfeature-unit-oldest 50.77% <0.00%> (ø)
openfeature-windows 37.49% <23.98%> (-0.05%) ⬇️
platform-core 31.85% <ø> (ø)
platform-esbuild 36.42% <ø> (ø)
platform-instrumentations-misc 30.37% <6.54%> (+0.01%) ⬆️
platform-integration-active 46.90% <26.47%> (?)
platform-integration-latest 46.94% <28.66%> (-0.07%) ⬇️
platform-integration-maintenance 46.99% <28.66%> (?)
platform-integration-oldest 47.15% <28.66%> (-0.02%) ⬇️
platform-shimmer 40.18% <ø> (ø)
platform-unit-guardrails 32.72% <ø> (ø)
platform-webpack 18.03% <0.00%> (ø)
plugins-axios 35.61% <23.98%> (-0.06%) ⬇️
plugins-azure-cosmos 35.90% <25.69%> (-0.10%) ⬇️
plugins-azure-event-hubs 34.85% <23.98%> (-0.05%) ⬇️
plugins-azure-service-bus 35.33% <23.98%> (-0.05%) ⬇️
plugins-body-parser 36.54% <23.98%> (-0.05%) ⬇️
plugins-bullmq 39.16% <46.13%> (+0.04%) ⬆️
plugins-cassandra 33.71% <25.69%> (-0.25%) ⬇️
plugins-cookie 25.16% <ø> (ø)
plugins-cookie-parser 24.91% <ø> (ø)
plugins-crypto 24.70% <ø> (ø)
plugins-dd-trace-api 33.42% <24.14%> (-0.13%) ⬇️
plugins-express-mongo-sanitize 25.16% <ø> (ø)
plugins-express-session 24.83% <ø> (ø)
plugins-fastify 37.83% <25.69%> (-0.09%) ⬇️
plugins-fetch 34.21% <24.14%> (-0.12%) ⬇️
plugins-fs 33.80% <24.14%> (-0.13%) ⬇️
plugins-generic-pool 23.87% <ø> (ø)
plugins-google-cloud-pubsub 41.52% <50.15%> (+0.03%) ⬆️
plugins-grpc 36.61% <25.69%> (-0.09%) ⬇️
plugins-handlebars 25.12% <ø> (ø)
plugins-hapi 35.67% <25.69%> (-0.09%) ⬇️
plugins-hono 36.00% <25.69%> (-0.09%) ⬇️
plugins-ioredis 34.26% <24.14%> (-0.13%) ⬇️
plugins-jest 27.04% <0.00%> (ø)
plugins-knex 24.84% <ø> (ø)
plugins-langgraph 32.42% <24.14%> (-0.12%) ⬇️
plugins-ldapjs 22.49% <ø> (ø)
plugins-light-my-request 24.56% <ø> (ø)
plugins-limitd-client 27.96% <6.81%> (-0.01%) ⬇️
plugins-lodash 24.06% <ø> (ø)
plugins-mariadb 35.23% <25.69%> (-0.10%) ⬇️
plugins-memcached 33.78% <25.69%> (-0.09%) ⬇️
plugins-microgateway-core 34.77% <25.69%> (-0.09%) ⬇️
plugins-modelcontextprotocol-sdk ?
plugins-moleculer 36.69% <25.69%> (?)
plugins-mongodb 35.83% <27.86%> (-0.18%) ⬇️
plugins-mongodb-core 35.51% <27.86%> (-0.10%) ⬇️
plugins-mongoose 34.60% <25.69%> (+0.06%) ⬆️
plugins-multer 24.87% <ø> (ø)
plugins-mysql 34.55% <27.86%> (-0.24%) ⬇️
plugins-mysql2 34.97% <27.86%> (?)
plugins-nats 36.43% <25.69%> (-0.10%) ⬇️
plugins-node-serialize 25.20% <ø> (ø)
plugins-opensearch 33.65% <24.14%> (-0.12%) ⬇️
plugins-passport-http 24.80% <ø> (ø)
plugins-pino 29.83% <6.81%> (?)
plugins-process 24.70% <ø> (ø)
plugins-pug 25.16% <ø> (ø)
plugins-redis 34.32% <25.69%> (-0.09%) ⬇️
plugins-router 38.18% <24.14%> (-0.13%) ⬇️
plugins-sequelize 23.75% <ø> (ø)
plugins-test-and-upstream-amqp10 33.92% <25.69%> (-0.09%) ⬇️
plugins-test-and-upstream-amqplib 39.31% <46.13%> (-0.07%) ⬇️
plugins-test-and-upstream-apollo 34.88% <25.69%> (-0.09%) ⬇️
plugins-test-and-upstream-avsc 33.82% <24.14%> (-0.13%) ⬇️
plugins-test-and-upstream-bunyan 29.24% <6.81%> (+0.13%) ⬆️
plugins-test-and-upstream-connect 36.31% <25.69%> (?)
plugins-test-and-upstream-graphql 36.11% <25.69%> (-0.10%) ⬇️
plugins-test-and-upstream-koa 35.85% <25.69%> (-0.09%) ⬇️
plugins-test-and-upstream-protobufjs 34.05% <25.69%> (-0.10%) ⬇️
plugins-test-and-upstream-rhea 39.18% <46.13%> (-0.17%) ⬇️
plugins-undici 34.70% <24.14%> (-0.12%) ⬇️
plugins-url 24.70% <ø> (ø)
plugins-valkey 33.89% <24.14%> (-0.13%) ⬇️
plugins-vm 24.70% <ø> (ø)
plugins-winston 29.70% <6.81%> (-0.07%) ⬇️
plugins-ws 37.14% <24.14%> (-0.13%) ⬇️
profiling-macos 43.26% <23.98%> (-0.12%) ⬇️
profiling-ubuntu 43.59% <23.98%> (-0.18%) ⬇️
profiling-windows 41.01% <23.98%> (-0.12%) ⬇️
serverless-aws-sdk-latest-aws-sdk 33.63% <25.69%> (-0.08%) ⬇️
serverless-aws-sdk-latest-bedrockruntime 31.73% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-latest-client 20.28% <ø> (?)
serverless-aws-sdk-latest-dynamodb 34.32% <25.69%> (-0.03%) ⬇️
serverless-aws-sdk-latest-eventbridge 27.10% <6.54%> (-0.06%) ⬇️
serverless-aws-sdk-latest-kinesis 37.46% <46.13%> (-0.06%) ⬇️
serverless-aws-sdk-latest-lambda 34.81% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-latest-s3 32.66% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-latest-serverless-peer-service 39.88% <46.13%> (-0.07%) ⬇️
serverless-aws-sdk-latest-sns 38.60% <46.13%> (?)
serverless-aws-sdk-latest-sqs 38.09% <44.58%> (-0.03%) ⬇️
serverless-aws-sdk-latest-stepfunctions 33.37% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-latest-util 47.00% <0.00%> (ø)
serverless-aws-sdk-oldest-aws-sdk 33.71% <25.69%> (?)
serverless-aws-sdk-oldest-bedrockruntime 31.99% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-oldest-client 20.67% <ø> (ø)
serverless-aws-sdk-oldest-dynamodb 34.37% <25.69%> (-0.03%) ⬇️
serverless-aws-sdk-oldest-eventbridge ?
serverless-aws-sdk-oldest-kinesis 37.59% <46.13%> (-0.06%) ⬇️
serverless-aws-sdk-oldest-lambda 34.87% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-oldest-s3 32.75% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-oldest-serverless-peer-service 39.92% <46.13%> (-0.07%) ⬇️
serverless-aws-sdk-oldest-sns 38.66% <46.13%> (-0.18%) ⬇️
serverless-aws-sdk-oldest-sqs ?
serverless-aws-sdk-oldest-stepfunctions 33.43% <24.14%> (-0.11%) ⬇️
serverless-aws-sdk-oldest-util 47.26% <0.00%> (ø)
serverless-azure-durable-functions ?
serverless-azure-functions-eventhubs 38.44% <23.98%> (-0.05%) ⬇️
serverless-azure-functions-servicebus 38.50% <23.98%> (-0.05%) ⬇️
serverless-lambda 34.56% <25.69%> (-0.11%) ⬇️
test-optimization-cucumber-latest-7.0.0 50.48% <42.40%> (-0.01%) ⬇️
test-optimization-cucumber-latest-latest 53.28% <42.40%> (+0.09%) ⬆️
test-optimization-cucumber-oldest-7.0.0 50.52% <42.40%> (+0.09%) ⬆️
test-optimization-cypress-latest-12.0.0-commonJS 49.60% <45.27%> (+0.41%) ⬆️
test-optimization-cypress-latest-12.0.0-esm 49.12% <45.27%> (-0.44%) ⬇️
test-optimization-cypress-latest-14.5.4-commonJS 49.45% <45.27%> (+0.07%) ⬆️
test-optimization-cypress-latest-14.5.4-esm 49.48% <45.27%> (+0.07%) ⬆️
test-optimization-cypress-latest-latest-commonJS 49.88% <45.27%> (+0.01%) ⬆️
test-optimization-cypress-latest-latest-esm 49.97% <45.27%> (+0.07%) ⬆️
test-optimization-cypress-oldest-12.0.0-commonJS 49.61% <45.27%> (+0.04%) ⬆️
test-optimization-cypress-oldest-12.0.0-esm 46.37% <34.67%> (-2.96%) ⬇️
test-optimization-cypress-oldest-14.5.4-commonJS 49.49% <45.27%> (+0.34%) ⬆️
test-optimization-cypress-oldest-14.5.4-esm 49.24% <45.27%> (-0.20%) ⬇️
test-optimization-jest-latest-latest 55.61% <46.70%> (+0.10%) ⬆️
test-optimization-jest-latest-oldest 54.52% <46.70%> (+0.10%) ⬆️
test-optimization-jest-oldest-latest 53.09% <45.27%> (-2.42%) ⬇️
test-optimization-jest-oldest-oldest 54.48% <46.70%> (+2.38%) ⬆️
test-optimization-mocha-latest-latest 53.80% <42.97%> (+0.07%) ⬆️
test-optimization-mocha-latest-oldest 51.48% <42.97%> (+0.07%) ⬆️
test-optimization-mocha-oldest-latest 53.86% <48.99%> (+0.07%) ⬆️
test-optimization-mocha-oldest-oldest 51.34% <42.97%> (+<0.01%) ⬆️
test-optimization-playwright-latest-latest-playwright-active-test-span 44.46% <28.65%> (+0.22%) ⬆️
test-optimization-playwright-latest-latest-playwright-atr 43.13% <29.22%> (+0.07%) ⬆️
test-optimization-playwright-latest-latest-playwright-efd 43.53% <30.37%> (+0.08%) ⬆️
test-optimization-playwright-latest-latest-playwright-final-status 43.58% <29.22%> (+0.07%) ⬆️
test-optimization-playwright-latest-latest-playwright-impacted-tests 43.06% <29.22%> (-0.04%) ⬇️
test-optimization-playwright-latest-latest-playwright-reporting 43.15% <28.65%> (+0.02%) ⬆️
test-optimization-playwright-latest-latest-playwright-test-management 44.74% <30.65%> (+0.09%) ⬆️
test-optimization-playwright-latest-oldest-playwright-active-test-span 44.29% <29.22%> (+0.23%) ⬆️
test-optimization-playwright-latest-oldest-playwright-atr 43.22% <30.37%> (+0.07%) ⬆️
test-optimization-playwright-latest-oldest-playwright-efd 43.45% <29.22%> (?)
test-optimization-playwright-latest-oldest-playwright-final-status 43.52% <29.22%> (+0.07%) ⬆️
test-optimization-playwright-latest-oldest-playwright-impacted-tests 42.99% <29.22%> (-0.04%) ⬇️
test-optimization-playwright-latest-oldest-playwright-reporting 42.97% <29.22%> (+0.05%) ⬆️
test-optimization-playwright-latest-oldest-playwright-test-management 44.70% <30.65%> (+0.06%) ⬆️
test-optimization-playwright-oldest-latest-playwright-active-test-span 44.33% <28.65%> (-0.13%) ⬇️
test-optimization-playwright-oldest-latest-playwright-atr 43.16% <29.22%> (+0.05%) ⬆️
test-optimization-playwright-oldest-latest-playwright-efd 43.54% <29.22%> (+0.05%) ⬆️
test-optimization-playwright-oldest-latest-playwright-final-status 43.63% <30.37%> (+0.05%) ⬆️
test-optimization-playwright-oldest-latest-playwright-impacted-tests 43.09% <29.22%> (-0.04%) ⬇️
test-optimization-playwright-oldest-latest-playwright-reporting 43.18% <28.65%> (+0.04%) ⬆️
test-optimization-playwright-oldest-latest-playwright-test-management 44.78% <30.65%> (+0.05%) ⬆️
test-optimization-playwright-oldest-oldest-playwright-active-test-span 44.33% <29.22%> (+0.23%) ⬆️
test-optimization-playwright-oldest-oldest-playwright-atr 43.26% <30.37%> (?)
test-optimization-playwright-oldest-oldest-playwright-efd 43.49% <29.22%> (+0.05%) ⬆️
test-optimization-playwright-oldest-oldest-playwright-final-status 43.56% <29.22%> (+0.05%) ⬆️
test-optimization-playwright-oldest-oldest-playwright-impacted-tests ?
test-optimization-playwright-oldest-oldest-playwright-reporting 43.01% <29.22%> (+0.05%) ⬆️
test-optimization-playwright-oldest-oldest-playwright-test-management 44.74% <30.65%> (+0.06%) ⬆️
test-optimization-selenium-latest ?
test-optimization-selenium-oldest 45.03% <32.09%> (+0.02%) ⬆️
test-optimization-testopt-active 48.72% <44.69%> (+0.12%) ⬆️
test-optimization-testopt-latest 48.72% <44.69%> (+0.12%) ⬆️
test-optimization-testopt-maintenance 48.76% <44.69%> (+0.12%) ⬆️
test-optimization-testopt-oldest 49.70% <44.69%> (+0.09%) ⬆️
test-optimization-vitest-latest 51.02% <34.67%> (+0.06%) ⬆️
test-optimization-vitest-oldest 48.17% <34.38%> (+0.28%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pr-commenter

pr-commenter Bot commented May 16, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-05-29 14:27:07

Comparing candidate commit 692ce3b in PR branch BridgeAR/2026-05-14-perf-encode-stringmap with baseline commit 130446e in branch master.

Found 57 performance improvements and 5 performance regressions! Performance is the same for 1435 metrics, 96 unstable metrics.

scenario:encoders-0.4-20

  • 🟩 cpu_user_time [-521.381ms; -494.056ms] or [-48.809%; -46.251%]
  • 🟩 execution_time [-1.641s; -1.617s] or [-70.801%; -69.754%]
  • 🟩 instructions [-2.5G instructions; -2.5G instructions] or [-36.348%; -35.854%]
  • 🟩 max_rss_usage [-64.663MB; -62.093MB] or [-25.578%; -24.562%]

scenario:encoders-0.4-22

  • 🟩 cpu_user_time [-528.177ms; -513.153ms] or [-54.161%; -52.621%]
  • 🟩 execution_time [-1.657s; -1.647s] or [-74.033%; -73.599%]
  • 🟩 instructions [-2.7G instructions; -2.6G instructions] or [-42.131%; -41.652%]
  • 🟩 max_rss_usage [-64.880MB; -62.603MB] or [-24.645%; -23.781%]

scenario:encoders-0.4-24

  • 🟩 cpu_user_time [-556.378ms; -530.764ms] or [-55.905%; -53.331%]
  • 🟩 execution_time [-1.679s; -1.664s] or [-74.523%; -73.866%]
  • 🟩 instructions [-2.6G instructions; -2.6G instructions] or [-42.113%; -41.708%]
  • 🟩 max_rss_usage [-64.084MB; -62.954MB] or [-24.003%; -23.580%]

scenario:encoders-0.4-events-legacy-20

  • 🟩 cpu_user_time [-1.454s; -1.407s] or [-51.355%; -49.714%]
  • 🟩 execution_time [-4.609s; -4.554s] or [-74.023%; -73.143%]
  • 🟩 instructions [-7.3G instructions; -7.3G instructions] or [-41.933%; -41.708%]
  • 🟩 max_rss_usage [-64.595MB; -61.223MB] or [-16.210%; -15.363%]

scenario:encoders-0.4-events-legacy-22

  • 🟩 cpu_user_time [-1.457s; -1.422s] or [-53.033%; -51.762%]
  • 🟩 execution_time [-4.616s; -4.567s] or [-75.120%; -74.325%]
  • 🟩 instructions [-7.5G instructions; -7.5G instructions] or [-44.539%; -44.297%]
  • 🟩 max_rss_usage [-67.720MB; -65.612MB] or [-16.389%; -15.879%]

scenario:encoders-0.4-events-legacy-24

  • 🟩 cpu_user_time [-1.426s; -1.351s] or [-53.067%; -50.249%]
  • 🟩 execution_time [-4.550s; -4.477s] or [-75.049%; -73.855%]
  • 🟩 instructions [-7.2G instructions; -7.2G instructions] or [-43.621%; -43.287%]
  • 🟩 max_rss_usage [-63.692MB; -61.919MB] or [-15.428%; -14.998%]

scenario:encoders-0.4-events-native-20

  • 🟩 cpu_user_time [-2.131s; -2.049s] or [-67.426%; -64.826%]
  • 🟩 execution_time [-7.194s; -7.076s] or [-85.158%; -83.758%]
  • 🟩 instructions [-11.8G instructions; -11.6G instructions] or [-57.384%; -56.496%]
  • 🟩 max_rss_usage [-155.252MB; -153.025MB] or [-32.206%; -31.744%]

scenario:encoders-0.4-events-native-22

  • 🟩 cpu_user_time [-2.142s; -2.066s] or [-71.453%; -68.945%]
  • 🟩 execution_time [-7.229s; -7.090s] or [-87.188%; -85.510%]
  • 🟩 instructions [-12.0G instructions; -11.9G instructions] or [-62.007%; -61.415%]
  • 🟩 max_rss_usage [-155.671MB; -153.091MB] or [-31.708%; -31.183%]

scenario:encoders-0.4-events-native-24

  • 🟩 cpu_user_time [-2.176s; -2.091s] or [-71.919%; -69.106%]
  • 🟩 execution_time [-7.268s; -7.108s] or [-87.330%; -85.399%]
  • 🟩 instructions [-11.9G instructions; -11.9G instructions] or [-62.163%; -61.900%]
  • 🟩 max_rss_usage [-155.030MB; -153.293MB] or [-31.318%; -30.967%]

scenario:encoders-0.5-20

  • 🟩 cpu_user_time [-69.653ms; -57.309ms] or [-17.971%; -14.786%]
  • 🟩 execution_time [-186.688ms; -178.828ms] or [-32.527%; -31.157%]
  • 🟩 instructions [-498.2M instructions; -468.8M instructions] or [-17.188%; -16.175%]

scenario:encoders-0.5-22

  • 🟩 cpu_user_time [-66.477ms; -54.480ms] or [-17.595%; -14.419%]
  • 🟩 execution_time [-188.889ms; -181.474ms] or [-32.763%; -31.477%]
  • 🟩 instructions [-493.1M instructions; -467.9M instructions] or [-17.209%; -16.330%]

scenario:encoders-0.5-24

  • 🟩 cpu_user_time [-84.406ms; -64.409ms] or [-22.512%; -17.179%]
  • 🟩 execution_time [-200.822ms; -187.719ms] or [-35.419%; -33.108%]
  • 🟩 instructions [-728.1M instructions; -638.2M instructions] or [-24.508%; -21.481%]

scenario:encoders-0.5-events-legacy-20

  • 🟩 cpu_user_time [-103.889ms; -89.025ms] or [-8.787%; -7.530%]
  • 🟩 execution_time [-227.849ms; -216.407ms] or [-16.358%; -15.536%]
  • 🟩 instructions [-524.7M instructions; -503.1M instructions] or [-6.363%; -6.101%]
  • 🟩 max_rss_usage [-37.550MB; -32.148MB] or [-22.193%; -19.000%]

scenario:encoders-0.5-events-legacy-22

  • 🟩 cpu_user_time [-128.826ms; -111.480ms] or [-10.808%; -9.353%]
  • 🟩 execution_time [-250.473ms; -238.606ms] or [-17.799%; -16.955%]
  • 🟩 instructions [-555.2M instructions; -534.1M instructions] or [-6.699%; -6.444%]
  • 🟩 max_rss_usage [-19.233MB; -11.875MB] or [-11.906%; -7.351%]

scenario:encoders-0.5-events-legacy-24

  • 🟩 cpu_user_time [-146.076ms; -129.748ms] or [-12.393%; -11.008%]
  • 🟩 execution_time [-267.239ms; -256.652ms] or [-19.125%; -18.368%]
  • 🟩 instructions [-794.1M instructions; -728.4M instructions] or [-9.556%; -8.766%]

scenario:exporting-pipeline-0.5-22

  • 🟥 cpu_user_time [+12.051ms; +14.564ms] or [+8.760%; +10.587%]
  • 🟥 execution_time [+12.125ms; +13.598ms] or [+7.361%; +8.256%]

scenario:exporting-pipeline-0.5-24

  • 🟥 cpu_user_time [+8.076ms; +10.813ms] or [+6.575%; +8.803%]

scenario:log-with-debug-20

  • 🟥 instructions [+195.4M instructions; +396.0M instructions] or [+5.620%; +11.387%]

scenario:spans-finish-later-24

  • 🟥 cpu_user_time [+38.304ms; +49.133ms] or [+6.171%; +7.915%]
  • 🟩 max_rss_usage [-55.059MB; -45.265MB] or [-16.458%; -13.531%]

@BridgeAR
BridgeAR force-pushed the BridgeAR/2026-05-14-perf-encode-stringmap branch from 8c4e1ab to 524e45a Compare May 27, 2026 13:05
@BridgeAR BridgeAR changed the title perf(encode): pre-warm _stringMap for well-known span tag keys perf(encode): consolidate the msgpack hot path May 27, 2026
@BridgeAR
BridgeAR force-pushed the BridgeAR/2026-05-14-perf-encode-stringmap branch from 524e45a to 0355b5e Compare May 27, 2026 15:28
@BridgeAR
BridgeAR force-pushed the BridgeAR/2026-05-14-perf-encode-stringmap branch from 0355b5e to d382d2a Compare May 27, 2026 18:06
Comment thread packages/dd-trace/src/encode/0.4.js Outdated
Comment thread packages/dd-trace/src/encode/0.5.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0064e4dda5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/dd-trace/src/encode/0.4.js Outdated
Comment thread packages/dd-trace/src/msgpack/chunk.js Outdated
@BridgeAR
BridgeAR force-pushed the BridgeAR/2026-05-14-perf-encode-stringmap branch from 0064e4d to 5b7f4bd Compare May 27, 2026 20:34
@BridgeAR
BridgeAR marked this pull request as draft May 27, 2026 20:48
@BridgeAR
BridgeAR marked this pull request as ready for review May 27, 2026 21:10
@BridgeAR
BridgeAR force-pushed the BridgeAR/2026-05-14-perf-encode-stringmap branch from 5b7f4bd to 7050b68 Compare May 27, 2026 21:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b7f4bddcd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/dd-trace/src/msgpack/chunk.js
BridgeAR added 6 commits May 28, 2026 17:16
Four small wins on the encode and debug-log paths:

1. `MsgpackEncoder#encodeValue` dispatches `string` first. The string
   arm covers tag values and map keys; the prior order paid four
   `switch` comparisons before reaching it.
2. `MsgpackEncoder#encodeMap` emits keys via `encodeString` directly.
   `Object.keys` only yields strings, so the typeof re-dispatch is
   dead work.
3. `AgentEncoder.encode` hoists the `DD_TRACE_ENCODING_DEBUG`
   hex-dump formatter to a module-level function. The closure used
   to allocate per encode whenever the debug switch was on.
4. `memoizedLogDebug` switches to printf-style. The substitution only
   runs when the debug channel has subscribers.

`log` now forwards trailing arguments to a function delegate so the
formatter in (3) can take `bytes`, `start`, `end` as parameters
instead of capturing them via closure.
The tracer dispatched every byte-layout write through three layers:
`subclass._encodeX(bytes, value)` → the base wrapper → the
`MsgpackEncoder` instance. The wrappers existed so msgpack could be
swapped at runtime, which never happened and was never planned.

`MsgpackChunk` now owns the byte-layout primitives directly. Call
sites encode straight into the chunk; the eight base-class wrappers
and the `MsgpackEncoder` class go away. `DataStreamsWriter`, the one
external caller of the deleted `msgpack.encode(payload)` method,
imports the free `encode` function that exposes the same recursive
dispatcher.
Initial capacity drops from 2 MiB to 1 MiB. Each `AgentEncoder` holds
two `MsgpackChunk` instances and the datastreams writer adds one more,
so the previous default cost every tracer-loaded process a fixed
4 - 6 MiB regardless of payload size. A representative HTTP trace
serializes well under 100 KiB, but keeping a megabyte of headroom
avoids the first resize on bursts of larger spans (long URLs, JSON
event payloads) so the typical request never copies through an
intermediate buffer.

`reserve` doubles the capacity on overflow instead of rounding up to
the next `minSize` multiple. Worst-case burst growth from 1 MiB to
the 8 MiB soft flush limit takes the same number of resizes as the
old 2 MiB-aligned walk but reaches the larger sizes earlier, cutting
the time spent copying through intermediate buffers.

`reset` is the new flush signal: it zeros the cursor and counts
consecutive flushes whose peak length stayed under a quarter of the
current buffer. After 32 such quiet flushes the chunk halves toward
the `minSize` floor. One above-threshold peak resets the streak, so
long-lived encoders give memory back during idle periods but hold
on to the warmed buffer through sustained traffic.
Every encoded span emits a `[KEY_ERROR][writeIntOrFloat(span.error)]`
pair, and `span.error` is `0` or `1` on the overwhelming majority of
spans — only a handful of plugins use any other numeric value, and the
tracer never stores anything else there. The hot loop therefore paid
two `MsgpackChunk.reserve` calls, two memory writes, and one trip
through `writeIntOrFloat`'s magnitude-detection branches for what is
effectively a constant on every span.

Two precomputed `[KEY_ERROR, fixint]` constants (7 bytes each) collapse
the common case to a single `bytes.set`. The else branch keeps the
existing variable-value path so the rare non-{0,1} values still emit
the shortest valid encoding.
`_encodeString` and `#encodeMetaEntries` looked up every value in
`_stringMap` before emitting it, including the multi-KiB strings the
tracer produces from stringified `span_events`, stack traces, full SQL
statements, and large query bodies. Those values are essentially
unique per span on real traffic, so the cache hit rate is near zero
and the lookup cost is paid for nothing. The
`encoders-0.4-events-legacy` sirun scenario picked the regression up
on a realistic trace fixture.

Values longer than `STRING_CACHE_BYPASS_LIMIT` (1 KiB) now write
directly through `MsgpackChunk.write`. The threshold sits well above
the routine span-tag distribution (URLs, methods, IPs, resource names
all comfortably below 256 bytes) and well below the size where the
per-call hash dominates the per-call write. Short, repeating tag
values still hit the cache and pay nothing.

Bench (`benchmark/sirun/encoding/index.js` adapted: 30-span trace,
5 000 iterations, 7 trials drop best+worst; Node 24.15.0):

* `encoder=0.4 events=legacy` master 324 ms -> patched 341 ms
  (was 346 ms before this commit, -1.5 %)
* `encoder=0.4 events=none`   master  50 ms -> patched  47 ms (-7.6 %)
* `encoder=0.5 events=none`   master  30 ms -> patched  29 ms (-5.5 %)

`encoder=0.5 events=legacy` still regresses because the 0.5 wire ships
strings as indices and cannot bypass the lookup.

Wire output is unchanged: `bytes.write` emits the same fixstr / str32
bytes that `_cacheString` would have stored, just without the cache
side effect.
The flush-on-soft-limit test was reaching into `encoder._estimatedSize`
to push the encoder over the threshold. The reach-in only existed
because the 8 MiB soft limit was a module-local constant, so the test
had no other way to trigger the branch without actually encoding a
multi-megabyte payload.

Add a `softLimit` constructor parameter to `AgentlessJSONEncoder` —
mirroring the same hook on the v0.4/v0.5 `AgentEncoder` — and rewrite
the test to construct a tiny-limit encoder. The reach-in goes away, the
soft-limit branch is now reachable from public API for any caller that
wants to bound payload size for tests or for memory-constrained
environments, and production behavior is unchanged when the parameter
is omitted.
BridgeAR and others added 9 commits May 28, 2026 17:18
…ion keys

Almost every real span carries `error: 0` / `error: 1` and a nanosecond
`start` ≥ 2³². When both hold the encoder can fuse the error key+fixint,
the `start` key + 0xCF type byte + 8-byte timestamp, and the `duration`
key into the same `bytes.reserve` that already covers the map header,
optional `type`, the three IDs, and name/resource/service. The new path
collapses up to four extra reserves per span (one each for the error
key+value, the start key, the start value's u64 dispatch, and the
duration key) into the existing one.

Spans that don't fit the assumption (synthetic test data with small
`start`, rare non-binary error flags) drop back to the per-field emits
so each integer still picks the shortest msgpack encoding — keeping the
existing wire-format guarantees for those inputs.

`KEY_START_PREFIX` is the precomputed `[KEY_START, 0xCF]` fusion. The
8-byte timestamp lands via two `Buffer.writeUInt32BE` calls; the hi/lo
split matches what `writeLong` would have done.
The numeric branch of the meta-map writer used to pay two `reserve`
calls per entry: one for the key prefix, one inside `writeIntOrFloat`
for the value byte. The metrics map is mostly small positive integers
(`_sampling_priority_v1`, `_dd.measured`, attribute counts, ms timings
that fit in 0..127), so every one of those entries hits the fixint
fast path inside `writeIntOrFloat` anyway — but only after paying a
second reserve and a three-level branch chain.

Speculate the fixint case at the call site: reserve `keyEntryLen + 1`
up front, copy the key, and write the value byte directly. When the
speculation misses (large counts, floats, signed numbers), rewind the
speculative byte and fall back to the full writer so the wire still
picks the shortest valid encoding.
Both formatters re-stringify `span_events` from scratch on every encode
call even when the underlying event array survives across encode cycles
(retries, re-emission paths). The events=legacy hot path is currently
bound by raw memory bandwidth from those re-stringifications, not the
cache lookups.

Leave the WeakMap memoization out of this branch — it interacts with
the formatter / span lifecycle in ways that warrant their own change.
Drop a pointer at the two call sites so the next reader knows where the
remaining headroom lives. No production behavior change.
Co-authored-by: Ruben Bridgewater <[email protected]>
`writeFixMap` has no caller -- `_encodeMap` and the v0.5 encoder go
through `writeMapPrefix` (map32) regardless of size. Same shape for
`writeFixArray`'s `= 0` default; its only caller passes the length
explicitly.
Pin the patch lines codecov flagged as uncovered:

1. `MsgpackChunk.writeNull` / `writeBoolean` / `writeBin` (bin32 path
   for byteLength >= 65 536) / `writeSigned` (int8 the encoder never
   reaches via `writeIntOrFloat`) / `writeNumber` NaN coercion. The
   spec exercises the rest of the public `writeX` surface in the same
   pass so the boundaries between fixint / uint8 / uint16 / uint32 /
   uint64 and the signed counterparts are pinned.
2. `msgpack/encode` dispatcher arms for `null`, `boolean`, `symbol`,
   the `default` arm (function / undefined), and `array.length >= 16`
   (array32 prefix).
3. `AgentEncoder._encode`'s non-fuseTail fallback for `error === 1`
   and unusual error flags -- the synthetic-input path the comment
   on the fused-tail block calls out.
Every v0.5 span used to pay seven separate `bytes.reserve` calls for
its fixed-size prefix: one for the `0x9C` fixarray marker, three for
the service / name / resource indices, and three for the trace / span
/ parent uint64 ids. Pre-resolve the three string indices, then write
the whole 43-byte head block (1 + 5 × 3 + 9 × 3) under one reserve.

The v0.5 wire is positional — no map keys — so the fuse is simpler
than the v0.4 equivalent: no key bytes to concatenate, and the four
fixed-shape elements (marker + three indices + three ids) tile the
block exactly. The remaining per-span fields (`start`, `duration`,
`error`, `meta`, `metrics`, `type`) keep their per-call writes because
their byte sizes depend on the value.

While here, refactor `_cacheString` so it returns the resolved index
instead of just having the side effect. The head fuse uses
`stringMap[value] ?? this._cacheString(value)` to resolve indices
without re-fetching, and `_encodeString` collapses onto the same
pattern. `_reset()` (inherited from 0.4) calls `_cacheString('')` for
the seeding side effect and ignores the return value — no behavior
change there.

Two new private helpers (`#writeIndexAt`, `#writeIdAt`) keep the
fused block readable; both are inlinable single-shot writes the
encoder code calls directly into the pre-reserved buffer.

Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0; same fixture on both checkouts, only the encoder source
differs):

* `encoder=0.5 events=none`   master  98 ms -> patched  77 ms (-21 %)
* `encoder=0.5 events=legacy` master 395 ms -> patched 366 ms ( -7 %)
* `encoder=0.4 *` unchanged within noise (the v0.4 hot path is not
  touched).
The v0.5 `_encodeMap` override used to pay two `bytes.reserve` calls
per numeric entry — one for the key (5-byte uint32 index) and one
inside `writeIntOrFloat` for the value byte. The metrics map is mostly
small positive integers (`_sampling_priority_v1`, `_dd.measured`,
attribute counts), so every one of those entries hits the fixint fast
path inside `writeIntOrFloat` anyway, but only after paying that
second reserve and a three-level branch chain.

Speculate the fixint case at the call site: reserve `5 + 1` up front,
write the key index, then write the value byte directly. Speculation
misses rewind the speculative byte and fall back to the full encoder
so the wire still picks the shortest valid encoding. Mirrors the same
move the v0.4 `#encodeMetaEntries` made for the meta hot path.

String entries collapse onto the same shape: both halves are uint32
indices on the v0.5 wire (5 bytes each), so the key and value emit
under a single 10-byte reserve. Non-string / non-number entries skip
early — same filter the previous loop applied implicitly.

Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0):

* `encoder=0.5 events=none`   master  100 ms -> patched  76 ms (-24 %)
* `encoder=0.5 events=legacy` master  397 ms -> patched 364 ms ( -8 %)

Wire output is unchanged.
`Buffer.allocUnsafe(size)` returns a pooled slice whose `byteOffset` is
non-zero when `size <= Buffer.poolSize / 2`. The previous
`new DataView(this.buffer.buffer)` and `new Uint8Array(this.buffer.buffer,
sourceStart, ...)` shapes addressed the shared 8 KiB slab from offset 0
instead of the chunk's own window, so `writeFloat`, `writeBigInt`, and
`MsgpackChunk.copy` either wrote into a sibling consumer's bytes or
returned a stale slice.

The 2048-byte prefix chunk is the first caller small enough to be
pool-allocated; the default 1 MiB chunk always lands at
`byteOffset === 0`, so the bug stayed latent on master.
@BridgeAR
BridgeAR force-pushed the BridgeAR/2026-05-14-perf-encode-stringmap branch from 2f63db1 to 80e8f34 Compare May 28, 2026 15:19
let newSize = this.buffer.length
// `*= 2` instead of `<<= 1`: `1073741824 << 1` is negative as int32,
// and msgpack values can legitimately reach the multi-GiB range.
while (newSize < needed) newSize *= 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@rochdev just a heads up this changes it from logarithmic resizing to quadratic

@bengl
bengl enabled auto-merge (squash) May 29, 2026 14:30
@bengl
bengl merged commit 68619de into master May 29, 2026
953 of 957 checks passed
@bengl
bengl deleted the BridgeAR/2026-05-14-perf-encode-stringmap branch May 29, 2026 14:44
dd-octo-sts Bot pushed a commit that referenced this pull request May 29, 2026
* perf(encode): tighten msgpack dispatch and debug-log allocation

Four small wins on the encode and debug-log paths:

1. `MsgpackEncoder#encodeValue` dispatches `string` first. The string
   arm covers tag values and map keys; the prior order paid four
   `switch` comparisons before reaching it.
2. `MsgpackEncoder#encodeMap` emits keys via `encodeString` directly.
   `Object.keys` only yields strings, so the typeof re-dispatch is
   dead work.
3. `AgentEncoder.encode` hoists the `DD_TRACE_ENCODING_DEBUG`
   hex-dump formatter to a module-level function. The closure used
   to allocate per encode whenever the debug switch was on.
4. `memoizedLogDebug` switches to printf-style. The substitution only
   runs when the debug channel has subscribers.

`log` now forwards trailing arguments to a function delegate so the
formatter in (3) can take `bytes`, `start`, `end` as parameters
instead of capturing them via closure.

* perf(encode): fold msgpack primitives onto MsgpackChunk

The tracer dispatched every byte-layout write through three layers:
`subclass._encodeX(bytes, value)` → the base wrapper → the
`MsgpackEncoder` instance. The wrappers existed so msgpack could be
swapped at runtime, which never happened and was never planned.

`MsgpackChunk` now owns the byte-layout primitives directly. Call
sites encode straight into the chunk; the eight base-class wrappers
and the `MsgpackEncoder` class go away. `DataStreamsWriter`, the one
external caller of the deleted `msgpack.encode(payload)` method,
imports the free `encode` function that exposes the same recursive
dispatcher.

* perf(msgpack): right-size MsgpackChunk to its actual workload

Initial capacity drops from 2 MiB to 1 MiB. Each `AgentEncoder` holds
two `MsgpackChunk` instances and the datastreams writer adds one more,
so the previous default cost every tracer-loaded process a fixed
4 - 6 MiB regardless of payload size. A representative HTTP trace
serializes well under 100 KiB, but keeping a megabyte of headroom
avoids the first resize on bursts of larger spans (long URLs, JSON
event payloads) so the typical request never copies through an
intermediate buffer.

`reserve` doubles the capacity on overflow instead of rounding up to
the next `minSize` multiple. Worst-case burst growth from 1 MiB to
the 8 MiB soft flush limit takes the same number of resizes as the
old 2 MiB-aligned walk but reaches the larger sizes earlier, cutting
the time spent copying through intermediate buffers.

`reset` is the new flush signal: it zeros the cursor and counts
consecutive flushes whose peak length stayed under a quarter of the
current buffer. After 32 such quiet flushes the chunk halves toward
the `minSize` floor. One above-threshold peak resets the streak, so
long-lived encoders give memory back during idle periods but hold
on to the warmed buffer through sustained traffic.

* perf(encode): pre-fuse the `error: 0` and `error: 1` payloads

Every encoded span emits a `[KEY_ERROR][writeIntOrFloat(span.error)]`
pair, and `span.error` is `0` or `1` on the overwhelming majority of
spans — only a handful of plugins use any other numeric value, and the
tracer never stores anything else there. The hot loop therefore paid
two `MsgpackChunk.reserve` calls, two memory writes, and one trip
through `writeIntOrFloat`'s magnitude-detection branches for what is
effectively a constant on every span.

Two precomputed `[KEY_ERROR, fixint]` constants (7 bytes each) collapse
the common case to a single `bytes.set`. The else branch keeps the
existing variable-value path so the rare non-{0,1} values still emit
the shortest valid encoding.

* perf(encode): bypass the string cache for values over 1 KiB

`_encodeString` and `#encodeMetaEntries` looked up every value in
`_stringMap` before emitting it, including the multi-KiB strings the
tracer produces from stringified `span_events`, stack traces, full SQL
statements, and large query bodies. Those values are essentially
unique per span on real traffic, so the cache hit rate is near zero
and the lookup cost is paid for nothing. The
`encoders-0.4-events-legacy` sirun scenario picked the regression up
on a realistic trace fixture.

Values longer than `STRING_CACHE_BYPASS_LIMIT` (1 KiB) now write
directly through `MsgpackChunk.write`. The threshold sits well above
the routine span-tag distribution (URLs, methods, IPs, resource names
all comfortably below 256 bytes) and well below the size where the
per-call hash dominates the per-call write. Short, repeating tag
values still hit the cache and pay nothing.

Bench (`benchmark/sirun/encoding/index.js` adapted: 30-span trace,
5 000 iterations, 7 trials drop best+worst; Node 24.15.0):

* `encoder=0.4 events=legacy` master 324 ms -> patched 341 ms
  (was 346 ms before this commit, -1.5 %)
* `encoder=0.4 events=none`   master  50 ms -> patched  47 ms (-7.6 %)
* `encoder=0.5 events=none`   master  30 ms -> patched  29 ms (-5.5 %)

`encoder=0.5 events=legacy` still regresses because the 0.5 wire ships
strings as indices and cannot bypass the lookup.

Wire output is unchanged: `bytes.write` emits the same fixstr / str32
bytes that `_cacheString` would have stored, just without the cache
side effect.

* test(encode): exercise agentless-JSON soft-limit flush via constructor

The flush-on-soft-limit test was reaching into `encoder._estimatedSize`
to push the encoder over the threshold. The reach-in only existed
because the 8 MiB soft limit was a module-local constant, so the test
had no other way to trigger the branch without actually encoding a
multi-megabyte payload.

Add a `softLimit` constructor parameter to `AgentlessJSONEncoder` —
mirroring the same hook on the v0.4/v0.5 `AgentEncoder` — and rewrite
the test to construct a tiny-limit encoder. The reach-in goes away, the
soft-limit branch is now reachable from public API for any caller that
wants to bound payload size for tests or for memory-constrained
environments, and production behavior is unchanged when the parameter
is omitted.

* perf(encode): extend per-span block past service into the start/duration keys

Almost every real span carries `error: 0` / `error: 1` and a nanosecond
`start` ≥ 2³². When both hold the encoder can fuse the error key+fixint,
the `start` key + 0xCF type byte + 8-byte timestamp, and the `duration`
key into the same `bytes.reserve` that already covers the map header,
optional `type`, the three IDs, and name/resource/service. The new path
collapses up to four extra reserves per span (one each for the error
key+value, the start key, the start value's u64 dispatch, and the
duration key) into the existing one.

Spans that don't fit the assumption (synthetic test data with small
`start`, rare non-binary error flags) drop back to the per-field emits
so each integer still picks the shortest msgpack encoding — keeping the
existing wire-format guarantees for those inputs.

`KEY_START_PREFIX` is the precomputed `[KEY_START, 0xCF]` fusion. The
8-byte timestamp lands via two `Buffer.writeUInt32BE` calls; the hi/lo
split matches what `writeLong` would have done.

* perf(encode): inline the fixint fast path in #encodeMetaEntries

The numeric branch of the meta-map writer used to pay two `reserve`
calls per entry: one for the key prefix, one inside `writeIntOrFloat`
for the value byte. The metrics map is mostly small positive integers
(`_sampling_priority_v1`, `_dd.measured`, attribute counts, ms timings
that fit in 0..127), so every one of those entries hits the fixint
fast path inside `writeIntOrFloat` anyway — but only after paying a
second reserve and a three-level branch chain.

Speculate the fixint case at the call site: reserve `keyEntryLen + 1`
up front, copy the key, and write the value byte directly. When the
speculation misses (large counts, floats, signed numbers), rewind the
speculative byte and fall back to the full writer so the wire still
picks the shortest valid encoding.

* docs(encode): note that span_events stringification is memoizable

Both formatters re-stringify `span_events` from scratch on every encode
call even when the underlying event array survives across encode cycles
(retries, re-emission paths). The events=legacy hot path is currently
bound by raw memory bandwidth from those re-stringifications, not the
cache lookups.

Leave the WeakMap memoization out of this branch — it interacts with
the formatter / span lifecycle in ways that warrant their own change.
Drop a pointer at the two call sites so the next reader knows where the
remaining headroom lives. No production behavior change.

* Apply suggestions from code review

Co-authored-by: Ruben Bridgewater <[email protected]>

* refactor(msgpack): drop dead writeFixMap and writeFixArray default

`writeFixMap` has no caller -- `_encodeMap` and the v0.5 encoder go
through `writeMapPrefix` (map32) regardless of size. Same shape for
`writeFixArray`'s `= 0` default; its only caller passes the length
explicitly.

* test(encode): cover msgpack chunk primitives and the 0.4 fallback branch

Pin the patch lines codecov flagged as uncovered:

1. `MsgpackChunk.writeNull` / `writeBoolean` / `writeBin` (bin32 path
   for byteLength >= 65 536) / `writeSigned` (int8 the encoder never
   reaches via `writeIntOrFloat`) / `writeNumber` NaN coercion. The
   spec exercises the rest of the public `writeX` surface in the same
   pass so the boundaries between fixint / uint8 / uint16 / uint32 /
   uint64 and the signed counterparts are pinned.
2. `msgpack/encode` dispatcher arms for `null`, `boolean`, `symbol`,
   the `default` arm (function / undefined), and `array.length >= 16`
   (array32 prefix).
3. `AgentEncoder._encode`'s non-fuseTail fallback for `error === 1`
   and unusual error flags -- the synthetic-input path the comment
   on the fused-tail block calls out.

* perf(encode): fuse the per-span head block on the v0.5 wire

Every v0.5 span used to pay seven separate `bytes.reserve` calls for
its fixed-size prefix: one for the `0x9C` fixarray marker, three for
the service / name / resource indices, and three for the trace / span
/ parent uint64 ids. Pre-resolve the three string indices, then write
the whole 43-byte head block (1 + 5 × 3 + 9 × 3) under one reserve.

The v0.5 wire is positional — no map keys — so the fuse is simpler
than the v0.4 equivalent: no key bytes to concatenate, and the four
fixed-shape elements (marker + three indices + three ids) tile the
block exactly. The remaining per-span fields (`start`, `duration`,
`error`, `meta`, `metrics`, `type`) keep their per-call writes because
their byte sizes depend on the value.

While here, refactor `_cacheString` so it returns the resolved index
instead of just having the side effect. The head fuse uses
`stringMap[value] ?? this._cacheString(value)` to resolve indices
without re-fetching, and `_encodeString` collapses onto the same
pattern. `_reset()` (inherited from 0.4) calls `_cacheString('')` for
the seeding side effect and ignores the return value — no behavior
change there.

Two new private helpers (`#writeIndexAt`, `#writeIdAt`) keep the
fused block readable; both are inlinable single-shot writes the
encoder code calls directly into the pre-reserved buffer.

Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0; same fixture on both checkouts, only the encoder source
differs):

* `encoder=0.5 events=none`   master  98 ms -> patched  77 ms (-21 %)
* `encoder=0.5 events=legacy` master 395 ms -> patched 366 ms ( -7 %)
* `encoder=0.4 *` unchanged within noise (the v0.4 hot path is not
  touched).

* perf(encode): speculate the fixint fast path in v0.5 _encodeMap

The v0.5 `_encodeMap` override used to pay two `bytes.reserve` calls
per numeric entry — one for the key (5-byte uint32 index) and one
inside `writeIntOrFloat` for the value byte. The metrics map is mostly
small positive integers (`_sampling_priority_v1`, `_dd.measured`,
attribute counts), so every one of those entries hits the fixint fast
path inside `writeIntOrFloat` anyway, but only after paying that
second reserve and a three-level branch chain.

Speculate the fixint case at the call site: reserve `5 + 1` up front,
write the key index, then write the value byte directly. Speculation
misses rewind the speculative byte and fall back to the full encoder
so the wire still picks the shortest valid encoding. Mirrors the same
move the v0.4 `#encodeMetaEntries` made for the meta hot path.

String entries collapse onto the same shape: both halves are uint32
indices on the v0.5 wire (5 bytes each), so the key and value emit
under a single 10-byte reserve. Non-string / non-number entries skip
early — same filter the previous loop applied implicitly.

Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0):

* `encoder=0.5 events=none`   master  100 ms -> patched  76 ms (-24 %)
* `encoder=0.5 events=legacy` master  397 ms -> patched 364 ms ( -8 %)

Wire output is unchanged.

* fix(msgpack): honour Buffer byteOffset in chunk views

`Buffer.allocUnsafe(size)` returns a pooled slice whose `byteOffset` is
non-zero when `size <= Buffer.poolSize / 2`. The previous
`new DataView(this.buffer.buffer)` and `new Uint8Array(this.buffer.buffer,
sourceStart, ...)` shapes addressed the shared 8 KiB slab from offset 0
instead of the chunk's own window, so `writeFloat`, `writeBigInt`, and
`MsgpackChunk.copy` either wrote into a sibling consumer's bytes or
returned a stale slice.

The 2048-byte prefix chunk is the first caller small enough to be
pool-allocated; the default 1 MiB chunk always lands at
`byteOffset === 0`, so the bug stayed latent on master.
This was referenced May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants