From e075f1be5adbfa84ee3e5dd7a021bef969ecc2a1 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Tue, 3 Nov 2026 13:43:00 -0500 Subject: [PATCH] feat: cap span attributes at 128 keys Previously, setAttribute accepted any number of distinct keys, so a span could grow without bound. Now a span keeps at most 128 distinct attributes, the OpenTelemetry SDK's default attributeCountLimit. A write to a key the span already has always lands. A new key past the cap is dropped and counted, and the count is exported with the span as the droppedAttributesCount OTLP field. Writing undefined to a key frees its slot. Spans below the cap track only the number of setAttribute calls, since distinct keys can never exceed the number of writes. Once writes reach the cap, the same field holds the exact key count and the dropped count. Signed-off-by: Bryan English --- README.md | 5 ++ lib/flush.js | 6 ++ lib/span.js | 51 ++++++++++++++++- test/test-attribute-limits.js | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 test/test-attribute-limits.js diff --git a/README.md b/README.md index f7457b9..4c1ebc1 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,11 @@ event loop is about to drain. Spans that are still buffered when `process.exit() is called explicitly are lost, because explicit exits do not run the exit-time flush. +A span keeps at most 128 distinct attributes, the OpenTelemetry SDK's default +`attributeCountLimit`. Writes to a key the span already has always land. A new +key past the limit is dropped and counted, and the count is exported with the +span as the `droppedAttributesCount` OTLP field. + ## License [MIT](./LICENSE) diff --git a/lib/flush.js b/lib/flush.js index 46e84c4..39c06cf 100644 --- a/lib/flush.js +++ b/lib/flush.js @@ -140,6 +140,12 @@ function spanToOtlp(span) { if (attributes.length > 0) otlpSpan.attributes = attributes; + // The OTLP field is dropped_attributes_count, spelled + // droppedAttributesCount in ProtoJSON. + const droppedAttributesCount = span.droppedAttributesCount; + if (droppedAttributesCount > 0) + otlpSpan.droppedAttributesCount = droppedAttributesCount; + const status = span.status; if (status.code !== 0 || status.message) otlpSpan.status = status; diff --git a/lib/span.js b/lib/span.js index fdf76c8..cfbc16b 100644 --- a/lib/span.js +++ b/lib/span.js @@ -30,6 +30,11 @@ const kAllZero16 = '0000000000000000'; const kTraceFlagSampled = 0x01; const kTraceFlagRandom = 0x02; +// A span keeps at most this many distinct attribute keys, the OpenTelemetry +// SDK's default attributeCountLimit. New keys past it are dropped and +// counted, while writes to keys the span already has always land. +const kAttributeCountLimit = 128; + // Two hex digits for every trace flags byte, so building a traceparent // never formats a number. const kTraceFlagsHex = Array.from({ length: 256 }, (_, i) => @@ -100,6 +105,12 @@ class Span { #startMonotonic; #attributes; + // #attributeLimit counts setAttribute calls until they reach the cap. + // Distinct keys never outnumber writes, so spans below the cap need no + // per-key accounting. Once writes reach the cap, it holds the exact + // distinct count and the dropped count instead, so an ordinary span + // initializes one field for all of this. + #attributeLimit = 0; #events; #status; #traceFlags; @@ -155,7 +166,40 @@ class Span { } setAttribute(key, value) { - this.#attributes[key] = value; + const writes = this.#attributeLimit; + if (typeof writes === 'number' && writes < kAttributeCountLimit) { + this.#attributeLimit = writes + 1; + this.#attributes[key] = value; + return this; + } + return this.#setAttributeCapped(key, value); + } + + // This runs only once writes reach the cap. A key the span does not + // have yet holds undefined, and undefined values are never exported, so + // a key counts against the cap only while it holds another value. Writing + // undefined to a key frees its slot. + #setAttributeCapped(key, value) { + const attrs = this.#attributes; + let limit = this.#attributeLimit; + if (typeof limit === 'number') { + let count = 0; + for (const k of Object.keys(attrs)) if (attrs[k] !== undefined) count++; + limit = { count, dropped: 0 }; + this.#attributeLimit = limit; + } + const had = attrs[key] !== undefined; + const has = value !== undefined; + if (has && !had) { + if (limit.count === kAttributeCountLimit) { + limit.dropped++; + return this; + } + limit.count++; + } else if (had && !has) { + limit.count--; + } + attrs[key] = value; return this; } @@ -163,6 +207,11 @@ class Span { return this.#attributes; } + get droppedAttributesCount() { + const limit = this.#attributeLimit; + return typeof limit === 'number' ? 0 : limit.dropped; + } + addEvent(name, attributes) { // Only the internal exception producer uses events today; cap them // once addEvent becomes part of a public API. diff --git a/test/test-attribute-limits.js b/test/test-attribute-limits.js new file mode 100644 index 0000000..167ee6d --- /dev/null +++ b/test/test-attribute-limits.js @@ -0,0 +1,105 @@ +'use strict'; + +const assert = require('node:assert'); +const { after, before, describe, it } = require('node:test'); + +const { Span, SPAN_KIND_INTERNAL } = require('../lib/span'); +const { startTracing, flushAndCollect, spanAttrs } = require('./common/otel'); + +const kAttributeCountLimit = 128; + +describe('span attribute limits', () => { + let collector; + + before(async () => { + collector = await startTracing(); + }); + + after(() => collector.close()); + + it('caps distinct keys at 128 and exports the dropped count', async () => { + const span = new Span('attribute-cap', SPAN_KIND_INTERNAL); + for (let i = 0; i < 200; i++) span.setAttribute(`key${i}`, i); + span.end(); + + const [exported] = await flushAndCollect(collector); + const attrs = spanAttrs(exported); + + assert.strictEqual(exported.attributes.length, kAttributeCountLimit); + assert.partialDeepStrictEqual(exported, { droppedAttributesCount: 72 }); + // The first 128 keys land and the rest are dropped. + assert.strictEqual(attrs.key0, '0'); + assert.strictEqual(attrs.key127, '127'); + assert.ok(!('key128' in attrs)); + assert.ok(!('key199' in attrs)); + }); + + it('lets writes to existing keys land past the cap', async () => { + const span = new Span('existing-key', SPAN_KIND_INTERNAL); + for (let i = 0; i < kAttributeCountLimit; i++) + span.setAttribute(`key${i}`, i); + span.setAttribute('key0', 'updated'); + span.setAttribute('past-the-cap', 'dropped'); + span.end(); + + const [exported] = await flushAndCollect(collector); + const attrs = spanAttrs(exported); + + assert.strictEqual(exported.attributes.length, kAttributeCountLimit); + assert.strictEqual(attrs.key0, 'updated'); + assert.ok(!('past-the-cap' in attrs)); + assert.partialDeepStrictEqual(exported, { droppedAttributesCount: 1 }); + }); + + it('frees a slot when a key is cleared past the cap', async () => { + const span = new Span('cleared-key', SPAN_KIND_INTERNAL); + for (let i = 0; i < kAttributeCountLimit; i++) + span.setAttribute(`key${i}`, i); + // Clearing a key frees its slot for the same key or a new one. + span.setAttribute('key0', undefined); + span.setAttribute('key0', 'back'); + span.setAttribute('key1', undefined); + span.setAttribute('other', 'lands'); + span.end(); + + const [exported] = await flushAndCollect(collector); + const attrs = spanAttrs(exported); + + assert.strictEqual(exported.attributes.length, kAttributeCountLimit); + assert.strictEqual(attrs.key0, 'back'); + assert.strictEqual(attrs.other, 'lands'); + assert.ok(!('key1' in attrs)); + assert.ok(!Object.hasOwn(exported, 'droppedAttributesCount')); + }); + + it('counts keys exactly after many writes to one key', async () => { + // The writes alone reach the cap while the span has one key, so the + // exact count starts there. A null value holds a slot like any other. + const span = new Span('rewrites', SPAN_KIND_INTERNAL); + for (let i = 0; i < kAttributeCountLimit; i++) span.setAttribute('same', i); + for (let i = 1; i < kAttributeCountLimit - 1; i++) + span.setAttribute(`key${i}`, i); + span.setAttribute('nullish', null); + span.setAttribute('one-too-many', 'dropped'); + span.end(); + + const [exported] = await flushAndCollect(collector); + const attrs = spanAttrs(exported); + + assert.strictEqual(exported.attributes.length, kAttributeCountLimit); + assert.strictEqual(attrs.same, String(kAttributeCountLimit - 1)); + assert.ok('nullish' in attrs); + assert.ok(!('one-too-many' in attrs)); + assert.partialDeepStrictEqual(exported, { droppedAttributesCount: 1 }); + }); + + it('exports no droppedAttributesCount under the cap', async () => { + const span = new Span('under-the-cap', SPAN_KIND_INTERNAL); + span.setAttribute('http.request.method', 'GET'); + span.end(); + + const [exported] = await flushAndCollect(collector); + + assert.ok(!Object.hasOwn(exported, 'droppedAttributesCount')); + }); +});