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')); + }); +});