From 815d53d8870a6f17052fa02bb2038922e83e1a82 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 4 Sep 2026 15:37:51 -0400 Subject: [PATCH 1/4] feat(traces): cap the attributes on a span event maxAttributesPerSpan does not reach inside events, so a span's width was bounded but its events' was not. Adds maxAttributesPerEvent, default 128, reported per event as the OTLP dropped_attributes_count. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018AAPCyRnC5HbukEZV9hbai --- .changeset/node-span-limits.md | 2 +- packages/core/src/traces/config.spec.ts | 8 ++- packages/core/src/traces/config.ts | 2 + packages/core/src/traces/index.spec.ts | 21 ++++++++ packages/core/src/traces/index.ts | 2 + packages/core/src/traces/otlp.spec.ts | 24 +++++++++ packages/core/src/traces/otlp.ts | 4 ++ packages/core/src/traces/span.spec.ts | 70 +++++++++++++++++++++++++ packages/core/src/traces/span.ts | 59 +++++++++++++++++++-- packages/core/src/traces/types.ts | 4 ++ packages/types/src/traces.ts | 15 ++++++ 11 files changed, 205 insertions(+), 6 deletions(-) diff --git a/.changeset/node-span-limits.md b/.changeset/node-span-limits.md index fbe06127fc..9c8a660f82 100644 --- a/.changeset/node-span-limits.md +++ b/.changeset/node-span-limits.md @@ -4,4 +4,4 @@ '@posthog/types': minor --- -Cap spans at 128 user attributes, 128 events and 8192 characters per string, configurable with `traces.maxAttributesPerSpan`, `traces.maxEventsPerSpan` and `traces.maxAttributeValueLength`. The earliest entries are kept, and a span that lost any reports how many as `droppedAttributesCount` and `droppedEventsCount`. The event cap is absolute, so an `exception` event the SDK records for you spends an ordinary slot. +Cap spans at 128 user attributes, 128 events, 128 attributes per event and 8192 characters per string, configurable with `traces.maxAttributesPerSpan`, `traces.maxEventsPerSpan`, `traces.maxAttributesPerEvent` and `traces.maxAttributeValueLength`. The earliest entries are kept, and a span that lost any reports how many as `droppedAttributesCount` and `droppedEventsCount`. The event cap is absolute, so an `exception` event the SDK records for you spends an ordinary slot. diff --git a/packages/core/src/traces/config.spec.ts b/packages/core/src/traces/config.spec.ts index 5237a0d52d..30ef7c623a 100644 --- a/packages/core/src/traces/config.spec.ts +++ b/packages/core/src/traces/config.spec.ts @@ -15,19 +15,24 @@ describe('resolveTracesConfig', () => { const resolved = resolveTracesConfig({ maxAttributesPerSpan: value, maxEventsPerSpan: value, + maxAttributesPerEvent: value, maxAttributeValueLength: value, }) expect(resolved.maxAttributesPerSpan).toBe(128) expect(resolved.maxEventsPerSpan).toBe(128) + expect(resolved.maxAttributesPerEvent).toBe(128) expect(resolved.maxAttributeValueLength).toBe(8192) }) it('honours explicit per-span caps', () => { // Without this the resolver can ignore maxEventsPerSpan entirely and every // other test still passes, because they all assert the default. - expect(resolveTracesConfig({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7 })).toMatchObject({ + expect( + resolveTracesConfig({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7, maxAttributesPerEvent: 9 }) + ).toMatchObject({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7, + maxAttributesPerEvent: 9, }) }) @@ -38,6 +43,7 @@ describe('resolveTracesConfig', () => { maxQueueSize: 2048, maxAttributesPerSpan: 128, maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, maxLiveSpans: 10_000, maxSpanAgeMs: 3_600_000, diff --git a/packages/core/src/traces/config.ts b/packages/core/src/traces/config.ts index dc60973a43..0d999997e8 100644 --- a/packages/core/src/traces/config.ts +++ b/packages/core/src/traces/config.ts @@ -11,6 +11,7 @@ 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 +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 @@ -124,6 +125,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: positiveInteger(config?.maxAttributesPerEvent, DEFAULT_MAX_ATTRIBUTES_PER_EVENT), maxAttributeValueLength: positiveInteger(config?.maxAttributeValueLength, DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH), flushIntervalMs: positiveInteger(config?.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS), maxExportBatchSize, diff --git a/packages/core/src/traces/index.spec.ts b/packages/core/src/traces/index.spec.ts index 0712ea99f8..696620e79f 100644 --- a/packages/core/src/traces/index.spec.ts +++ b/packages/core/src/traces/index.spec.ts @@ -22,6 +22,7 @@ const resolveForTest = (partial?: Partial): ResolvedTraces beforeSpanSend: [], maxAttributesPerSpan: 128, maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, maxLiveSpans: 10000, maxSpanAgeMs: 3600000, @@ -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({ diff --git a/packages/core/src/traces/index.ts b/packages/core/src/traces/index.ts index 5d67e00d81..2766535ab9 100644 --- a/packages/core/src/traces/index.ts +++ b/packages/core/src/traces/index.ts @@ -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, @@ -654,6 +655,7 @@ export class PostHogTraces { autoKeys, this._config.maxAttributesPerSpan, this._config.maxEventsPerSpan, + this._config.maxAttributesPerEvent, this._config.maxAttributeValueLength, keysBeforeHook ) diff --git a/packages/core/src/traces/otlp.spec.ts b/packages/core/src/traces/otlp.spec.ts index 641171df2d..e09d57f061 100644 --- a/packages/core/src/traces/otlp.spec.ts +++ b/packages/core/src/traces/otlp.spec.ts @@ -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. diff --git a/packages/core/src/traces/otlp.ts b/packages/core/src/traces/otlp.ts index 686b7e8e86..7d397f977c 100644 --- a/packages/core/src/traces/otlp.ts +++ b/packages/core/src/traces/otlp.ts @@ -105,6 +105,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 } diff --git a/packages/core/src/traces/span.spec.ts b/packages/core/src/traces/span.spec.ts index 2039adc43a..289d5eaf55 100644 --- a/packages/core/src/traces/span.spec.ts +++ b/packages/core/src/traces/span.spec.ts @@ -26,6 +26,7 @@ describe('PostHogSpan', () => { autoAttributeKeys: [], maxAttributes: 128, maxEvents: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, ...init, }, @@ -227,6 +228,73 @@ 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('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' @@ -900,6 +968,7 @@ describe('attribute store', () => { autoAttributeKeys: [], maxAttributes: 128, maxEvents: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, }, (record) => ended.push(record) @@ -926,6 +995,7 @@ describe('attribute store', () => { autoAttributeKeys: [], maxAttributes: 128, maxEvents: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, }, (record) => ended.push(record) diff --git a/packages/core/src/traces/span.ts b/packages/core/src/traces/span.ts index 3124945c9d..930857be8e 100644 --- a/packages/core/src/traces/span.ts +++ b/packages/core/src/traces/span.ts @@ -42,6 +42,7 @@ export interface SpanInit { autoAttributeKeys: string[] maxAttributes: number maxEvents: number + maxAttributesPerEvent: number maxAttributeValueLength: number } @@ -65,6 +66,7 @@ export class PostHogSpan implements Span { private readonly _autoKeys: Set private readonly _maxAttributes: number private readonly _maxEvents: number + private readonly _maxAttributesPerEvent: number private readonly _maxAttributeValueLength: number private _userAttributeCount = 0 private _userEventCount = 0 @@ -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 @@ -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 }), }), }) } @@ -342,6 +348,7 @@ export function applySpanLimits( autoKeys: ReadonlySet, maxAttributes: number, maxEvents: number, + maxAttributesPerEvent: number, maxAttributeValueLength: number, keysBeforeHook: readonly string[] = [] ): void { @@ -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) } @@ -697,6 +710,44 @@ 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 were refused. + * + * Keys past the cap are never read, so a wide object does not pay for the getters + * on values that are about to be dropped — the order `_writeAttribute` uses for + * the same reason. + */ +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 = {} + const kept = Math.min(keys.length, max) + for (let index = 0; index < kept; index++) { + const key = keys[index] + 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 + } + // 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: keys.length - kept } +} + /** `truncateAttributeValue` across an attribute bag, in place. */ export function truncateAttributes(attributes: SpanAttributes, maxLength: number): SpanAttributes { for (const key of Object.keys(attributes)) { diff --git a/packages/core/src/traces/types.ts b/packages/core/src/traces/types.ts index 7360c70a26..9e333c2b2c 100644 --- a/packages/core/src/traces/types.ts +++ b/packages/core/src/traces/types.ts @@ -63,6 +63,7 @@ export interface SpanEventRecord { /** ms epoch. */ timestamp: number attributes?: SpanAttributes + droppedAttributesCount?: number } /** @@ -72,6 +73,8 @@ export interface SpanEventRecord { * field `beforeSpanSend` cannot see, and so cannot corrupt. */ export interface SpanRecord extends HookSpanRecord { + /** The hook-visible event plus the count no hook may rewrite. */ + events: SpanEventRecord[] traceState?: string /** The W3C trace-flags byte this span propagates, e.g. `01` sampled. */ traceFlags: string @@ -108,6 +111,7 @@ export interface ResolvedTracesConfig extends TracesConfig { beforeSpanSend: BeforeSpanSendFn[] maxAttributesPerSpan: number maxEventsPerSpan: number + maxAttributesPerEvent: number maxAttributeValueLength: number /** Bound on spans started but not yet ended. At the bound `startSpan` returns a no-op handle. */ maxLiveSpans: number diff --git a/packages/types/src/traces.ts b/packages/types/src/traces.ts index eaf84e2309..350dc5ac36 100644 --- a/packages/types/src/traces.ts +++ b/packages/types/src/traces.ts @@ -326,6 +326,19 @@ export interface TracesConfig { */ maxEventsPerSpan?: number + /** + * Maximum attributes on a single span event. On overflow the first + * `maxAttributesPerEvent` are kept and later ones are dropped, with the + * number dropped reported on the exported event. + * + * `maxAttributesPerSpan` counts a span's own attributes and does not reach + * inside its events, so this is what bounds an event's width — including the + * `exception.*` attributes the SDK records for you. + * + * @default 128 + */ + maxAttributesPerEvent?: number + /** * Maximum length of a string attribute value. Longer values are truncated, * and the bound reaches every string the value contains, including the ones @@ -378,6 +391,8 @@ export interface OtlpSpanEvent { name: string timeUnixNano: string attributes?: OtlpSpanKeyValue[] + /** Attributes dropped by `maxAttributesPerEvent`. Omitted when none were. */ + droppedAttributesCount?: number } export interface OtlpSpanStatus { From 479689047d125d9f2de3b91e82d1b1d58aeb7f97 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 4 Sep 2026 16:22:56 -0400 Subject: [PATCH 2/4] fix(traces): stop a nullish value spending an event's attribute slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encoder drops a nullish attribute, so charging one against maxAttributesPerEvent let a blanked value evict a real one — the rule _writeAttribute and the span half of applySpanLimits already follow. Also corrects a comment claiming a hook cannot rewrite the per-event drop count, and the two ResolvedTracesConfig fixtures that stopped typechecking. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018AAPCyRnC5HbukEZV9hbai --- .changeset/node-span-limits.md | 2 +- .../rn-flags-disable-and-update-flags.md | 7 +++++ packages/core/src/traces/config.spec.ts | 4 +-- packages/core/src/traces/config.ts | 1 + packages/core/src/traces/live-spans.spec.ts | 1 + packages/core/src/traces/otlp.spec.ts | 1 + packages/core/src/traces/span.spec.ts | 28 +++++++++++++++++++ packages/core/src/traces/span.ts | 26 +++++++++++------ packages/core/src/traces/types.ts | 2 +- 9 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 .changeset/rn-flags-disable-and-update-flags.md diff --git a/.changeset/node-span-limits.md b/.changeset/node-span-limits.md index 9c8a660f82..85ccb33595 100644 --- a/.changeset/node-span-limits.md +++ b/.changeset/node-span-limits.md @@ -4,4 +4,4 @@ '@posthog/types': minor --- -Cap spans at 128 user attributes, 128 events, 128 attributes per event and 8192 characters per string, configurable with `traces.maxAttributesPerSpan`, `traces.maxEventsPerSpan`, `traces.maxAttributesPerEvent` and `traces.maxAttributeValueLength`. The earliest entries are kept, and a span that lost any reports how many as `droppedAttributesCount` and `droppedEventsCount`. The event cap is absolute, so an `exception` event the SDK records for you spends an ordinary slot. +Cap spans at 128 user attributes, 128 events, 128 attributes per event and 8192 characters per string, configurable with `traces.maxAttributesPerSpan`, `traces.maxEventsPerSpan`, `traces.maxAttributesPerEvent` and `traces.maxAttributeValueLength`. The earliest entries are kept, and a span that lost any reports how many as `droppedAttributesCount` and `droppedEventsCount`, as does an event that lost attributes. The event cap is absolute, so an `exception` event the SDK records for you spends an ordinary slot. diff --git a/.changeset/rn-flags-disable-and-update-flags.md b/.changeset/rn-flags-disable-and-update-flags.md new file mode 100644 index 0000000000..73879e9713 --- /dev/null +++ b/.changeset/rn-flags-disable-and-update-flags.md @@ -0,0 +1,7 @@ +--- +'@posthog/core': minor +'posthog-react-native': minor +'posthog-js-lite': minor +--- + +feat(flags): add the `advancedDisableFeatureFlags` option and a public `updateFlags(flags, payloads?, { merge? })` method, matching the web SDK's `advanced_disable_feature_flags` and `updateFlags`. With the option set, `reloadFeatureFlags()` and the reloads triggered by `identify()`, `group()`, `setPersonPropertiesForFlags()` and `reset()` become no-ops, and any flags request that still goes out for remote config or surveys carries `disable_flags: true` so the server skips flag evaluation. `updateFlags` supplies locally evaluated flag values (with payloads) at runtime: values persist, `getFeatureFlag()`/`getFeatureFlagPayload()` read them back, and `onFeatureFlags` listeners fire — so React Native session replay gated on a linked flag re-evaluates when flags are pushed in. diff --git a/packages/core/src/traces/config.spec.ts b/packages/core/src/traces/config.spec.ts index 30ef7c623a..03a07b6a64 100644 --- a/packages/core/src/traces/config.spec.ts +++ b/packages/core/src/traces/config.spec.ts @@ -11,7 +11,7 @@ describe('resolveTracesConfig', () => { ['a fraction', 1.5], ['a large fraction', 200.5], ['infinity', Infinity], - ])('falls back to the default per-span caps when given %s', (_label, value) => { + ])('falls back to the default caps when given %s', (_label, value) => { const resolved = resolveTracesConfig({ maxAttributesPerSpan: value, maxEventsPerSpan: value, @@ -24,7 +24,7 @@ describe('resolveTracesConfig', () => { expect(resolved.maxAttributeValueLength).toBe(8192) }) - it('honours explicit per-span caps', () => { + it('honours explicit per-span and per-event caps', () => { // Without this the resolver can ignore maxEventsPerSpan entirely and every // other test still passes, because they all assert the default. expect( diff --git a/packages/core/src/traces/config.ts b/packages/core/src/traces/config.ts index 0d999997e8..a34b05f648 100644 --- a/packages/core/src/traces/config.ts +++ b/packages/core/src/traces/config.ts @@ -11,6 +11,7 @@ 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 default, which is the same number. 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 diff --git a/packages/core/src/traces/live-spans.spec.ts b/packages/core/src/traces/live-spans.spec.ts index 47ab14c5d9..3e9815bafe 100644 --- a/packages/core/src/traces/live-spans.spec.ts +++ b/packages/core/src/traces/live-spans.spec.ts @@ -23,6 +23,7 @@ describe('live spans', () => { beforeSpanSend: [], maxAttributesPerSpan: 128, maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, } diff --git a/packages/core/src/traces/otlp.spec.ts b/packages/core/src/traces/otlp.spec.ts index e09d57f061..4c926668f3 100644 --- a/packages/core/src/traces/otlp.spec.ts +++ b/packages/core/src/traces/otlp.spec.ts @@ -167,6 +167,7 @@ describe('OTLP span encoding', () => { beforeSpanSend: [], maxAttributesPerSpan: 128, maxEventsPerSpan: 128, + maxAttributesPerEvent: 128, maxAttributeValueLength: 8192, maxLiveSpans: 10000, maxSpanAgeMs: 3600000, diff --git a/packages/core/src/traces/span.spec.ts b/packages/core/src/traces/span.spec.ts index 289d5eaf55..2733952119 100644 --- a/packages/core/src/traces/span.spec.ts +++ b/packages/core/src/traces/span.spec.ts @@ -280,6 +280,34 @@ describe('PostHogSpan', () => { 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. diff --git a/packages/core/src/traces/span.ts b/packages/core/src/traces/span.ts index 930857be8e..16be19e1e4 100644 --- a/packages/core/src/traces/span.ts +++ b/packages/core/src/traces/span.ts @@ -712,11 +712,10 @@ function resolveToJson(value: object): { selfDescribed: boolean; value?: SpanAtt /** * A copy of a caller-supplied attribute bag holding at most `max` entries, each - * value bounded to `maxLength`, plus how many entries were refused. + * value bounded to `maxLength`, plus how many entries the cap refused. * - * Keys past the cap are never read, so a wide object does not pay for the getters - * on values that are about to be dropped — the order `_writeAttribute` uses for - * the same reason. + * 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, @@ -731,9 +730,13 @@ function boundAttributes( return { attributes: {}, dropped: 0 } } const attributes: SpanAttributes = {} - const kept = Math.min(keys.length, max) - for (let index = 0; index < kept; index++) { - const key = keys[index] + 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) @@ -741,11 +744,18 @@ function boundAttributes( // 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: keys.length - kept } + return { attributes, dropped } } /** `truncateAttributeValue` across an attribute bag, in place. */ diff --git a/packages/core/src/traces/types.ts b/packages/core/src/traces/types.ts index 9e333c2b2c..83e995f3fe 100644 --- a/packages/core/src/traces/types.ts +++ b/packages/core/src/traces/types.ts @@ -73,7 +73,7 @@ export interface SpanEventRecord { * field `beforeSpanSend` cannot see, and so cannot corrupt. */ export interface SpanRecord extends HookSpanRecord { - /** The hook-visible event plus the count no hook may rewrite. */ + /** The hook-visible event plus the SDK's own per-event drop count. */ events: SpanEventRecord[] traceState?: string /** The W3C trace-flags byte this span propagates, e.g. `01` sampled. */ From 1ce3bc3b46a95ac7dc05dd262f333193d216a2d0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 4 Sep 2026 16:29:51 -0400 Subject: [PATCH 3/4] chore: drop an unrelated changeset committed by mistake rn-flags-disable-and-update-flags belongs to separate RN work; it is preserved in the stash it came from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018AAPCyRnC5HbukEZV9hbai --- .changeset/rn-flags-disable-and-update-flags.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/rn-flags-disable-and-update-flags.md diff --git a/.changeset/rn-flags-disable-and-update-flags.md b/.changeset/rn-flags-disable-and-update-flags.md deleted file mode 100644 index 73879e9713..0000000000 --- a/.changeset/rn-flags-disable-and-update-flags.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@posthog/core': minor -'posthog-react-native': minor -'posthog-js-lite': minor ---- - -feat(flags): add the `advancedDisableFeatureFlags` option and a public `updateFlags(flags, payloads?, { merge? })` method, matching the web SDK's `advanced_disable_feature_flags` and `updateFlags`. With the option set, `reloadFeatureFlags()` and the reloads triggered by `identify()`, `group()`, `setPersonPropertiesForFlags()` and `reset()` become no-ops, and any flags request that still goes out for remote config or surveys carries `disable_flags: true` so the server skips flag evaluation. `updateFlags` supplies locally evaluated flag values (with payloads) at runtime: values persist, `getFeatureFlag()`/`getFeatureFlagPayload()` read them back, and `onFeatureFlags` listeners fire — so React Native session replay gated on a linked flag re-evaluates when flags are pushed in. From a9e76dcfa84adf4170902fdfb37b59f688e30e34 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 4 Sep 2026 17:47:29 -0400 Subject: [PATCH 4/4] refactor(traces): keep the per-event attribute cap internal The cap is not one of the knobs the traces spec enumerates, so it stays a fixed 128 instead of a public `maxAttributesPerEvent` option. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HQy8eimnfV3jVQKbzELc65 --- packages/core/src/traces/config.spec.ts | 16 ++++++++-------- packages/core/src/traces/config.ts | 7 +++++-- packages/types/src/traces.ts | 19 +++++-------------- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/core/src/traces/config.spec.ts b/packages/core/src/traces/config.spec.ts index 03a07b6a64..eaef6082bc 100644 --- a/packages/core/src/traces/config.spec.ts +++ b/packages/core/src/traces/config.spec.ts @@ -11,31 +11,31 @@ describe('resolveTracesConfig', () => { ['a fraction', 1.5], ['a large fraction', 200.5], ['infinity', Infinity], - ])('falls back to the default caps when given %s', (_label, value) => { + ])('falls back to the default per-span caps when given %s', (_label, value) => { const resolved = resolveTracesConfig({ maxAttributesPerSpan: value, maxEventsPerSpan: value, - maxAttributesPerEvent: value, maxAttributeValueLength: value, }) expect(resolved.maxAttributesPerSpan).toBe(128) expect(resolved.maxEventsPerSpan).toBe(128) - expect(resolved.maxAttributesPerEvent).toBe(128) expect(resolved.maxAttributeValueLength).toBe(8192) }) - it('honours explicit per-span and per-event caps', () => { + it('honours explicit per-span caps', () => { // Without this the resolver can ignore maxEventsPerSpan entirely and every // other test still passes, because they all assert the default. - expect( - resolveTracesConfig({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7, maxAttributesPerEvent: 9 }) - ).toMatchObject({ + expect(resolveTracesConfig({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7 })).toMatchObject({ maxAttributesPerSpan: 5, maxEventsPerSpan: 7, - maxAttributesPerEvent: 9, }) }) + 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, diff --git a/packages/core/src/traces/config.ts b/packages/core/src/traces/config.ts index fd81bfc34f..cf7faca372 100644 --- a/packages/core/src/traces/config.ts +++ b/packages/core/src/traces/config.ts @@ -11,7 +11,10 @@ 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 default, which is the same number. +// 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 @@ -123,7 +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: positiveInteger(config?.maxAttributesPerEvent, DEFAULT_MAX_ATTRIBUTES_PER_EVENT), + maxAttributesPerEvent: DEFAULT_MAX_ATTRIBUTES_PER_EVENT, maxAttributeValueLength: positiveInteger(config?.maxAttributeValueLength, DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH), flushIntervalMs: positiveInteger(config?.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS), maxExportBatchSize, diff --git a/packages/types/src/traces.ts b/packages/types/src/traces.ts index 350dc5ac36..1881574ef1 100644 --- a/packages/types/src/traces.ts +++ b/packages/types/src/traces.ts @@ -120,6 +120,10 @@ export interface Span { /** * Record a timestamped event within the span, e.g. a cache miss or a retry. * Defaults to the current time. + * + * One event carries at most 128 attributes; further keys are dropped and + * counted on the exported event. Use `maxEventsPerSpan` to bound how many + * events a span carries. */ addEvent(name: string, attributes?: SpanAttributes, timestamp?: SpanTimeInput): this @@ -326,19 +330,6 @@ export interface TracesConfig { */ maxEventsPerSpan?: number - /** - * Maximum attributes on a single span event. On overflow the first - * `maxAttributesPerEvent` are kept and later ones are dropped, with the - * number dropped reported on the exported event. - * - * `maxAttributesPerSpan` counts a span's own attributes and does not reach - * inside its events, so this is what bounds an event's width — including the - * `exception.*` attributes the SDK records for you. - * - * @default 128 - */ - maxAttributesPerEvent?: number - /** * Maximum length of a string attribute value. Longer values are truncated, * and the bound reaches every string the value contains, including the ones @@ -391,7 +382,7 @@ export interface OtlpSpanEvent { name: string timeUnixNano: string attributes?: OtlpSpanKeyValue[] - /** Attributes dropped by `maxAttributesPerEvent`. Omitted when none were. */ + /** Attributes dropped by the SDK's per-event attribute cap. Omitted when none were. */ droppedAttributesCount?: number }