Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions lib/flush.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
51 changes: 50 additions & 1 deletion lib/span.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -155,14 +166,52 @@ 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;
}

getAttributes() {
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.
Expand Down
105 changes: 105 additions & 0 deletions test/test-attribute-limits.js
Original file line number Diff line number Diff line change
@@ -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'));
});
});
Loading