Skip to content

feat(ofrep-web): add SSE event stream support (#1497)#1509

Open
marcozabel wants to merge 21 commits intoopen-feature:mainfrom
open-feature-forking:feat/ofrep-web-sse-support
Open

feat(ofrep-web): add SSE event stream support (#1497)#1509
marcozabel wants to merge 21 commits intoopen-feature:mainfrom
open-feature-forking:feat/ofrep-web-sse-support

Conversation

@marcozabel
Copy link
Copy Markdown
Contributor

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

This PR

  • adds this new feature

Related Issues

#1497

Notes

Follow-up Tasks

How to test

@marcozabel marcozabel force-pushed the feat/ofrep-web-sse-support branch from b095230 to 65bfa43 Compare April 8, 2026 09:16
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>
@marcozabel marcozabel force-pushed the feat/ofrep-web-sse-support branch from 65bfa43 to a7ccda1 Compare April 8, 2026 09:18
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts
Comment thread libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
@github-actions github-actions bot requested review from beeme1mr and toddbaert April 8, 2026 09:51
@marcozabel marcozabel marked this pull request as ready for review April 8, 2026 14:00
@marcozabel marcozabel requested review from a team as code owners April 8, 2026 14:00
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>
@marcozabel marcozabel force-pushed the feat/ofrep-web-sse-support branch from f9fbf00 to ed91874 Compare April 9, 2026 09:14
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();
});
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.

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?

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.

I think maybe we want something like a "grace-period" here - where we transition from STALE to ERROR.

cc @jonathannorris @lukas-reining

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I drafted a a grace period. Let me know what you think 👍

Comment thread libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts Outdated
@toddbaert
Copy link
Copy Markdown
Member

@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.

Comment thread libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
Copy link
Copy Markdown
Member

@toddbaert toddbaert left a comment

Choose a reason for hiding this comment

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

I left a few comments. I think there's a couple deviations from the ADR, but definitely on the right track!

@toddbaert toddbaert requested a review from thomaspoignant April 9, 2026 18:31
* If SSE connects, stop polling; if no SSE, does not start polling
* (that is handled by the caller).
*/
private _connectSseIfAvailable(result: EvaluateFlagsResponse): void {
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: but should we use a capitalized SSE for all these functions?

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.

The major style guides I'm familiar with would recommend sseConnection - (ie: treat the acronym as a word with only the first char capitalized)

That said I don't really care.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@marcozabel
Copy link
Copy Markdown
Contributor Author

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
Comment thread libs/providers/ofrep-web/src/lib/sse-manager.ts Outdated
marcozabel and others added 2 commits April 15, 2026 14:01
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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what is the _pollingIntervalId?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

@toddbaert toddbaert left a comment

Choose a reason for hiding this comment

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

All my concerns have been addressed.

* Providers construct the URL as `origin + requestUri`.
*/
export interface EventStreamEndpoint {
origin: string;
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.

Per ADR-0008 / openapi.yaml, endpoint.origin is optional — when absent, providers should fall back to the configured OFREP baseUrl origin.

Two issues:

  1. origin is typed as required here.
  2. In sse-manager.ts, new URL(requestUri, stream.endpoint.origin) will throw when origin is undefined, and SseManager has no baseUrl to 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 },
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: 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}`;
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.

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;
};
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.

ADR-0008 Implementation Notes recommend a changeDetection option with values sse (default) / polling / none:

  • polling ignores eventStreams entirely
  • none disables all background refresh (only onContextChange triggers 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(),
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.

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();
}
}

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.

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>
@marcozabel marcozabel force-pushed the feat/ofrep-web-sse-support branch from 6f7f2c1 to b3dd931 Compare April 20, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants