Skip to content
Open
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
125 changes: 95 additions & 30 deletions packages/dd-trace/src/openfeature/writers/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ class BaseFFEWriter {

this._buffer = []
this._bufferLimit = 1000
this._bufferSize = 0
this._bufferStart = 0
this._dropWarningLogged = false

this._config = config
this._endpoint = endpoint
Expand Down Expand Up @@ -99,51 +100,87 @@ class BaseFFEWriter {
const eventArray = Array.isArray(events) ? events : [events]

for (const event of eventArray) {
if (this._buffer.length >= this._bufferLimit) {
log.warn('%s event buffer full (limit is %d), dropping event', this.constructor.name, this._bufferLimit)
this._droppedEvents++
continue
}

const eventSizeBytes = Buffer.byteLength(JSON.stringify(event))

// Check individual event size limit if configured
if (this._eventSizeLimit && eventSizeBytes > this._eventSizeLimit) {
log.warn('%s event size %d bytes exceeds limit %d, dropping event',
this.constructor.name, eventSizeBytes, this._eventSizeLimit)
if (this._buffer.length < this._bufferLimit) {
this._buffer.push(event)
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Filter oversized events before occupying the bounded queue

Because size validation now happens only during flush(), events over the 1 MB limit still consume slots and replace valid older exposures in this ring. For example, if the queue contains 1,000 valid events and a synchronous burst of oversized evaluation contexts arrives before the timer runs, those invalid events evict the valid batch and are then themselves discarded during flushing, potentially delivering nothing; the count cap also no longer prevents a burst of large unique objects from consuming excessive memory. Oversized events must not be allowed to displace sendable entries in the bounded queue.

Useful? React with 👍 / 👎.

} else {
this._buffer[this._bufferStart] = event
this._bufferStart = (this._bufferStart + 1) % this._bufferLimit
Comment on lines +106 to +107

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: is there a significant reason to prefer recent events? This seems to introduce more complexity and room for error.

Dropping newer events is also very slightly faster (because allocators optimize for young objects dying young).

this._droppedEvents++
continue
}

// Check if adding this event would exceed payload size limit if configured
if (this._payloadSizeLimit && this._bufferSize + eventSizeBytes > this._payloadSizeLimit) {
log.debug('%s buffer size would exceed %d bytes, flushing first', this.constructor.name, this._payloadSizeLimit)
this.flush()
if (!this._dropWarningLogged) {
this._dropWarningLogged = true
log.warn(
'%s dropped exposure event(s) at cap %d. This may invalidate experiment results.',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: exposure-specific logging in a potentially shared "base" writer. It doesn't look like it's actually shared, so maybe we should merge the two — this would make the interaction between BaseFFEWriter and ExposuresWriter easier to follow

this.constructor.name,
this._bufferLimit
)
}
}

this._bufferSize += eventSizeBytes
this._buffer.push(event)
}
}

/**
* Flushes all buffered events to the agent
* Sizes, batches, and flushes all buffered events.
*/
flush () {
if (this._buffer.length === 0) {
return
}
const events = this._buffer

const events = this._bufferStart === 0
? this._buffer
: [...this._buffer.slice(this._bufferStart), ...this._buffer.slice(0, this._bufferStart)]
this._buffer = []
this._bufferSize = 0
this._bufferStart = 0

const payload = this._encode(this.makePayload(events))
let batch = []
let batchSize = 0

// eslint-disable-next-line eslint-rules/eslint-log-printf-style
log.debug(() => `${this.constructor.name} flushing payload: ${safeJSONStringify(payload)}`)
for (const event of events) {
let eventSize
try {
eventSize = Buffer.byteLength(JSON.stringify(event))
} catch (error) {
log.warn('%s could not serialize an event, dropping event: %s', this.constructor.name, error.message)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid rethrowing non-Error serialization failures

When an exposure or its subject.attributes has a toJSON/getter that throws null or undefined, this catch block immediately throws a new TypeError while reading error.message; because flush() also runs from the interval and beforeExit, that exception can escape into and crash the instrumented application instead of dropping the malformed event. Log the caught value without assuming it is an Error, including in the identical catch inside #send.

AGENTS.md reference: AGENTS.md:L222-L225

Useful? React with 👍 / 👎.

this._droppedEvents++
continue
}

const route = this.#createActiveRoute()
this.#sendRequest(payload, events.length, route, this._fallbackRoute)
if (this._eventSizeLimit && eventSize > this._eventSizeLimit) {
log.warn(
'%s event size %d bytes exceeds limit %d, dropping event',
this.constructor.name,
eventSize,
this._eventSizeLimit
)
this._droppedEvents++
continue
}

if (this._payloadSizeLimit && eventSize > this._payloadSizeLimit) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I was a bit confused by two almost identical checks. One way to generalize this is to make sure that the event size limit is ≤ payload size limit (which should always be the case), so we can drop the second check.

In constructor:

if (this._payloadSizeLimit) {
  this._eventSizeLimit = Math.min(this._eventSizeLimit || Infinity, this._payloadSizeLimit)
}

log.warn(
'%s event size %d bytes exceeds payload limit %d, dropping event',
this.constructor.name,
eventSize,
this._payloadSizeLimit
)
this._droppedEvents++
continue
}

if (batch.length > 0 && this._payloadSizeLimit && batchSize + eventSize > this._payloadSizeLimit) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: the piece here looks like it would allow a single event to exceed the whole payload size limit (when batch.length === 0 && batchSize + eventSize > this._payloadSizeLimit).

batch.length > 0 could be removed because that's an impossible case. batchSize + eventSize > this._payloadSizeLimit implies batch is not empty

Suggested change
if (batch.length > 0 && this._payloadSizeLimit && batchSize + eventSize > this._payloadSizeLimit) {
if (this._payloadSizeLimit && batchSize + eventSize > this._payloadSizeLimit) {

this.#send(batch)
batch = []
batchSize = 0
}

batch.push(event)
batchSize += eventSize
}

if (batch.length > 0) {
this.#send(batch)
}
}

/**
Expand Down Expand Up @@ -194,6 +231,34 @@ class BaseFFEWriter {
this._fallbackRoute = fallbackRoute ? this.#createRoute(fallbackRoute) : undefined
}

/**
* Sends one event batch.
*
* @param {Array<object>} events - Events in the batch
* @returns {void}
*/
#send (events) {
let payload
try {
payload = this._encode(this.makePayload(events))
} catch (error) {
log.warn(
'%s could not encode %d event(s), dropping batch: %s',
this.constructor.name,
events.length,
error.message
)
this._droppedEvents += events.length
return
}

// eslint-disable-next-line eslint-rules/eslint-log-printf-style
log.debug(() => `${this.constructor.name} flushing payload: ${safeJSONStringify(payload)}`)

const route = this.#createActiveRoute()
this.#sendRequest(payload, events.length, route, this._fallbackRoute)
}

/**
* Creates request state for a configured writer route.
*
Expand Down
89 changes: 47 additions & 42 deletions packages/dd-trace/src/openfeature/writers/exposures.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,8 @@ const {
EVP_SUBDOMAIN_HEADER_NAME,
} = require('../../evp_proxy/constants')
const { joinEVPProxyPath } = require('../../evp_proxy/path')
const log = require('../../log')
const BaseFFEWriter = require('./base')

// Disabled-state cap. Drops invalidate experiment results because the provider's
// exposure dedupe cache keeps masking dropped events after recovery. The first
// drop emits a warning and `droppedEventCount` accumulates the cumulative loss.
const PENDING_MAX_EVENTS = 1000

/**
* @typedef {object} ExposureRoute
* @property {URL} url - Route base URL
Expand Down Expand Up @@ -63,14 +57,14 @@ class ExposuresWriter extends BaseFFEWriter {
// Disabled until route selection resolves.
#enabled = false

/** @type {ExposureEvent[]} */
#pendingEvents = []
#routeResolved = false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: #routeResolved is a confusing name and actually seems to mean something other than it says:

  1. route can be set in constructor but #routeResolved is not set here
  2. setEnabled can be called with empty/missing route but #routeResolved is set here regardless

The most accurate name for the field is #setEnabledCalled but maybe it should be #isInitializing or similar


/** @type {ReturnType<typeof setImmediate> | undefined} */
#startupFlushImmediate

/** @type {ExposureContext} */
#context

#dropWarned = false

/**
* @param {import('../../config/config-base')} config - Tracer configuration object
* @param {ExposureRoute} [route] - Caller-supplied route
Expand Down Expand Up @@ -116,16 +110,20 @@ class ExposuresWriter extends BaseFFEWriter {
* @returns {void}
*/
setEnabled (enabled, route) {
this.#routeResolved = true

if (route) {
this.#setRoute(route)
}

this.#enabled = enabled

if (enabled && this.#pendingEvents.length > 0) {
// Flush all pending events as a batch
super.append(this.#pendingEvents)
this.#pendingEvents = []
if (enabled) {
this.#scheduleStartupFlush()
} else {
this.#cancelStartupFlush()
this._buffer = []
this._bufferStart = 0
}
}

Expand Down Expand Up @@ -155,50 +153,57 @@ class ExposuresWriter extends BaseFFEWriter {
}

/**
* Appends exposure event(s) to the buffer
* Appends exposure event(s) to the buffer.
*
* @param {ExposureEvent|ExposureEvent[]} events - Exposure event(s) to append
* @returns {void}
*/
append (events) {
if (this.#enabled) {
super.append(events)
return
}
if (this.#routeResolved && !this.#enabled) return

const eventArray = Array.isArray(events) ? events : [events]
this.#pendingEvents.push(...eventArray)
if (this.#pendingEvents.length > PENDING_MAX_EVENTS) {
const dropped = this.#pendingEvents.length - PENDING_MAX_EVENTS
this.#pendingEvents.splice(0, dropped)
this._droppedEvents += dropped
if (!this.#dropWarned) {
this.#dropWarned = true
log.warn(
'%s dropped exposure event(s) at cap %d. This may invalidate experiment results.',
this.constructor.name, PENDING_MAX_EVENTS)
}
}
super.append(events)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major (🐛): the base writer will try to periodically flush these events, ignoring #enabled. If a flush attempt happens before setEnabled is called, this will likely lead to loosing events (or them going through?) — not the result we want in either case

EDIT: no, the flush is overridden to skip flush when #enabled is false. I hate implementation inheritance. This is another place where the flow is convoluted — merging two classes would simplify it

}

/**
* @returns {number} Cumulative number of exposure events dropped due to buffer overflow.
* Flushes buffered exposure events through the selected route.
*
* @returns {void}
*/
get droppedEventCount () {
return this._droppedEvents
flush () {
if (!this.#enabled) return

this.#cancelStartupFlush()
super.flush()
}

/**
* Flushes buffered exposure events to the agent
* Flushes startup events after route selection completes.
*
* @returns {void}
*/
flush () {
if (!this.#enabled) {
return
}
super.flush()
#scheduleStartupFlush () {
if (this.#startupFlushImmediate || this._buffer.length === 0) return

this.#startupFlushImmediate = setImmediate(() => {
this.#startupFlushImmediate = undefined
this.flush()
})
}

/**
* Cancels a scheduled drain.
*
* @returns {void}
*/
#cancelStartupFlush () {
if (!this.#startupFlushImmediate) return
clearImmediate(this.#startupFlushImmediate)
this.#startupFlushImmediate = undefined
}

/**
* Formats exposure events with service context metadata
* @param {Array<ExposureEvent>} events - Array of exposure events
* @param {Array<ExposureEvent>} events - Array of exposure events to format
* @returns {ExposureEventPayload} Formatted payload with service context
*/
makePayload (events) {
Expand Down
Loading
Loading