From 6c436ac9928794d0b9c251a0c7c6ee81114bc1a9 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 17 Apr 2026 17:17:00 +0200 Subject: [PATCH] Rewrite client-side feature flags guide with OFREP gotchas Based on hands-on use of the page to migrate an `agent_runs` flag in the Logfire frontend, several things in the walkthrough cost real time: - `baseUrl` was shown as `${apiHost}/v1/ofrep/v1`, but the OFREP provider library appends `/ofrep/v1/evaluate/flags` itself - passing the full OFREP path doubles it and produces 404s. Correct it to `${apiHost}/v1` and add a pitfall callout. - The backend rejects evaluation requests without a `context` field with a 422, and the OFREP provider marks itself fatally errored on that. Spell out the requirement and recommend `setProviderAndWait(provider, { targetingKey: 'anonymous' })` or gating registration on auth. - The `fetchImplementation` snippet dropped the `Content-Type: application/json` header the library sets on the `Request` object, which also produces a 422. Show the correct header-merging pattern. - External toggle + "no version yet" are easy to miss - call them out in Prerequisites so FLAG_NOT_FOUND doesn't send readers hunting. - Recommend `useContextMutator` inside a React app rather than calling `OpenFeature.setContext` directly, so the React-SDK hooks re-render. - Add a debugging table mapping symptoms (`FLAG_NOT_FOUND`, 422, 404, 401, stale value, `reason: DEFAULT`) to causes and fixes, plus a raw `fetch` snippet for isolating backend vs SDK issues. No behaviour changes - this is a docs-only improvement. --- .../client-side-feature-flags.md | 175 +++++++++++++----- 1 file changed, 131 insertions(+), 44 deletions(-) diff --git a/docs/how-to-guides/client-side-feature-flags.md b/docs/how-to-guides/client-side-feature-flags.md index 0197ccfb9..2e29ad243 100644 --- a/docs/how-to-guides/client-side-feature-flags.md +++ b/docs/how-to-guides/client-side-feature-flags.md @@ -6,8 +6,9 @@ This guide shows how to set up a **JavaScript/TypeScript web application** using ## Prerequisites -1. **Create your variables** in the Logfire UI (Settings > Variables) and mark them as **external** — see [External Variables and OFREP](../reference/advanced/managed-variables/external.md) -2. **Create an API key** with the `project:read_external_variables` scope — this restricted scope is safe to use in client-side code since it only exposes variables you've explicitly marked as external +1. **Create your variable** in the Logfire UI (*Settings → Managed Variables → New variable*) and turn on the **External** toggle. Without *External*, OFREP returns `FLAG_NOT_FOUND` for that variable — see [External Variables and OFREP](../reference/advanced/managed-variables/external.md). +2. **Publish a value.** A freshly-created variable has no versions. Open the variable, click *Edit* on the `latest` label, pick a value, and create a version. Until there's a published version, OFREP still returns `FLAG_NOT_FOUND`. +3. **Create an API key** with the `project:read_external_variables` scope. This restricted scope is safe to ship in client code — it only exposes variables you've explicitly marked as external. ## Installation @@ -43,37 +44,54 @@ const LOGFIRE_API_KEY = 'your-api-key' // project:read_external_variables scope const LOGFIRE_API_HOST = 'logfire-api.pydantic.dev' // or your self-hosted API host const provider = new OFREPWebProvider({ - baseUrl: `https://${LOGFIRE_API_HOST}/v1/ofrep/v1`, - fetchImplementation: (input, init) => - fetch(input, { - ...init, - headers: { - ...Object.fromEntries(new Headers(init?.headers).entries()), - Authorization: `Bearer ${LOGFIRE_API_KEY}`, - }, - }), + // ``@openfeature/ofrep-web-provider`` appends ``/ofrep/v1/evaluate/flags`` + // to this URL itself, so ``baseUrl`` is the parent prefix — not the full + // OFREP path. See the pitfall note below. + baseUrl: `https://${LOGFIRE_API_HOST}/v1`, + fetchImplementation: (input, init) => { + // Start from whatever headers the library set on the Request (the + // library clones the Request into ``input`` with a ``Content-Type`` + // header already attached — dropping it causes the backend to 422). + const headers = new Headers(input instanceof Request ? input.headers : undefined) + for (const [key, value] of new Headers(init?.headers).entries()) { + headers.set(key, value) + } + headers.set('Authorization', `Bearer ${LOGFIRE_API_KEY}`) + headers.set('Content-Type', 'application/json') + return fetch(input, { ...init, headers }) + }, }) -OpenFeature.setProvider(provider) +// Seed the context *before* registering the provider so the initial bulk +// evaluation posts a valid ``{"context": {...}}`` body. See "Evaluation +// context" below. +await OpenFeature.setProviderAndWait(provider, { targetingKey: 'anonymous' }) ``` !!! note "API key in client-side code" The `project:read_external_variables` scope is designed to be safe for client-side use. It only grants read access to variables you've explicitly marked as external. Keep sensitive configuration in internal (non-external) variables, which are inaccessible with this scope. -## Setting Evaluation Context +!!! warning "Get the `baseUrl` right" + The OFREP provider library appends `/ofrep/v1/evaluate/flags` (or `/evaluate/flags/{key}`) to `baseUrl` itself, so `baseUrl` is the parent prefix — **not** the full OFREP path. Pass `\`https://${apiHost}/v1\``, and the library makes requests to `https:///v1/ofrep/v1/evaluate/flags`. If you see 404s on URLs like `https:///v1/ofrep/v1/ofrep/v1/evaluate/flags` in the network tab, the `/ofrep/v1` suffix was included twice — drop it from `baseUrl`. -Set the evaluation context to enable targeting and deterministic rollouts. The `targetingKey` ensures that the same user always gets the same variant: +## Evaluation context + +Every OFREP request must carry a non-empty `context` with a `targetingKey`. The backend returns **422** (and the provider goes into a fatal `ERROR` state) if the context is missing, so the recommended flow is: + +1. Seed the provider with a stub `{ targetingKey: 'anonymous' }` via `setProviderAndWait` at startup, or +2. Delay `setProvider` until auth has resolved a real user identifier. + +Once the user is authenticated, swap the context in: ```typescript await OpenFeature.setContext({ - targetingKey: userId, - // Additional attributes for targeting rules + targetingKey: userId, // stable per-user identifier (user id, email, …) plan: 'enterprise', region: 'us-east', }) ``` -Any attributes you include in the context can be used by [conditional rules](../reference/advanced/managed-variables/ui.md#targeting-with-conditional-rules) configured in the Logfire UI. For example, you could route all `enterprise` plan users to a specific label. +The `targetingKey` ensures the same user always lands in the same variant bucket for rollouts. Any additional attributes become inputs for [conditional rules](../reference/advanced/managed-variables/ui.md#targeting-with-conditional-rules) configured in the Logfire UI — for example, routing `enterprise` plan users to a specific label. ## Evaluating Flags @@ -98,7 +116,7 @@ console.log(details.variant) // label name (e.g., "production", "canary") console.log(details.reason) // evaluation reason (e.g., "TARGETING_MATCH", "SPLIT") ``` -The second argument to each method is the **default value**, returned when the flag can't be evaluated (e.g., network error, flag not found). +The second argument to each method is the **default value**, returned when the flag can't be evaluated (e.g., network error, flag not found). Use `getBooleanDetails` / `getStringDetails` instead of `getBooleanValue` / `getStringValue` when you need to distinguish "defaulted because of an error" from "the real value happens to be the same as the default" — `details.reason === 'ERROR'` tells you the provider couldn't resolve the flag. ## React Integration @@ -122,21 +140,47 @@ For React applications, OpenFeature provides a React SDK with hooks for flag eva yarn add @openfeature/react-sdk ``` -Wrap your application with the `OpenFeatureProvider` and use hooks in your components: +Wrap your application with the `OpenFeatureProvider` and read flags through `useBooleanFlagValue` / `useStringFlagValue` / `useNumberFlagValue`. These hooks re-render their component automatically when the provider becomes ready, the flag configuration changes server-side, or the evaluation context changes — you don't need a custom subscription layer. ```tsx -import { OpenFeatureProvider, useBooleanFlagValue, useStringFlagDetails } from '@openfeature/react-sdk' - -// In your app root +import { + OpenFeatureProvider, + useBooleanFlagValue, + useContextMutator, + useStringFlagDetails, +} from '@openfeature/react-sdk' +import { useEffect } from 'react' + +// In your app root - registered once, *after* auth has resolved. function App() { return ( - - - + + + + + + ) } -// In any component +// Keep the evaluation context synced with the logged-in user. +// Prefer ``useContextMutator`` over calling ``OpenFeature.setContext`` +// directly once ```` is mounted - it updates the +// domain-scoped context used by the hooks. +function OpenFeatureContextSync() { + const { user } = useAuth() + const { setContext } = useContextMutator() + + useEffect(() => { + setContext({ + targetingKey: user?.id ?? 'anonymous', + ...(user?.email ? { email: user.email } : {}), + }).catch(() => undefined) + }, [user?.id, user?.email, setContext]) + + return null +} + function MyComponent() { const showBanner = useBooleanFlagValue('show_banner', false) const theme = useStringFlagDetails('ui_theme', 'light') @@ -150,6 +194,42 @@ function MyComponent() { } ``` +!!! tip "Register the provider once, gated on auth" + Call `OpenFeature.setProviderAndWait(provider, { targetingKey })` at most **once** per session. If you re-register, any cached values from the first registration are discarded and the provider may spend a render cycle in `ERROR` while it recovers. If your app has no user context before login, either seed with `targetingKey: 'anonymous'` as shown above, or only call `setProviderAndWait` after auth has resolved a real user id. + +## Debugging + +When flag evaluation isn't returning what you expect, run through this checklist in order. + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `details.errorCode === 'FLAG_NOT_FOUND'` for every flag | Provider's initial bulk evaluation failed, so its cache is empty. | Check the network tab for a failed request to `/v1/ofrep/v1/evaluate/flags`. Usually a 422 (see next row). | +| `details.errorCode === 'FLAG_NOT_FOUND'` for one specific flag only | The variable isn't marked **External**, or it doesn't have a published version. | Toggle *External* on in the variable settings, then create a version under the `latest` label. | +| Network tab shows `422 Unprocessable Entity` on `/evaluate/flags` | Request body was missing `context` or the `Content-Type: application/json` header. | Ensure `setContext` (or `setProviderAndWait(provider, context)`) runs before the first evaluation, and your `fetchImplementation` preserves the `Content-Type` header — see the snippet in [Setup](#setup). | +| Network tab shows `404 Not Found` on `/ofrep/v1/ofrep/v1/...` | `baseUrl` has `/ofrep/v1` and the provider library appended another. | Use `baseUrl: \`https://${apiHost}/v1\`` — the library adds the `/ofrep/v1` suffix itself. | +| Network tab shows `401 Unauthorized` | API key missing, expired, or lacking the right scope. | Create a new key with `project:read_external_variables` in *Settings → API keys*. | +| Value never updates after `setContext` | Still reading via `OpenFeature.setContext` / `OpenFeature.getClient` instead of the React-SDK hooks. | Use `useContextMutator().setContext` and `useBooleanFlagValue` / `useStringFlagValue` inside the `` subtree so React re-renders on context changes. | +| All requests 200, but `details.reason === 'DEFAULT'` | The provider cache is warm but no rule matched the given context. | Open the variable's *Targeting* tab and verify the rules match the attributes you're sending (`targetingKey`, `plan`, …). | + +### Inspecting a single evaluation from the devtools + +If you want to confirm the backend is returning what you expect independently of the SDK, make a raw OFREP request from the browser console: + +```javascript +await fetch('https://logfire-api.pydantic.dev/v1/ofrep/v1/evaluate/flags/show_new_feature', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + context: { targetingKey: 'user-123' }, + }), +}).then(r => r.json()) +``` + +A `200` with `{ value, reason, variant }` means the backend is wired correctly; any client-side failure to see the value is an SDK-layer issue (cache cold, wrong context, etc.). + ## Full Example Here's a complete setup combining initialization, context, and evaluation: @@ -158,32 +238,39 @@ Here's a complete setup combining initialization, context, and evaluation: import { OFREPWebProvider } from '@openfeature/ofrep-web-provider' import { OpenFeature } from '@openfeature/web-sdk' -// Initialize once at app startup -function initFeatureFlags(apiKey: string, apiHost: string) { - const provider = new OFREPWebProvider({ - baseUrl: `https://${apiHost}/v1/ofrep/v1`, - fetchImplementation: (input, init) => - fetch(input, { - ...init, - headers: { - ...Object.fromEntries(new Headers(init?.headers).entries()), - Authorization: `Bearer ${apiKey}`, - }, - }), +function buildProvider(apiKey: string, apiHost: string) { + return new OFREPWebProvider({ + baseUrl: `https://${apiHost}/v1`, + fetchImplementation: (input, init) => { + const headers = new Headers(input instanceof Request ? input.headers : undefined) + for (const [key, value] of new Headers(init?.headers).entries()) { + headers.set(key, value) + } + headers.set('Authorization', `Bearer ${apiKey}`) + headers.set('Content-Type', 'application/json') + return fetch(input, { ...init, headers }) + }, + }) +} + +// Register once, seeded with an anonymous targetingKey so the initial +// bulk evaluation carries a valid context. +export async function initFeatureFlags(apiKey: string, apiHost: string) { + await OpenFeature.setProviderAndWait(buildProvider(apiKey, apiHost), { + targetingKey: 'anonymous', }) - OpenFeature.setProvider(provider) } -// Set context when user authenticates -async function setUserContext(userId: string, attributes: Record = {}) { +// Swap to a real identifier after the user logs in. +export async function setUserContext(userId: string, attributes: Record = {}) { await OpenFeature.setContext({ targetingKey: userId, ...attributes, }) } -// Evaluate flags anywhere in your app -function getFeatureFlags() { +// Evaluate flags anywhere in your app. +export function getFeatureFlags() { const client = OpenFeature.getClient() return { showNewDashboard: client.getBooleanValue('show_new_dashboard', false), @@ -225,4 +312,4 @@ Both endpoints accept a JSON body with a `context` object containing `targetingK } ``` -Authenticate with an `Authorization: Bearer ` header using a key with `project:read_external_variables` scope. +Authenticate with an `Authorization: Bearer ` header using a key with `project:read_external_variables` scope. Both endpoints require `Content-Type: application/json` and a non-empty `context`.