Skip to content
6 changes: 6 additions & 0 deletions packages/core/src/traces/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@ describe('resolveTracesConfig', () => {
})
})

it('keeps the per-event attribute cap fixed', () => {
// The cap is internal, so an untyped caller naming it gets the default.
expect(resolveTracesConfig({ maxAttributesPerEvent: 9 } as any).maxAttributesPerEvent).toBe(128)
})

it('applies the documented defaults', () => {
expect(resolveTracesConfig(undefined)).toMatchObject({
flushIntervalMs: 5000,
maxExportBatchSize: 512,
maxQueueSize: 2048,
maxAttributesPerSpan: 128,
maxEventsPerSpan: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
maxLiveSpans: 10_000,
maxSpanAgeMs: 3_600_000,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/traces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const DEFAULT_MAX_QUEUE_SIZE = 2048
// OpenTelemetry's per-span defaults.
const DEFAULT_MAX_ATTRIBUTES_PER_SPAN = 128
const DEFAULT_MAX_EVENTS_PER_SPAN = 128
// OpenTelemetry's per-event limit, which is the same number. Fixed rather than
// configurable: `maxAttributesPerSpan` and `maxEventsPerSpan` already give a
// caller room to shape a span, and this one only has to stop an event holding
// an unbounded bag.
const DEFAULT_MAX_ATTRIBUTES_PER_EVENT = 128
// OpenTelemetry leaves the value length unlimited, which is what lets one
// multi-MB attribute make a span too large for the endpoint to accept — and an
// oversized span is dropped whole. 8 KB holds a deep stack trace and any
Expand Down Expand Up @@ -121,6 +126,7 @@ export function resolveTracesConfig(
beforeSpanSend: resolveBeforeSpanSend(config?.beforeSpanSend, logger),
maxAttributesPerSpan: positiveInteger(config?.maxAttributesPerSpan, DEFAULT_MAX_ATTRIBUTES_PER_SPAN),
maxEventsPerSpan: positiveInteger(config?.maxEventsPerSpan, DEFAULT_MAX_EVENTS_PER_SPAN),
maxAttributesPerEvent: DEFAULT_MAX_ATTRIBUTES_PER_EVENT,
maxAttributeValueLength: positiveInteger(config?.maxAttributeValueLength, DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH),
flushIntervalMs: positiveInteger(config?.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS),
maxExportBatchSize,
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/traces/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const resolveForTest = (partial?: Partial<ResolvedTracesConfig>): ResolvedTraces
beforeSpanSend: [],
maxAttributesPerSpan: 128,
maxEventsPerSpan: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
maxLiveSpans: 10000,
maxSpanAgeMs: 3600000,
Expand Down Expand Up @@ -1509,6 +1510,26 @@ describe('PostHogTraces', () => {
expect(sent.droppedEventsCount).toBe(1)
})

it('re-applies the event attribute cap to what beforeSpanSend widened', async () => {
const traces = createTraces({
maxAttributesPerEvent: 2,
beforeSpanSend: [
(span) => {
span.events[0].attributes = { a: 1, b: 2, c: 3, d: 4 }
return span
},
],
})
const span = traces.startSpan('checkout')
span.addEvent('query', { a: 1 })
span.end()
await traces.flush()

const event = sentSpans()[0].events![0]
expect(event.attributes!.map((attribute) => attribute.key)).toEqual(['a', 'b'])
expect(event.droppedAttributesCount).toBe(2)
})

it('keeps the auto-context keys when beforeSpanSend pushes past the cap', async () => {
context = { distinctId: 'alice', sessionId: 'session-1' }
const traces = createTraces({
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/traces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export class PostHogTraces {
autoAttributeKeys: Object.keys(autoAttributes),
maxAttributes: this._config.maxAttributesPerSpan,
maxEvents: this._config.maxEventsPerSpan,
maxAttributesPerEvent: this._config.maxAttributesPerEvent,
maxAttributeValueLength: this._config.maxAttributeValueLength,
startTime,
backdated: startTime !== now,
Expand Down Expand Up @@ -654,6 +655,7 @@ export class PostHogTraces {
autoKeys,
this._config.maxAttributesPerSpan,
this._config.maxEventsPerSpan,
this._config.maxAttributesPerEvent,
this._config.maxAttributeValueLength,
keysBeforeHook
)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/traces/live-spans.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('live spans', () => {
beforeSpanSend: [],
maxAttributesPerSpan: 128,
maxEventsPerSpan: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
}

Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/traces/otlp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ describe('OTLP span encoding', () => {
expect(span.events).toEqual([{ name: 'cache miss', timeUnixNano: '1700000000040000000' }])
})

it('carries an event attribute drop as the proto counter', () => {
const span = buildOtlpSpan(
record({
events: [{ name: 'query', timestamp: 1_700_000_000_040, droppedAttributesCount: 3 }],
})
)
expect(span.events?.[0]).toMatchObject({ name: 'query', droppedAttributesCount: 3 })
})

it.each([
['none were dropped', 0],
['a hook wrote a negative', -2],
['a hook wrote a non-number', 'lots' as unknown as number],
])('omits the event drop counter when %s', (_label, dropped) => {
// Coerced like the span's own counters: a non-integer here is refused for
// the whole request, taking unrelated spans with it.
const span = buildOtlpSpan(
record({
events: [{ name: 'query', timestamp: 1_700_000_000_040, droppedAttributesCount: dropped }],
})
)
expect(span.events?.[0].droppedAttributesCount).toBeUndefined()
})

it('sets the sampled bit and marks a root span as known-not-remote', () => {
// A root span has no parent context to be remote, which the OTel Go and
// Java exporters also report as known-not-remote rather than unknown.
Expand Down Expand Up @@ -143,6 +167,7 @@ describe('OTLP span encoding', () => {
beforeSpanSend: [],
maxAttributesPerSpan: 128,
maxEventsPerSpan: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
maxLiveSpans: 10000,
maxSpanAgeMs: 3600000,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/traces/otlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ function toOtlpEvent(event: SpanRecord['events'][number], logger?: Logger): Otlp
encoded.attributes = attributes
}
}
const dropped = nonNegativeCount(event.droppedAttributesCount)
if (dropped) {
encoded.droppedAttributesCount = dropped
}
return encoded
}

Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/traces/span.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('PostHogSpan', () => {
autoAttributeKeys: [],
maxAttributes: 128,
maxEvents: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
...init,
},
Expand Down Expand Up @@ -227,6 +228,101 @@ describe('PostHogSpan', () => {
})
})

describe('event attribute cap', () => {
it('keeps the first attributes and reports the rest as dropped', () => {
const span = createSpan({ maxAttributesPerEvent: 2 })
span.addEvent('query', { a: 1, b: 2, c: 3, d: 4 })
span.end()

expect(ended[0].events[0].attributes).toEqual({ a: 1, b: 2 })
expect(ended[0].events[0].droppedAttributesCount).toBe(2)
})

it('leaves the count off an event that lost nothing', () => {
const span = createSpan({ maxAttributesPerEvent: 2 })
span.addEvent('query', { a: 1, b: 2 })
span.end()

expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
})

it('bounds an exception event like any other', () => {
// The SDK's own `exception.*` attributes are width the caller sees too, so
// they spend the cap rather than being exempt from it.
const span = createSpan({ maxAttributesPerEvent: 1 })
span.recordException(new Error('boom'))
span.end()

expect(Object.keys(ended[0].events[0].attributes ?? {})).toEqual(['exception.type'])
expect(ended[0].events[0].droppedAttributesCount).toBe(2)
})

it('does not read a value past the cap', () => {
// The cap is spent before the value is bounded, so a wide bag does not pay
// for getters on entries that are about to be dropped.
const read: string[] = []
const watched: any = {}
for (const key of ['a', 'b', 'c']) {
Object.defineProperty(watched, key, {
enumerable: true,
get() {
read.push(key)
return key
},
})
}

const span = createSpan({ maxAttributesPerEvent: 2 })
span.addEvent('query', watched)
span.end()

expect(read).toEqual(['a', 'b'])
expect(ended[0].events[0].droppedAttributesCount).toBe(1)
})

it('does not let a nullish value spend a slot', () => {
// The encoder drops these, so a caller who blanked a value rather than
// omitting the key must not cost the event a real attribute. Same rule the
// span half of the cap already follows.
const span = createSpan({ maxAttributesPerEvent: 2 })
span.addEvent('query', { blanked: undefined, cleared: null, real: 1, second: 2 })
span.end()

expect(ended[0].events[0].attributes).toEqual({ real: 1, second: 2 })
expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
})

it('survives an attribute bag whose own keys cannot be read', () => {
const hostile = new Proxy(
{},
{
ownKeys() {
throw new Error('ownKeys exploded')
},
}
)
const span = createSpan()
expect(() => span.addEvent('query', hostile)).not.toThrow()
expect(() => span.end()).not.toThrow()

expect(ended[0].events[0].attributes).toEqual({})
})

it('counts the span attribute cap separately from an event cap', () => {
// maxAttributesPerSpan does not reach inside events, which is the gap this
// cap closes: a span at its own cap can still carry full-width events.
const span = createSpan({ maxAttributes: 1, maxAttributesPerEvent: 3 })
span.setAttributes({ kept: 1, dropped: 2 })
span.addEvent('query', { a: 1, b: 2, c: 3 })
span.end()

expect(ended[0].attributes).toEqual({ kept: 1 })
expect(ended[0].droppedAttributesCount).toBe(1)
expect(ended[0].events[0].attributes).toEqual({ a: 1, b: 2, c: 3 })
expect(ended[0].events[0].droppedAttributesCount).toBeUndefined()
})
})

describe('attribute store hygiene', () => {
it('does not copy a polluted Object.prototype key into the span', () => {
;(Object.prototype as any).polluted = 'yes'
Expand Down Expand Up @@ -900,6 +996,7 @@ describe('attribute store', () => {
autoAttributeKeys: [],
maxAttributes: 128,
maxEvents: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
},
(record) => ended.push(record)
Expand All @@ -926,6 +1023,7 @@ describe('attribute store', () => {
autoAttributeKeys: [],
maxAttributes: 128,
maxEvents: 128,
maxAttributesPerEvent: 128,
maxAttributeValueLength: 8192,
},
(record) => ended.push(record)
Expand Down
69 changes: 65 additions & 4 deletions packages/core/src/traces/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface SpanInit {
autoAttributeKeys: string[]
maxAttributes: number
maxEvents: number
maxAttributesPerEvent: number
maxAttributeValueLength: number
}

Expand All @@ -65,6 +66,7 @@ export class PostHogSpan implements Span {
private readonly _autoKeys: Set<string>
private readonly _maxAttributes: number
private readonly _maxEvents: number
private readonly _maxAttributesPerEvent: number
private readonly _maxAttributeValueLength: number
private _userAttributeCount = 0
private _userEventCount = 0
Expand All @@ -87,6 +89,7 @@ export class PostHogSpan implements Span {
this._autoKeys = new Set(init.autoAttributeKeys)
this._maxAttributes = init.maxAttributes
this._maxEvents = init.maxEvents
this._maxAttributesPerEvent = init.maxAttributesPerEvent
this._maxAttributeValueLength = init.maxAttributeValueLength
// Null-prototype: a `__proto__` key would otherwise swap this object's prototype
// instead of becoming an entry, and `toString` and friends would read as
Expand Down Expand Up @@ -183,12 +186,15 @@ export class PostHogSpan implements Span {
return this
}
this._userEventCount++
// Copied so a caller reusing one object across events can't mutate a recorded one.
const bounded =
attributes && boundAttributes(attributes, this._maxAttributesPerEvent, this._maxAttributeValueLength)
this._events.push({
name: sanitizeName(name, 'Span event name', this._maxAttributeValueLength, this._logger),
timestamp: resolveSuppliedTime(timestamp, this._now(), 'event timestamp', this._logger),
// Copied so a caller reusing one object across events can't mutate a recorded one.
...(attributes && {
attributes: truncateAttributes(assignUserAttributes({}, attributes), this._maxAttributeValueLength),
...(bounded && {
attributes: bounded.attributes,
...(bounded.dropped && { droppedAttributesCount: bounded.dropped }),
}),
})
}
Expand Down Expand Up @@ -342,6 +348,7 @@ export function applySpanLimits(
autoKeys: ReadonlySet<string>,
maxAttributes: number,
maxEvents: number,
maxAttributesPerEvent: number,
maxAttributeValueLength: number,
keysBeforeHook: readonly string[] = []
): void {
Expand Down Expand Up @@ -391,7 +398,13 @@ export function applySpanLimits(
}
keptEvents++
if (event.attributes) {
event.attributes = truncateAttributes({ ...event.attributes }, maxAttributeValueLength)
// A hook can widen an event as freely as it can add one, and neither goes
// through `addEvent`.
const bounded = boundAttributes(event.attributes, maxAttributesPerEvent, maxAttributeValueLength)
event.attributes = bounded.attributes
if (bounded.dropped) {
event.droppedAttributesCount = nonNegativeCount(event.droppedAttributesCount) + bounded.dropped
}
}
events.push(event)
}
Expand Down Expand Up @@ -691,6 +704,54 @@ function resolveToJson(value: object): { selfDescribed: boolean; value?: SpanAtt
return { selfDescribed: false }
}

/**
* A copy of a caller-supplied attribute bag holding at most `max` entries, each
* value bounded to `maxLength`, plus how many entries the cap refused.
*
* Once the cap is spent the remaining keys are counted without being read, so a
* wide object does not pay for the getters on values it is about to drop.
*/
function boundAttributes(
source: SpanAttributes,
max: number,
maxLength: number
): { attributes: SpanAttributes; dropped: number } {
let keys: string[]
try {
keys = Object.keys(source)
} catch {
// A hostile own-keys trap costs the bag, not the event carrying it.
return { attributes: {}, dropped: 0 }
}
const attributes: SpanAttributes = {}
let kept = 0
let dropped = 0
for (const key of keys) {
if (kept >= max) {
dropped++
continue
}
let value: SpanAttributeValue
try {
value = truncateAttributeValue(source[key], maxLength)
} catch {
// A throwing getter costs its own key, as it does in `assignUserAttributes`.
value = UNSERIALIZABLE_VALUE
}
// Nullish spends no slot, matching `_writeAttribute` and the span half of
// `applySpanLimits`: the encoder drops these, so a caller who blanked a value
// rather than omitting the key must not lose a real attribute to it.
if (isNullish(value)) {
continue
}
kept++
// defineProperty, not assignment: `attributes['__proto__'] = v` hits the
// prototype setter and the attribute vanishes.
Object.defineProperty(attributes, key, { value, enumerable: true, writable: true, configurable: true })
}
return { attributes, dropped }
}

/** `truncateAttributeValue` across an attribute bag, in place. */
export function truncateAttributes(attributes: SpanAttributes, maxLength: number): SpanAttributes {
for (const key of Object.keys(attributes)) {
Expand Down
Loading