Skip to content

feat: SSE support for OFREP endpoints - #2012

Open
JamieSinn wants to merge 5 commits into
open-feature:mainfrom
JamieSinn:sse-server
Open

feat: SSE support for OFREP endpoints#2012
JamieSinn wants to merge 5 commits into
open-feature:mainfrom
JamieSinn:sse-server

Conversation

@JamieSinn

@JamieSinn JamieSinn commented Aug 4, 2026

Copy link
Copy Markdown
Member

This PR

The implementation of the wire protocol/message format is the bare SSE object that was defined in ADR-0008, not a custom protocol.
The SSE server is using the LaunchDarkly EventSource server which we've proven in DevCycle/Dynatrace that it's quite stable and has the ideal functionality that we want (channels, separation, and highly performant).

Related Issues

open-feature/protocol#63

Notes

The URL path for this /ofrep/v1/sse is very open to discussion - I have no strong opinions on this, but this just felt the most logical.

How to test

This was tested manually; I can add a script or something to the test folder if that's easier.

Signed-off-by: Jamie Sinn <james.sinn@dynatrace.com>
Signed-off-by: Jamie Sinn <james.sinn@dynatrace.com>
@JamieSinn
JamieSinn requested review from a team as code owners August 4, 2026 21:01
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Aug 4, 2026
@netlify

netlify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploy Preview for polite-licorice-3db33c canceled.

Name Link
🔨 Latest commit f121b83
🔍 Latest deploy log https://app.netlify.com/projects/polite-licorice-3db33c/deploys/6a725a83645ba90008bec017

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

OFREP now supports optional SSE event-stream advertisements and refetchEvaluation notifications. The implementation adds configuration, version tracking, ETags, selector-specific endpoints, heartbeats, and conditional bulk responses.

Changes

OFREP SSE support

Layer / File(s) Summary
SSE contracts and configuration
core/pkg/service/ofrep/models.go, core/pkg/store/query.go, flagd/cmd/start.go, flagd/pkg/runtime/from_config.go, flagd/go.mod, docs/reference/flagd-cli/flagd_start.md
Bulk responses and selectors expose SSE metadata. CLI and runtime configuration carry SSE settings.
SSE events and configuration tracking
flagd/pkg/service/flag-evaluation/ofrep/sse/*
The SSE service tracks configuration versions, detects changes, manages channels, sends heartbeats, and publishes refetch events.
SSE service integration
flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go, flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go
OFREP conditionally creates, routes, starts, and shuts down the SSE service while preserving evaluation handling.
Versioned bulk evaluation responses
flagd/pkg/service/flag-evaluation/ofrep/handler.go, flagd/pkg/service/flag-evaluation/ofrep/sse_bulk_test.go
Bulk responses include ETags, modification metadata, and selector-specific event streams. Matching ETags return 304 Not Modified.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • open-feature/go-sdk-contrib#852 — Covers consumption of OFREP SSE event-stream support.
  • open-feature/js-sdk-contrib#1497 — Covers consumption of SSE advertisements and refetch events.
  • open-feature/python-sdk-contrib#366 — Covers corresponding OFREP ADR-0008 SSE support.
  • open-feature/dotnet-sdk-contrib#610 — Covers corresponding OFREP SSE advertisements and refetch events.
  • open-feature/rust-sdk-contrib#116 — Covers corresponding OFREP ADR-0008 SSE support.
  • open-feature/js-sdk-contrib#1498 — Covers related OFREP SSE advertisements and refetch notifications.
  • open-feature/java-sdk-contrib#1743 — Covers consumption of server-side SSE support.
  • open-feature/kotlin-sdk-contrib#31 — Covers related OFREP SSE advertisement and consumption support.

Suggested reviewers: toddbaert

Sequence Diagram(s)

sequenceDiagram
  participant OFREPClient
  participant OFREPHandler
  participant SSEService
  participant StoreTracker
  OFREPClient->>OFREPHandler: Request bulk evaluation
  OFREPHandler->>StoreTracker: Read selector version
  StoreTracker-->>OFREPHandler: Return ETag and last-modified time
  OFREPHandler-->>OFREPClient: Return eventStreams metadata
  OFREPClient->>SSEService: Subscribe to channel
  StoreTracker->>SSEService: Publish refetchEvaluation after a flag change
  SSEService-->>OFREPClient: Send refetch event
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding SSE support for OFREP endpoints.
Description check ✅ Passed The description directly explains the ADR-0008-compliant OFREP SSE server, response format, endpoint path, and testing approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Signed-off-by: Jamie Sinn <james.sinn@dynatrace.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
flagd/pkg/runtime/from_config.go (1)

30-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider spelling out the inactivity-delay field name and its unit.

OfrepSSEInactivityDel abbreviates "Delay" and omits the unit. The consuming field is SSEInactivityDelaySec, which records the unit. Config is exported, so renaming later is a breaking change for embedders.

♻️ Proposed rename
-	OfrepSSEEnabled       bool
-	OfrepSSEInactivityDel int
-	OfrepSSEPublicURL     string
+	OfrepSSEEnabled            bool
+	OfrepSSEInactivityDelaySec int
+	OfrepSSEPublicURL          string

Update the assignment in flagd/cmd/start.go at line 214 to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flagd/pkg/runtime/from_config.go` around lines 30 - 32, Rename the exported
Config field OfrepSSEInactivityDel to OfrepSSEInactivityDelaySec to spell out
the name and document seconds, and update the corresponding assignment in the
start command to use the new field while preserving its existing value.
flagd/pkg/service/flag-evaluation/ofrep/sse/service_test.go (1)

34-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the fixed sleeps with polling to avoid a flaky test.

The test relies on two 100 ms sleeps for synchronization. The second sleep waits for the subscription to register server-side. New sets es.ReplayAll = false, so an event published before registration is lost permanently. On a loaded CI runner the test then fails at the 3 second timeout.

Poll svc.active.snapshot() for the channel instead. The test is in the same package, so it can read that state directly.

♻️ Proposed refactor
-	// allow the tracker's initial (empty) snapshot to be consumed and skipped
-	time.Sleep(100 * time.Millisecond)
-
 	stream, err := eventsource.Subscribe(ts.URL+"?channels=fs1", "")
 	require.NoError(t, err)
 	defer stream.Close()
 
-	// allow the subscription to register server-side before publishing
-	time.Sleep(100 * time.Millisecond)
+	// wait for the subscription to register server-side; ReplayAll is false, so an
+	// event published before registration is lost
+	require.Eventually(t, func() bool {
+		for _, ch := range svc.active.snapshot() {
+			if ch == "fs1" {
+				return true
+			}
+		}
+		return false
+	}, 5*time.Second, 10*time.Millisecond, "subscription did not register")

If activeChannels.snapshot() returns a different shape, adapt the predicate accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/service_test.go` around lines 34
- 44, Replace the fixed synchronization sleeps in the test with polling: retain
the initial delay only as needed to consume the empty snapshot, then poll
svc.active.snapshot() until channel fs1 is registered before calling s.Update.
Use the existing polling/assertion utilities and adapt the predicate to the
snapshot’s shape, ensuring publication occurs only after subscription
registration.
flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go (1)

86-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the lastModified carry-forward.

TestTracker_Version discards lastModified at every call site. No test asserts the carry-forward logic in tracker.go lines 134-142, which preserves lastModified when a fingerprint is unchanged and refreshes it otherwise. That value reaches clients as flagConfigLastModified in the bulk response, so a regression would be visible externally.

💚 Proposed test
func TestTracker_Update_LastModifiedCarriedForward(t *testing.T) {
	tr := &Tracker{versions: map[string]version{}}
	flags := []model.Flag{testFlag("fs1", "a", "on")}

	tr.update(flags)
	_, firstLM, ok := tr.Version(mustSelector(t, "flagSetId=fs1"))
	require.True(t, ok)
	require.NotZero(t, firstLM)

	// an unchanged config must keep the original lastModified
	time.Sleep(1100 * time.Millisecond) // lastModified has second granularity
	tr.update(flags)
	_, sameLM, ok := tr.Version(mustSelector(t, "flagSetId=fs1"))
	require.True(t, ok)
	assert.Equal(t, firstLM, sameLM, "unchanged config must keep lastModified")

	// a changed config must refresh lastModified
	tr.update([]model.Flag{testFlag("fs1", "a", "off")})
	_, newLM, ok := tr.Version(mustSelector(t, "flagSetId=fs1"))
	require.True(t, ok)
	assert.Greater(t, newLM, firstLM, "changed config must refresh lastModified")
}

The test needs the time import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go` around lines 86
- 111, Extend TestTracker_Version or add a focused tracker update test to assert
lastModified is initially set, remains unchanged after updating with the same
flags, and increases after a fingerprint-changing update. Add the required time
import and account for the value’s second-level granularity when separating
unchanged and changed updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@flagd/pkg/service/flag-evaluation/ofrep/handler.go`:
- Around line 232-237: Update requestETag and the 304 decision to use only the
If-None-Match header as the client cache validator; keep flagConfigEtag separate
as change-trigger metadata and prevent it from influencing the comparison. Add a
regression test covering flagConfigEtag=etag-v2 with If-None-Match: etag-v1,
ensuring the response does not incorrectly return 304.

In `@flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go`:
- Around line 59-62: Update NewOfrepService to validate that flagStore is
non-nil whenever cfg.SSEEnabled is true, returning a construction error before
calling sse.New. Preserve the existing SSE initialization for valid stores and
the current behavior when SSE is disabled.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/service.go`:
- Around line 13-15: Update the SSE service configuration and NewOfrepService
construction so the heartbeat interval is derived from the configured OFREP
inactivity delay rather than always using defaultHeartbeatInterval. Pass the
advertised delay into the SSE service, calculate a heartbeat that remains safely
below it, and preserve consistent behavior for the default configuration.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/tracker.go`:
- Around line 69-85: Update Tracker.Run to distinguish a normal context-driven
watcher closure from an unexpected store or selector error, using the
result/error signaling exposed by store.Watch. Preserve silent shutdown for
context cancellation, but log the unexpected watcher error before returning so
failures are observable; keep the existing initialization snapshot and publish
behavior unchanged.

---

Nitpick comments:
In `@flagd/pkg/runtime/from_config.go`:
- Around line 30-32: Rename the exported Config field OfrepSSEInactivityDel to
OfrepSSEInactivityDelaySec to spell out the name and document seconds, and
update the corresponding assignment in the start command to use the new field
while preserving its existing value.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/service_test.go`:
- Around line 34-44: Replace the fixed synchronization sleeps in the test with
polling: retain the initial delay only as needed to consume the empty snapshot,
then poll svc.active.snapshot() until channel fs1 is registered before calling
s.Update. Use the existing polling/assertion utilities and adapt the predicate
to the snapshot’s shape, ensuring publication occurs only after subscription
registration.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go`:
- Around line 86-111: Extend TestTracker_Version or add a focused tracker update
test to assert lastModified is initially set, remains unchanged after updating
with the same flags, and increases after a fingerprint-changing update. Add the
required time import and account for the value’s second-level granularity when
separating unchanged and changed updates.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e146c194-5268-4b69-900f-956c7e1fccb1

📥 Commits

Reviewing files that changed from the base of the PR and between bbb05d4 and c80f9e5.

⛔ Files ignored due to path filters (1)
  • flagd/go.sum is excluded by !**/*.sum
📒 Files selected for processing (15)
  • core/pkg/service/ofrep/models.go
  • core/pkg/store/query.go
  • flagd/cmd/start.go
  • flagd/go.mod
  • flagd/pkg/runtime/from_config.go
  • flagd/pkg/service/flag-evaluation/ofrep/handler.go
  • flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go
  • flagd/pkg/service/flag-evaluation/ofrep/ofrep_service_test.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/event.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/handler.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/service.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/service_test.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/tracker.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse_bulk_test.go

Comment thread flagd/pkg/service/flag-evaluation/ofrep/handler.go Outdated
Comment thread flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go Outdated
Comment thread flagd/pkg/service/flag-evaluation/ofrep/sse/service.go
Comment thread flagd/pkg/service/flag-evaluation/ofrep/sse/tracker.go
Signed-off-by: Jamie Sinn <james.sinn@dynatrace.com>
Signed-off-by: Jamie Sinn <james.sinn@dynatrace.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go (1)

110-118: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Test all selector versions after unexpected watcher closure.

This test only verifies allKey. Tracker.Version also serves flagSetId and source selectors. If a later change leaves either entry intact, selector-specific bulk requests can receive stale 304 Not Modified responses. Seed those keys and assert that every lookup misses after Run returns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go` around lines 110
- 118, Expand TestTracker_Run_UnexpectedCloseInvalidatesVersions to seed version
entries for the flagSetId and source selectors in addition to allKey, then call
Tracker.Version with each selector and assert every lookup reports ok as false
after Run returns. Preserve the existing unexpected-close setup and
stale-version invalidation assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go`:
- Around line 110-118: Expand TestTracker_Run_UnexpectedCloseInvalidatesVersions
to seed version entries for the flagSetId and source selectors in addition to
allKey, then call Tracker.Version with each selector and assert every lookup
reports ok as false after Run returns. Preserve the existing unexpected-close
setup and stale-version invalidation assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd7d3001-3481-495d-b673-b36c3e0b6f43

📥 Commits

Reviewing files that changed from the base of the PR and between cea6215 and f121b83.

📒 Files selected for processing (5)
  • flagd/pkg/service/flag-evaluation/ofrep/handler.go
  • flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/tracker.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/tracker_test.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse_bulk_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • flagd/pkg/service/flag-evaluation/ofrep/ofrep_service.go
  • flagd/pkg/service/flag-evaluation/ofrep/handler.go
  • flagd/pkg/service/flag-evaluation/ofrep/sse/tracker.go

// be notified (via a `refetchEvaluation` event) when the flag configuration changes.
type EventStream struct {
Type string `json:"type"`
Endpoint *EventStreamEndpoint `json:"endpoint"`

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.

What about the url option? I saw in it in the adr and if I understood the discussion correctly, it should even be default? Or has that changed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I actually wrote it with the URL method the first time I wrote this out, but then I realized we have a small issue of that we don't know the required path/host path that clients use to reach the OFREP endpoint as people regularly put flagd behind a proxy, SSL termination, etc.

The endpoint method allows us to use the reliance on the OFREP client to fall back to the same host as the OFREP request is being made to for the SSE endpoint. This means we don't need to configure an endpoint/host on the flagd instance.

Though - I did add a configuration property to allow configuring the hostname if you know it, or want to use a different host/route path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants