feat(ofrep-web): add SSE event stream support (#1497)#1509
feat(ofrep-web): add SSE event stream support (#1497)#1509marcozabel wants to merge 21 commits intoopen-feature:mainfrom
Conversation
b095230 to
65bfa43
Compare
Implement real-time flag change notifications via Server-Sent Events per ADR-0008. The provider now parses the optional `eventStreams` array from bulk evaluation responses and connects to SSE endpoints using the native EventSource API. Changes: - Add EventStream types to ofrep-core (BulkEvaluationSuccessResponse) - Add SseManager class handling EventSource lifecycle, event coalescing, and inactivity timeout via Page Visibility API - Extend postBulkEvaluateFlags with flagConfigEtag/flagConfigLastModified query params for SSE-triggered re-fetches - Integrate SseManager into OFREPWebProvider with automatic fallback to polling when SSE is unavailable - Add inactivityDelaySec option to OFREPWebProviderOptions - Add 20 new tests covering SSE connection, refetch, coalescing, inactivity timeout, error fallback, and context change handling Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
65bfa43 to
a7ccda1
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces Server-Sent Events (SSE) support to the OFREP web provider for real-time flag updates, including a new SseManager to handle connection lifecycles and inactivity timeouts. The feedback highlights critical issues where polling might not resume correctly after a context change or an SSE error due to uncleared interval IDs. Additionally, there are suggestions to improve URL resolution using the URL constructor and to enhance logging by including the failed connection URL.
When onContextChange receives a response without eventStreams after previously having SSE active, restart polling to maintain real-time updates. Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
…) did not reset _pollingIntervalId to undefined after clearing the interval. This prevented polling from restarting when SSE errors triggered a fallback, since the guard check would see a stale value. Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
- Use URL constructor for robust URL resolution instead of string concat - Include URL in SSE connection error log message for debugging Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
f9fbf00 to
ed91874
Compare
Signed-off-by: Marco Zabel <marco.zabel@dynatrace.com>
| // EventSource auto-reconnects; if all connections fail, | ||
| // the provider-level error callback triggers polling fallback. | ||
| this._callbacks.onError(); | ||
| }); |
There was a problem hiding this comment.
EventSource auto-reconnects on transient errors (MDN reference). Should _callbacks.onError() only fire when es.readyState === EventSource.CLOSED (i.e. the browser has given up), rather than on every error event? As-is, a single transient blip will tear down SSE and fall back to polling, which conflicts with the goal of delegating reconnection to the EventSource built-in backoff. Also worth considering: should SSE errors emit a PROVIDER_STALE event in this case?
There was a problem hiding this comment.
I think maybe we want something like a "grace-period" here - where we transition from STALE to ERROR.
There was a problem hiding this comment.
I drafted a a grace period. Let me know what you think 👍
|
@marcozabel the CI failure is pre-existing, some dep-linting error that somehow got passed CI before. I've fixed it in main. Rebasing here should resolve it. |
toddbaert
left a comment
There was a problem hiding this comment.
I left a few comments. I think there's a couple deviations from the ADR, but definitely on the right track!
| * If SSE connects, stop polling; if no SSE, does not start polling | ||
| * (that is handled by the caller). | ||
| */ | ||
| private _connectSseIfAvailable(result: EvaluateFlagsResponse): void { |
There was a problem hiding this comment.
nitpick: but should we use a capitalized SSE for all these functions?
There was a problem hiding this comment.
The major style guides I'm familiar with would recommend sseConnection - (ie: treat the acronym as a word with only the first char capitalized)
- https://google.github.io/styleguide/tsguide.html
- https://github.com/airbnb/javascript#naming--camelCase
That said I don't really care.
There was a problem hiding this comment.
I'm in favor of treating it as a name 😄
Since inactivityDelaySec comes from parsed JSON, it could be null at runtime. Use != null instead of !== undefined for defensive null/undefined handling. Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Replace imperative for-loops with reduce for URL collection and forEach for URL iteration. Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Per ADR-0008, SSE URLs may contain auth tokens or channel credentials and must not be logged. Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements real-time flag change notifications via Server-Sent Events (SSE) in the OFREP web provider, following ADR-0008. Key additions include an SseManager to handle the lifecycle of EventSource connections, support for inactivity timeouts based on document visibility, and logic to switch between polling and SSE. The core API was also updated to include SSE metadata in bulk evaluation requests. The review feedback suggests improving code maintainability by using built-in EventSource constants instead of magic numbers for connection state checks.
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Marco Zabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
| // Context change: re-fetch without SSE metadata | ||
| const result = await this._fetchFlags(newContext); | ||
| this._connectSseIfAvailable(result); | ||
| if (!this._sseManager && this._pollingInterval > 0 && !this._pollingIntervalId) { |
There was a problem hiding this comment.
When creating the polling, we use setInterval to periodically make requests. This method returns the id of the interval for later reference and we store it in _pollingIntervalId.
toddbaert
left a comment
There was a problem hiding this comment.
All my concerns have been addressed.
| * Providers construct the URL as `origin + requestUri`. | ||
| */ | ||
| export interface EventStreamEndpoint { | ||
| origin: string; |
There was a problem hiding this comment.
Per ADR-0008 / openapi.yaml, endpoint.origin is optional — when absent, providers should fall back to the configured OFREP baseUrl origin.
Two issues:
originis typed as required here.- In
sse-manager.ts,new URL(requestUri, stream.endpoint.origin)will throw whenoriginis undefined, andSseManagerhas nobaseUrlto fall back to.
Suggest making origin?: string and passing baseUrl into SseManager for the fallback.
| public async postBulkEvaluateFlags( | ||
| requestBody?: EvaluationRequest, | ||
| etag: string | null = null, | ||
| sseMetadata?: { flagConfigEtag?: string; flagConfigLastModified?: string | number }, |
There was a problem hiding this comment.
Minor: consider exporting a named type (e.g. BulkEvaluationRefetchMetadata) from ofrep-core instead of an inline structural type here. The equivalent SseRefetchMetadata lives in ofrep-web, which means any future non-web OFREP provider would duplicate it. Since this is the shared package's public API, the type belongs alongside EventStream.
| } | ||
| const qs = params.toString(); | ||
| if (qs) { | ||
| url = url + `?${qs}`; |
There was a problem hiding this comment.
No test currently asserts that flagConfigEtag / flagConfigLastModified actually appear on the outgoing request URL — the existing tests stop at the callback/argument layer. Since these query params are the spec conformance surface (ADR-0008 §SSE metadata transport), worth adding a test in ofrep-api.spec.ts that intercepts the fetch and asserts the URL.
Also worth covering that pre-existing baseOptions.query params are still preserved after the switch to URLSearchParams here.
| * If neither is set, defaults to 120 seconds. | ||
| */ | ||
| inactivityDelaySec?: number; | ||
| }; |
There was a problem hiding this comment.
ADR-0008 Implementation Notes recommend a changeDetection option with values sse (default) / polling / none:
pollingignoreseventStreamsentirelynonedisables all background refresh (onlyonContextChangetriggers refetch)
Currently there's no way to force polling when a server returns eventStreams, and no way to disable background refresh. Worth adding, or tracking as a follow-up so other OFREP providers (Java, Swift, etc.) align on the same option name.
| { | ||
| onRefetch: (metadata) => this._handleSseRefetch(metadata), | ||
| onStale: () => this._handleSseStale(), | ||
| onError: () => this._handleSseError(), |
There was a problem hiding this comment.
Per ADR-0008, when onContextChange triggers a re-fetch and the eventStream URL set is unchanged, existing connections should be reused. The current SseManager.connect unconditionally calls disconnect() first, so every context change tears down and reconnects even when the URLs are identical.
Recommend diffing the incoming URL set against this._urls and skipping the teardown when they match. Without this, every context change produces a brief SSE dark window where a refetchEvaluation event could be missed, plus an avoidable EventSource round-trip. This matters more for providers that frequently switch context (e.g. multi-user web apps).
| this.startPolling(); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
ADR-0008 says: "if polling is disabled, continue SSE reconnection attempts and rely on explicit refresh triggers such as onContextChange."
Currently when pollInterval === 0 and SSE hits a fatal error (readyState === CLOSED), this handler falls through without doing anything — and since native EventSource doesn't auto-reconnect from CLOSED, the provider is silently stuck on cached flags until onContextChange fires.
Recommend adding a retry loop with backoff on the else branch so SSE reconnection continues when polling is off. Worth a test too, since this is the hard case the ADR explicitly calls out.
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
…eFlags Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
…is absent Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
…sabled Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
6f7f2c1 to
b3dd931
Compare
Implement real-time flag change notifications via Server-Sent Events per ADR-0008. The provider now parses the optional
eventStreamsarray from bulk evaluation responses and connects to SSE endpoints using the native EventSource API.Changes:
This PR
Related Issues
#1497
Notes
Follow-up Tasks
How to test