Splitstream + Pennant — the joint stack
Pennant is a feature-flag / remote-config service with real-time SDK push. Splitstream is an A/B testing platform. The natural pair:
- Pennant decides whether the feature ships.Use the flag as the kill switch — if it's off, no variant traffic.
- Splitstream decides which variant the user sees. And whether the variant moved the metric.
The @philiprehberger/growth umbrella wraps both clients in a single useExperiment():
import { Pennant } from "@philiprehberger/pennant";
import { Splitstream } from "@philiprehberger/splitstream";
import { Growth } from "@philiprehberger/growth";
const growth = new Growth({
pennant: new Pennant({
apiKey: process.env.PENNANT_KEY!,
context: { userId: "alice" },
}),
splitstream: new Splitstream({
apiKey: process.env.SPLITSTREAM_KEY!,
context: { userId: "alice" },
}),
});
const { variant, killSwitchActive, track } = await growth.useExperiment(
"checkout-v2",
{ fallback: "control" }
);
if (killSwitchActive) {
// Pennant flag was off — Splitstream was NOT consulted.
// Exposure was NOT logged. Returns 'control' (or your controlKey).
} else {
// Pennant flag was on — Splitstream.assign() ran, exposure logged.
// variant ∈ {'control', 'treatment', null}.
}
track("purchase-completed", { revenue: 49 });What the umbrella does (and doesn't)
- Does: read the Pennant flag with the same key shape (
${experimentKey}-enabledby default), gate the Splitstream call accordingly, expose a unifiedtrack()callback. Total wrapper LOC: ~150. - Doesn't:own state, persist anything, or replace either client's features. Both work standalone.
Override the kill-switch convention
new Growth({
pennant,
splitstream,
killSwitchKeyFor: (experimentKey) => `exp.${experimentKey}.gate`,
killSwitchDefault: false, // fail closed when the flag isn't in the snapshot
});React
import { GrowthProvider, useExperiment } from "@philiprehberger/growth/react";
function App() {
return (
<GrowthProvider growth={growth}>
<CheckoutButton />
</GrowthProvider>
);
}
function CheckoutButton() {
const { variant, killSwitchActive, isLoading, track } = useExperiment("checkout-v2", {
fallback: "control",
});
if (isLoading) return <button disabled>Loading…</button>;
return (
<button
className={variant === "treatment" ? "bg-green-500" : "bg-blue-500"}
onClick={() => track("purchase-completed", { revenue: 49 })}
>
Buy now
</button>
);
}Why ship them as separate products
Same buyer overlap, different artifacts. Pennant's headliner is the SDK + SSE story. Splitstream's headliner is the assignment API + the credible-interval readout. Sharing a repo would muddle both pitches. They cross-link from the docs sites, and the joint sample is the same five lines on both. The umbrella exists so the joint pitch can be one call.