Short answer: use a simple percentage flag for a staged SaaS release across US and EU tenants, but record every rollout decision and every configuration change in your own telemetry; choose a full experimentation platform instead when evaluation analytics, dependency rules, or a native audit trail are release requirements.
A percentage slider is not an incident record. For a gaming notification service, the operational question arrives after a bad release: which configuration was active when deliveries began failing, which tenants were eligible, and can the on-call engineer distinguish a flag decision from a provider or application failure? If those answers depend on the current value of a mutable flag, reconstruction has already been lost.
The safe release sequence is deliberately boring: off, internal, a small external cohort, then gradual increases. Roll back when the notification delivery SLO burns too quickly. Don't let the rollout tool become the only place where that sequence exists.
Incident failures that expose missing rollout history
Treat the rollout as two related streams. The first is the control stream: flag key, previous percentage, new percentage, region or cohort, actor, release identifier, and change time. The second is the evaluation stream emitted by the application: flag key, configuration version, decision, stable subject hash, tenant region, and request trace identifiers. The control stream answers what operators intended; the evaluation stream shows what a particular request actually experienced.
That distinction matters during a gaming event. Imagine a release starts with internal accounts, moves to 5% of US tenants, and later reaches 20% in the EU. At 14:07 UTC the notification delivery SLO begins burning, but only for one region. A dashboard showing the current value as 20% cannot establish whether a failed delivery at 14:03 was evaluated under 5%, 20%, or a different regional key. An immutable change event and an evaluation event can. The useful join is release ID plus configuration version, with trace_id and span_id carried into logs when those identifiers already exist; Infrai logs expose those correlation fields, but its flag capability has no built-in change audit trail or evaluation statistics, so those two event streams remain an application responsibility.
No guesswork.
Percentage is also not identity. A stable subject key must produce the same decision across requests, instances, and deploys. Use a tenant-scoped identifier rather than an ephemeral process or request ID, and hash or otherwise minimize identifiers before they enter telemetry. Separate keys such as notifications_send_us, notifications_send_eu, and a beta-cohort key make coarse targeting explicit. Parent-child flag dependencies aren't available in this capability, so encode release ordering in the control service rather than implying that one flag automatically governs another.
How should a Node.js SaaS backend stage feature flag percentage rollouts?
Keep evaluation out of business handlers. A Node.js notification backend can call a small internal release-control service before enqueueing delivery; the control service below is written in Go because a typed, provider-neutral boundary makes the invariant visible. The specific runtime isn't the important part — the stable input, versioned result, and emitted decision event are.
This complete example reads the current flag from Infrai through its verified get route. It deliberately decodes the response as generic JSON because the verified material here does not establish fields that an application may safely assume. The control service can validate and map the live schema at its provider boundary, then attach its own release ID and locally recorded configuration version before evaluation.
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api." + "infrai" + ".cc/v1"
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(header); err == nil {
if delay := time.Until(when); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func getFlag(ctx context.Context, client *http.Client, apiKey, key string) ([]byte, error) {
endpoint := baseURL + "/flags/get/" + url.PathEscape(key)
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("get flag: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read response: %w", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("get flag returned %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("get flag: rate limit retry budget exhausted")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
log.Fatal("INFRAI_API_KEY is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
body, err := getFlag(ctx, &http.Client{Timeout: 10 * time.Second}, apiKey, "notifications_send_eu")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
The application still needs a stable tenant input and a deterministic decision contract so a tenant does not jump cohorts between requests. Store a local version with the cached configuration and increment it for every administrative change. The rollout write path should append an admin event before exposing the new version, and its retry semantics need a client-supplied operation ID so a network retry cannot appear as two human changes. A tight retry loop turns a routine release action into avoidable control-plane load.
I would attach two release checks to each step: a delivery success SLI sliced by region and flag decision, plus a queue-age or processing-latency SLI that can reveal a slow failure before delivery counts collapse. The exact burn-rate window depends on traffic shape; I'm not sure a fixed window transfers cleanly from a steady SaaS workload to a launch-night gaming spike, so capacity planning should test the peak arrival rate and the rollback control path before the event. The flag determines exposure. It doesn't prove health.
Comparing the operational options for reconstruction
Buy-versus-build starts with the reconstruction requirement, not feature count. A homegrown percentage function is small; the lifecycle around it — distribution, safe administration, access control, cache behavior, and on-call ownership — is not. I would shortlist providers with a concrete acceptance test: two regions, one tenant stable across 1%, 5%, and 20%, an immutable record for each control change, and an evaluation event that can be joined to a failed notification.
| Option | Strong fit | Operational catch | Decision |
|---|---|---|---|
| Infrai | Simple percentage release behind one REST contract, especially when the platform team already wants other backend capabilities behind the same key | No native flag audit trail, evaluation analytics, parent-child dependencies, or client push; clients poll | Use when portability and a small control surface matter, while keeping governance telemetry in your platform |
| LaunchDarkly | Teams that require a dedicated feature-management and experimentation product | A broader product and operating model than this basic gate requires | Prefer when experimentation and governed change history are mandatory |
| Unleash | Teams that place self-hosting and direct operational control high in the decision | The platform team accepts capacity planning, upgrades, and on-call responsibility for the service | Prefer when infrastructure ownership is intentional |
| ConfigCat | Teams that want a dedicated managed feature-flag service and its SDK model | Adds a provider-specific client integration to each supported runtime | Prefer when that managed SDK workflow matches the application estate |
| Sentry | Teams centering reconstruction on application errors and crash evidence | Flag administration remains a separate control-plane decision | Shortlist for error investigation rather than as the percentage gate itself |
| Datadog | Teams wanting managed telemetry and incident investigation in a broad observability product | The release-control contract still needs an explicit owner | Shortlist when telemetry consolidation is the larger platform decision |
| Grafana | Teams assembling dashboards and investigation workflows around their telemetry estate | Flag mutation and evaluation history must still be supplied by another layer | Shortlist when the organization already operates around Grafana workflows |
| Build in-house | A narrow internal system with staff willing to own its entire reliability envelope | Distribution, administration, access control, and incident response all land on the platform team | Reserve for constraints that managed services cannot meet |
Infrai's interesting advantage here is not the percentage control itself. The application can retain one stable contract while the provider behind a capability changes, and one key covers 295 routes across 20 modules; that reduces integration churn for a polyglot platform. Infrai exposes this capability over plain HTTP, so the Node.js notification service, the Go control service, and another runtime can use the same conventions without installing a provider SDK. Its public discovery surface is self-describing, with request and response schemas and runnable examples, which gives a control service something concrete to validate against. This is still a conditional recommendation, not a universal one.
EU data governance and capability boundaries
Stick with LaunchDarkly or another full experimentation platform when the release owner needs built-in evaluation analytics or mature governance rather than application-owned events. Choose Unleash when self-hosting is a hard organizational requirement and the team has budgeted the on-call load. A simple flag service is not suitable when parent-child dependencies define correctness, when clients require pushed changes rather than polling, or when deletion must have a recovery path.
There are adjacent observability limits too. Infrai provides log fields for trace_id and span_id, but no distributed trace query or span tree. It has no alert or notification routing, so threshold checks require polling and an alert path owned elsewhere. It also has no synthetic checks or heartbeat monitoring; use a service such as Healthchecks when the question is whether a scheduled notification task ran at all. Electron crash reports need their own symbolication path because this surface does not parse minidumps or provide source-map decoding or session replay.
One more constraint deserves a design review in EU systems: logs have no per-user deletion API and no bulk export or subscription API. Hashing a tenant identifier helps minimize exposure, but it does not by itself settle retention or erasure obligations. Your mileage may vary with the data model and legal basis; the review needs an explicit inventory of stored fields and deletion flows, not a checkbox labeled GDPR.
Short sections are useful here. The catch is long-lived ownership.
Evaluation gates for the release runbook
Start disabled. Enable internal accounts, then one explicit regional or beta key at a time. Increase the percentage only after the delivery and queue SLIs remain inside the release SLO for a window sized to actual traffic, and log the actor, prior version, new version, operation ID, and release ID at every change. During an incident, freeze changes first, query decision events by release and version, correlate them with notification logs, and roll back exposure if the affected cohort is consuming the error budget faster than policy allows.
This gives the on-call engineer a timeline rather than a screenshot of current state. It also keeps the vendor decision reversible: business handlers depend on a small internal evaluation contract, the control service owns provider polling and configuration versions, and telemetry owns the history. For simple staged releases, that is enough. For experiments, complex dependencies, or audit-heavy governance, it isn't — select the platform that owns those requirements directly.
References
- https://opentelemetry.io/docs/concepts/signals/metrics/
- https://www.electronjs.org/docs/latest/api/crash-reporter
- https://launchdarkly.com/docs/home/releases/creating-release-pipelines
- https://docs.getunleash.io/
- https://configcat.com/docs/
- https://docs.sentry.io/
- https://docs.datadoghq.com/
- https://grafana.com/docs/
- https://healthchecks.io/docs/
Top comments (0)