Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 131 additions & 44 deletions docs/how-to-guides/client-side-feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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://<host>/v1/ofrep/v1/evaluate/flags`. If you see 404s on URLs like `https://<host>/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

Expand All @@ -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

Expand All @@ -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 (
<OpenFeatureProvider>
<MyComponent />
</OpenFeatureProvider>
<AuthProvider>
<OpenFeatureProvider>
<OpenFeatureContextSync />
<MyComponent />
</OpenFeatureProvider>
</AuthProvider>
)
}

// In any component
// Keep the evaluation context synced with the logged-in user.
// Prefer ``useContextMutator`` over calling ``OpenFeature.setContext``
// directly once ``<OpenFeatureProvider/>`` 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')
Expand All @@ -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 `<OpenFeatureProvider/>` 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:
Expand All @@ -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<string, string> = {}) {
// Swap to a real identifier after the user logs in.
export async function setUserContext(userId: string, attributes: Record<string, string> = {}) {
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),
Expand Down Expand Up @@ -225,4 +312,4 @@ Both endpoints accept a JSON body with a `context` object containing `targetingK
}
```

Authenticate with an `Authorization: Bearer <api-key>` header using a key with `project:read_external_variables` scope.
Authenticate with an `Authorization: Bearer <api-key>` header using a key with `project:read_external_variables` scope. Both endpoints require `Content-Type: application/json` and a non-empty `context`.
Loading