Skip to content

App

Flags & experiments

Kill switches, rollouts, targeting and A/B tests share one primitive, flag(). Flags are decided per visitor on the server during SSR, so the decision costs no JavaScript, OpenFeature connects through one decide() call, and csr=false pages can run A/B tests at no extra cost.

On this page

A kill switch, a staged rollout, a targeting rule and an A/B/n test all come down to the same thing: a name with variants, decided per visitor. ogygia gives that decision to one primitive, flag(). Because the decision happens on the server during SSR, the chosen variant is what renders, so there is no flicker, no client SDK and no JavaScript spent on deciding, and a csr = false page can run an A/B test at no extra cost.

You read a flag by calling it, you branch on it with pick, and you connect a vendor through OpenFeature with one decide() call.

Installing

Flags are their own package. Install it and list its plugin beside ogygia():

pnpm add @ogygia/flags
import { ogygia } from 'ogygia/vite';
import { flags } from '@ogygia/flags/vite';

plugins: [ogygia(), flags(), sveltekit()]

Keep the plugin listed in every app that reads flags. Without it a flag still decides from its native rule, but the request never learns that a flag was read, and that matters in three places:

  • Stored pages. Each read marks the render as per-visitor, so postrender never stores one variant of an A/B test and serves it to everyone. Without the plugin nothing marks the render, and a stored page can keep the variant of whoever requested it first.
  • Federation. Each decision is recorded on the request, which is how a mount carries it to other teams.
  • Sources and exposures. The plugin primes a decide({ source }) once at the start of each request and drains the exposure sink at the end. Without it, the source is ignored and exposures are only sent when a batch fills up or its timer fires.

The server warns once the first time it sees a flag read without the plugin. See Plugins.

Declare: the second argument is the shape

import { flag } from '@ogygia/flags';

export const checkout = flag('new-checkout');                    // kill switch — off until decided on
export const promo    = flag('summer-promo', 10);                // number → sticky rollout to 10%
export const beta     = flag('beta', (c) => !!c.visitor?.beta);  // function → targeting
export const hero     = flag('hero', { control: 80, bold: 20 }); // record → weighted variants (ratios)
  • Omitted, the flag is a kill switch that is off for every visitor, which is the safe default.
  • A number is the sticky percentage of traffic that gets the feature.
  • A function targets per request. It returns true for on, false for off (and a rollout can then never include the visitor), or undefined to fall through. Because it runs on every request, it can read a cookie, an environment variable or a database row, which gives you runtime toggles without a redeploy.
  • A record defines weighted variants. The weights are ratios ({ control: 3, bold: 1 } is 75/25), and the first key is the control.

Read: by calling the flag

'/': page(Home, {
  load: (c) => ({
    dark: checkout(c),        // boolean
    variant: hero(c),         // 'control' | 'bold' — sticky per visitor
  }),
}),

The decision is deterministic and sticky. The same visitor gets the same answer on every request, because it comes from a hash of (flag name, visitor id), so there is no storage, no cookie write and nothing to sync.

Branch: pick, one verb, two forms

pick takes a map from variant to thing and gives this visitor their entry.

For a page slot, you don’t pass c, because the router supplies it per request:

import Hero from './Hero.svelte';
import HeroBold from './HeroBold.svelte';

'/': page(hero.pick({ control: Hero, bold: HeroBold }), { load }),

$infer sees no difference, because the page’s types come from the load, so the arms can be anything. They don’t even have to be different files. A component’s schedule rides on its import attribute, so two bindings of ONE file are two different pages, which lets you answer a real question with a real experiment, such as whether full hydration beats islands:

import Lab from './Lab.svelte';                          // pure server HTML — zero JS
import LabLive from './Lab.svelte' with { wake: 'load' }; // the SAME file as one live island

export const csrMode = flag('csr-mode', { static: 50, hydrated: 50 });

'/lab': page(csrMode.pick({ static: Lab, hydrated: LabLive }), { load }),

For a value, you have c, so you pass it and get the answer back:

const trialDays = pricing.pick(c, { control: 7, generous: 30 });    // 7 | 30, typed + total
const label     = hero.pick(c, { control: 'Buy now', bold: 'BUY →' });
const cfg        = hero.pick(c, {
  control: { columns: 2, video: false },
  bold:    { columns: 1, video: true },
});

TypeScript requires the map to cover every variant, and the return type is the union of its values. pick earns its keep at three or more variants. For a boolean flag you call it, and for two variants a ternary is enough.

Who is the visitor?

Stickiness needs an identity, which you declare once, on the route table:

export const app = routes(table, {
  visitor: (c) => session_from(c.cookies),   // → { sub: 'u123', … } or undefined
});

That becomes c.visitor everywhere. Flags stick to c.visitor.sub, and the same identity is what mounts sign onward to other teams. Outside the router, or for anonymous traffic, the fallback is an og-vid cookie, and after that the visitor is anonymous. Anonymous visitors always see the control, so a split never changes for a visitor you can’t recognize. Without the router, you provide the identity yourself, as described in Plain SvelteKit.

For sticky rollouts on logged-out traffic, create the cookie:

import { anonymousVisitor } from '@ogygia/router';

const anon = anonymousVisitor();                       // { cookie?, days? }
routes(table, { visitor: (c) => session(c) ?? anon(c) });

Plain SvelteKit

Flags don’t need the server router. A flag reads a request context with request, url and cookies, and a Kit RequestEvent already has all three, so you can pass the event straight to a flag in any server load, form action or endpoint:

import { hero } from '$lib/flags';

export const load = (event) => ({ variant: hero(event) });

Two things work differently without the router. The first is registration. A source is asked about the flags that have been registered, and a flag registers when its module is first imported, so import your flags module in hooks.server.ts. Then every flag is known before the first request primes the source. Router apps import all their route modules at startup, so their list is always complete.

The second is identity. The router gives every flag c.visitor, but a Kit event has no visitor, so a read falls back to the og-vid cookie, and with no cookie the visitor is anonymous. Anonymous visitors get the control, which means that until you provide an identity, every weighted flag and every rollout answers control for everyone. Targeting functions still run, because they read the event themselves.

For signed-in visitors, pass a visitor on the context you give the flag. A small helper keeps that in one place:

import type { RequestEvent } from '@sveltejs/kit';

export function flagContext(event: RequestEvent) {
  const user = event.locals.user;            // whatever your auth sets
  return {
    request: event.request,
    url: event.url,
    cookies: event.cookies,
    visitor: user ? { sub: user.id } : undefined,
  };
}
import { hero } from '$lib/flags';
import { flagContext } from '$lib/server/flag-context';

export const load = (event) => ({ variant: hero(flagContext(event)) });

For logged-out visitors, set the og-vid cookie yourself in a handle, which is what anonymousVisitor() does on the router. Kit’s cookies.get returns a cookie set earlier in the same request, so the very first request already sticks:

import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import * as ogygia from 'ogygia/server';
import '$lib/flags';   // register every flag before the first request

const visitorCookie: Handle = ({ event, resolve }) => {
  if (!event.cookies.get('og-vid')) {
    event.cookies.set('og-vid', crypto.randomUUID(), {
      path: '/', maxAge: 365 * 86_400, httpOnly: true, sameSite: 'lax',
    });
  }
  return resolve(event);
};

export const handle = sequence(visitorCookie, ogygia.handle());

Exposure events take their sub from c.visitor, so with the cookie alone the decision is sticky but the events arrive without a sub. Pass a visitor as well if your analytics needs one.

Vendor values: .value()

A flag can carry a payload written by the vendor, meaning configuration edited in the vendor’s dashboard rather than in code. You validate it with a schema, and an invalid payload is ignored and never gets in, under the same trust-boundary rule as a body schema:

import * as v from 'valibot';

export const banner = flag('promo-banner', 0, {
  value: v.object({ text: v.string(), tone: v.picklist(['info', 'warn']) }),
  fallback: { text: '', tone: 'info' },   // when the vendor is silent or the value is invalid
});

banner.value(c);   // typed payload — never undefined

The payload comes from the source, so .value() only ever returns something when a source sends values: ofrep() does whenever the service answers with one, while openfeature() needs values: true (see Sources reference). Without a source, .value() returns the fallback.

Without a value schema, .value() hands back whatever the source sent, unchecked and typed unknown, and the fallback (or undefined) when the source sent nothing. Declare a schema for anything you render.

OpenFeature: decide({ source })

Native flags need no setup. To have a vendor decide flags, you connect it once. ogygia depends on no vendor SDK; you give decide your own OpenFeature client:

import { OpenFeature } from '@openfeature/server-sdk';
import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server';
import { decide } from '@ogygia/flags';
import { openfeature } from '@ogygia/flags/openfeature';

OpenFeature.setProvider(new LaunchDarklyProvider(env.LD_KEY));
decide({ source: openfeature(OpenFeature.getClient()) });

The source resolves once per request, over exactly the flags your app declares, and every read after that stays synchronous. From then on a vendor-decided flag and a native one look the same, with the same calls, the same pick and the same federation carry. Decisions are validated against your declared variants, so a typo at the vendor cannot invent a bucket. If the vendor answers with an error or a variant you did not declare, that flag falls back to its native rule.

A slow vendor is a different matter. The request waits for the source before anything renders, so the source’s speed becomes part of your time to first byte, on every request the handle sees, whether or not the page reads a flag. ofrep() gives up after 800 ms by default and leaves every flag on its native rule. openfeature() and a custom source have no timeout of their own: the request waits for the slowest evaluation. If your provider can stall, give it a deadline, as shown under A custom source.

Without a vendor SDK, you can speak the OpenFeature remote protocol directly:

import { ofrep } from '@ogygia/flags/openfeature';
decide({ source: ofrep({ url: 'http://flagd:8016' }) });

For custom logic or a database, a source is an ordinary function:

decide({ source: async (queries, c) => {
  const rows = await db.flags(c.visitor?.sub);
  return Object.fromEntries(rows.map((r) => [r.name, r.variant]));
}});

On plain SvelteKit (without the ogygia router), import your flags module in hooks.server.ts, so every flag is registered before the first request primes the source. Router apps import all route modules at startup, so their registry is always complete. Plain SvelteKit covers the rest.

decide() can be called more than once, from different modules. Each call changes only the options it names, so decide({ source }) in one file and decide({ exposure }) in another add up. Passing source: undefined or exposure: undefined removes that one, and a batch you pass resets any field you leave out to its default.

Sources reference

A source is set once with decide({ source }) and runs once per request, at the start, before the page or hole renders. Its context has request, url and cookies, plus a visitor found the way a flag read finds one when no router identity is there: the signed claims from an upstream shell, else the og-vid cookie as { sub, anonymous: true }, else none. So the default context of openfeature() and ofrep() sends the vendor a targetingKey, and per-visitor targeting works as soon as visitors carry the cookie.

The source runs before the router or your load, so it never sees an identity they resolve later, such as a signed-in user from the router’s visitor resolver or a visitor you pass on a flag’s context. When the vendor should target signed-in users, pass your own context and read the session from the request.

openfeature(client, options?)

Bridges an OpenFeature server client that you created. Any object with getBooleanValue and getStringValue fits, so any SDK version works.

import { decide } from '@ogygia/flags';
import { openfeature } from '@ogygia/flags/openfeature';

decide({
  source: openfeature(OpenFeature.getClient(), {
    // the default sends c.visitor, with sub as targetingKey; this adds a country on top
    context: (c) => ({
      ...c.visitor,
      targetingKey: c.visitor?.sub,
      country: c.request.headers.get('x-country'),
    }),
    values: true,
  }),
});
OptionDefaultWhat it does
contextc.visitor’s keys, with sub as targetingKeyBuilds the evaluation context from the request. The default sends the visitor the source sees; pass your own to add more.
valuesfalseAlso asks for each flag’s object value (getObjectValue) and passes it to .value(c). Without it, vendor payloads never arrive.

Each request evaluates every registered flag in parallel. A boolean flag asks getBooleanValue(name, false), so a flag the vendor doesn’t know stays off, and a variant flag asks getStringValue(name, control), whose answer is the variant name. values: true adds one more call per flag. A flag whose evaluation throws falls back to its native rule. There is no timeout.

ofrep(options)

Speaks the OpenFeature Remote Evaluation Protocol directly, with no SDK, to any OFREP service (flagd, the OpenFeature operator, a gateway). It sends one bulk POST to <url>/ofrep/v1/evaluate/flags/bulk per request.

import { ofrep } from '@ogygia/flags/openfeature';

decide({
  source: ofrep({
    url: 'http://flagd:8016',
    headers: { authorization: `Bearer ${env.FLAGS_TOKEN}` },
    timeout: 300,
  }),
});
OptionDefaultWhat it does
urlrequiredThe service’s base URL.
headersnoneHeaders sent with every evaluation, such as an auth token or a tenant.
contextc.visitor’s keys, with sub as targetingKeyBuilds the evaluation context. The default sends the source’s visitor, as with openfeature().
fetchthe global fetchThe fetch to use.
timeout800Milliseconds before the evaluation is aborted.

A non-2xx answer, a network error or the timeout leaves every flag on its native rule. Flags the service answers with an errorCode, flags you did not declare and variants you did not declare are skipped. A boolean flag takes the answer’s variant, or on/off from a boolean value. A value that is not a boolean becomes the flag’s .value(c) payload.

A custom source

A source is a function from the registered flags and the request to a record of decisions. It may be async.

import type { FlagSource, FlagQuery, Resolved } from '@ogygia/flags';

// type FlagSource = (queries: FlagQuery[], c: CtxLike) => Record<string, Resolved> | Promise<…>
// interface FlagQuery { name: string; variants: readonly string[]; kind: 'boolean' | 'variant' }

Each FlagQuery describes one flag your app declared. kind is 'boolean' for a kill switch, rollout or targeting flag (its variants are off and on) and 'variant' for a weighted set. The record you return is keyed by flag name, and each value can take one of three forms:

You returnIt means
'bold'the variant name
true / false'on' / 'off', for a boolean flag
{ variant, value? }a variant, plus a payload for .value(c)

A flag you leave out uses its native rule, a variant that isn’t declared is ignored, and if the function throws or rejects, every flag uses its native rule for that request. The source runs once per request over every registered flag, so keep it cheap or cache inside it.

Nothing puts a deadline on a source, so wrap one that can stall. Returning an empty record when time runs out leaves every flag on its native rule:

import type { FlagSource } from '@ogygia/flags';

const within = (ms: number, source: FlagSource): FlagSource => (queries, c) =>
  Promise.race([
    source(queries, c),
    new Promise<Record<string, never>>((resolve) => setTimeout(() => resolve({}), ms)),
  ]);

decide({ source: within(300, openfeature(OpenFeature.getClient())) });

prime_flags() and flush_exposures()

The flags plugin calls these for you: prime_flags(c) at the start of each request and flush_exposures() at its end. They are exported for code that runs outside a request. Call flush_exposures() to send queued exposures before a process exits or at the end of a test, and prime_flags(c) to run the source for a request the plugin never saw, such as one built in a unit test. Priming a request twice does nothing the second time.

The precedence chain

Each read resolves in this order, and the first hit wins:

?og-exp override → carried claims → the source → the native rule → control.

  • override: a QA force from the URL (below).
  • carried claims: a decision an upstream shell already made (federation).
  • source: the vendor, if decide({ source }) set one.
  • native rule: your targeting function, weights or rollout.
  • control: the first variant (or off).

QA override

You can force a variant from the URL, and it takes precedence over everything, stickiness included:

/lab?og-exp=csr-mode:hydrated
/?og-exp=hero:bold,new-checkout:on

This is dev-only by default. In production, an open URL override would let any visitor switch unfinished features on and pollute your data, so production ignores ?og-exp unless you allow it. Permit QA with a signal a visitor cannot forge:

decide({ overrides: (c) => c.visitor?.roles?.includes('qa') ?? false });

Mutual exclusion: the layer option

Suppose two experiments touch the hero section, a redesign and an autoplay video. If one visitor lands in BOTH treatments and conversions move, you can’t tell which change caused it, because the two measurements contaminate each other. Experiments on the same surface should therefore never share a visitor.

Layers solve this with lanes. Flags that share a layer string form a group:

const heroRedesign = flag('hero-redesign', { control: 50, bold: 50 }, { layer: 'hero-surface' });
const heroVideo    = flag('hero-video',    { control: 50, video: 50 }, { layer: 'hero-surface' });

For each visitor, the layer makes ONE sticky decision before any member’s logic runs. It hashes (layer name, visitor id) over the sorted member names to choose the visitor’s owner. You can picture all traffic split into N equal lanes, one per member. Reading a member flag then starts with one question: is this flag the owner for this visitor?

  • If it is not the owner, it answers control immediately, and the flag’s targeting and weights never run for that visitor.
  • If it is the owner, it carries on as if the layer did not exist, inside its own lane.

For example, if visitor puru hashes into hero-video’s lane, heroRedesign(puru) always returns 'control' and its 50/50 never executes, while heroVideo(puru) applies its own 50/50. The same inputs hash the same way on every request and on every replica, and because the names are sorted, the order in which modules are imported can never change a lane. There is no storage, no coordination between servers and nothing to sync.

Remember the arithmetic: a member only experiments inside its own lane, so its real exposure is its split divided by the number of members. A 50% variant in a two-member layer reaches 25% of all traffic, and adding a third member shrinks each lane to a third.

Some things outrank the layer. The override, carried claims and the vendor source all sit ABOVE the lane gate in the precedence chain. ?og-exp=hero-redesign:bold shows QA the variant even for a visitor the other member owns, a carried decision from an upstream shell is applied as it is, and a vendor that answers for a flag bypasses the lane, which makes the vendor responsible for exclusion. Don’t run the same surface in both systems.

Measurement: decide({ exposure })

ogygia ships no metrics pipeline. It provides the decision, and counting is your analytics’ job. The one connection point is an exposure sink, which fires once per request per flag on the first read; under SSR, the decision is the rendering of the variant. Batching is built in:

decide({
  exposure: (events) => analytics.track(events),   // an array of { name, variant, sub, at }
  batch: { max: 25, ms: 2000 },                     // flushed at the cap, the timer, or request end
});

A sink that throws never breaks a request, and the remaining events drain at the end of each request, which is safe on serverless platforms. For logs and data-og-exp stamps, stamp(c) gives you name:variant:

<section data-og-exp={data.stamp}>…</section>

One world across teams

“Carried” in the precedence chain is the federation tier. Every flag a request decides registers its bucket automatically, and when a shell mounts other teams’ apps, the visitor’s whole set of decisions travels in the signed claims. Nobody lists anything by hand, so a forgotten entry can’t silently split a visitor between teams, and since a carried decision beats the local native rule, one visitor never ends up in two worlds.

A shell that only routes to teams, and never reads the flag itself, decides it in advance on the table so the bucket still travels:

routes(table, { visitor: session, flags: [csrMode] });   // decide-but-don't-render

The flag manifest

Every production build writes an inventory of the flag() call sites it compiled. The inventory is resolved from the AST, so renamed and namespace imports count, while an unrelated local function named flag does not. It is written to:

node_modules/.ogygia/flags-manifest.json   →  { flags: [{ name, kind, file, line }], names: [...] }

Flags go stale over time, and with flags defined in code, the inventory alone gives you most of what dedicated flag-debt tools offer. Record the manifest in CI, diff it per release, and raise an alarm for a name that lives past its retirement date. The ogygia_flags MCP tool reads it live. Retiring a flag stays a human edit, where you delete the flag and keep the branch that won, and the manifest tells you which flags are overdue.