-
Notifications
You must be signed in to change notification settings - Fork 407
feat(openfeature): improve exposure buffering #9742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
|
|
@@ -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) | ||||||
| } else { | ||||||
| this._buffer[this._bufferStart] = event | ||||||
| this._bufferStart = (this._bufferStart + 1) % this._bufferLimit | ||||||
|
Comment on lines
+106
to
+107
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.', | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
| 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) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an exposure or its 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) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||
| this.#send(batch) | ||||||
| batch = [] | ||||||
| batchSize = 0 | ||||||
| } | ||||||
|
|
||||||
| batch.push(event) | ||||||
| batchSize += eventSize | ||||||
| } | ||||||
|
|
||||||
| if (batch.length > 0) { | ||||||
| this.#send(batch) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
@@ -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. | ||||||
| * | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -63,14 +57,14 @@ class ExposuresWriter extends BaseFFEWriter { | |
| // Disabled until route selection resolves. | ||
| #enabled = false | ||
|
|
||
| /** @type {ExposureEvent[]} */ | ||
| #pendingEvents = [] | ||
| #routeResolved = false | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor:
The most accurate name for the field is |
||
|
|
||
| /** @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 | ||
|
|
@@ -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 | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
EDIT: no, the |
||
| } | ||
|
|
||
| /** | ||
| * @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) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.