Production-ready, feature-level canary releases for Node.js. Route specific users to specific features without affecting the rest of your user base.
npm install @futurmille/canary- Architecture
- Quick Start
- Core Concepts
- Storage Adapters
- Framework Integration
- Dashboard
- Observability Hooks
- Custom Strategies
- Graceful Degradation
- API Reference
- Testing
- Real-World Scenario
- Runnable Examples
┌─────────────────────────────────────────────────────────┐
│ Your Application │
├─────────────┬───────────────────────────┬───────────────┤
│ Express │ NestJS │ Fastify / │
│ Middleware │ Guard + Decorators │ Hapi / any │
├─────────────┴───────────────────────────┴───────────────┤
│ CanaryManager │
│ (assignment, rollout, rollback) │
├──────────────────┬──────────────────────────────────────┤
│ Strategies │ Storage (Port) │
│ ┌────────────┐ │ ┌──────────────┐ ┌──────────────┐ │
│ │ Percentage │ │ │ InMemory │ │ Redis │ │
│ │ Whitelist │ │ │ (tests/dev) │ │ (production) │ │
│ │ Attribute │ │ └──────────────┘ └──────────────┘ │
│ │ Custom... │ │ ┌──────────────┐ │
│ └────────────┘ │ │ Your Adapter │ │
│ │ └──────────────┘ │
├──────────────────┴──────────────────────────────────────┤
│ Observability Hooks │
│ onAssignment · onExposure · onRollback │
└─────────────────────────────────────────────────────────┘
Design principles:
- Ports & Adapters — storage and strategies are interfaces; swap implementations without touching business logic
- Dependency Inversion — consumers depend on
ICanaryStorageandIAssignmentStrategy, not concrete classes - Single Responsibility — routing logic, storage, assignment, and observability are separate concerns
- Zero dependencies — the core package has no runtime dependencies; Redis is an optional peer dep
import { CanaryManager, InMemoryStorage } from '@futurmille/canary';
// 1. Create the manager with a storage backend
const manager = new CanaryManager({
storage: new InMemoryStorage(), // Use RedisStorage in production
});
// 2. Define an experiment with assignment strategies
await manager.createExperiment('checkout-v2', [
{ type: 'whitelist', userIds: ['internal-tester'] }, // Always canary
{ type: 'attribute', attribute: 'plan', values: ['enterprise'] }, // Enterprise gets canary
{ type: 'percentage', percentage: 10 }, // 10% of everyone else
]);
// 3. Resolve which variant a user should see
const variant = await manager.getVariant(
{ id: 'user-123', attributes: { plan: 'free', country: 'US' } },
'checkout-v2',
);
if (variant === 'canary') {
// Show new checkout
} else {
// Show current checkout
}An experiment represents a single feature you want to canary. Each experiment has:
- A unique name (identifier)
- An enabled flag (can be toggled without deleting)
- A list of strategies (evaluated in order)
// Create
const exp = await manager.createExperiment('search-v2', strategies, 'New search engine');
// Read
const exp = await manager.getExperiment('search-v2');
const all = await manager.listExperiments();
// Update (partial)
await manager.updateExperiment('search-v2', { enabled: false });
await manager.updateExperiment('search-v2', {
strategies: [{ type: 'percentage', percentage: 50 }],
});
// Delete (also removes all assignments)
await manager.deleteExperiment('search-v2');Strategies determine which users get the canary variant. They are evaluated in order — the first match wins. If no strategy matches, the user gets stable.
Deterministic hash-based bucketing using FNV-1a. The same user always lands in the same bucket for a given experiment, even across restarts.
{ type: 'percentage', percentage: 25 } // 25% of users get canaryExplicit user IDs. Use for internal team testing, beta users, or specific accounts.
{ type: 'whitelist', userIds: ['alice', 'bob', 'qa-account-1'] }Match on user attributes like country, plan tier, role, or any custom property.
{ type: 'attribute', attribute: 'country', values: ['US', 'CA'] }
{ type: 'attribute', attribute: 'plan', values: ['enterprise', 'business'] }
{ type: 'attribute', attribute: 'beta', values: [true] }Strategies compose naturally. This configuration means:
- Internal testers always get canary
- Enterprise users always get canary
- 10% of remaining users get canary
- Everyone else gets stable
await manager.createExperiment('checkout-v2', [
{ type: 'whitelist', userIds: ['qa-1', 'qa-2'] },
{ type: 'attribute', attribute: 'plan', values: ['enterprise'] },
{ type: 'percentage', percentage: 10 },
]);The system needs two things to decide who gets canary:
getUserFromRequest— extracts user identity + attributes from the incoming request- Strategies — rules that match against those attributes
The connection between them:
getUserFromRequest Strategies
══════════════════ ══════════
Request ──→ Extract from JWT/session/headers ──→ { id, attributes } ──→ Evaluate rules ──→ 'canary' | 'stable'
JWT / Passport (most common in production):
getUserFromRequest: (req) => {
// Passport populates req.user after AuthGuard runs
const user = req['user'] as any;
if (!user) return null; // unauthenticated → stable
return {
id: user.sub, // ← used by whitelist strategy
attributes: {
plan: user.plan, // ← used by attribute strategy (plan = enterprise?)
role: user.role, // ← used by attribute strategy (role = admin?)
country: user.country, // ← used by attribute strategy (country = US?)
company: user.orgId, // ← used by attribute strategy (specific company?)
},
};
},Session-based auth:
getUserFromRequest: (req) => {
const session = req['session'] as any;
if (!session?.userId) return null;
return {
id: session.userId,
attributes: {
plan: session.plan,
role: session.role,
},
};
},API key / header-based (for testing or internal services):
getUserFromRequest: (req) => {
const headers = req['headers'] as Record<string, string>;
const userId = headers['x-user-id'];
if (!userId) return null;
return {
id: userId,
attributes: {
plan: headers['x-user-plan'] || 'free',
country: headers['x-user-country'] || 'US',
},
};
},| I want to canary... | Strategy to use | Example |
|---|---|---|
| Specific user IDs (QA, internal team) | whitelist |
{ type: 'whitelist', userIds: ['qa-1', 'dev-alice'] } |
| All enterprise customers | attribute |
{ type: 'attribute', attribute: 'plan', values: ['enterprise'] } |
| Users in US and Canada | attribute |
{ type: 'attribute', attribute: 'country', values: ['US', 'CA'] } |
| Admin users only | attribute |
{ type: 'attribute', attribute: 'role', values: ['admin'] } |
| A specific company/org | attribute |
{ type: 'attribute', attribute: 'company', values: ['acme-corp'] } |
| 5% of all users randomly | percentage |
{ type: 'percentage', percentage: 5 } |
| Beta opt-in users | attribute |
{ type: 'attribute', attribute: 'beta', values: [true] } |
Strategies are evaluated top to bottom. First match wins, rest are skipped:
await manager.createExperiment('new-dashboard', [
// Priority 1: QA team — always canary, regardless of anything else
{ type: 'whitelist', userIds: ['qa-maria', 'qa-john'] },
// Priority 2: Enterprise customers — always canary
{ type: 'attribute', attribute: 'plan', values: ['enterprise', 'business'] },
// Priority 3: US users only (not ready for other regions yet)
{ type: 'attribute', attribute: 'country', values: ['US'] },
// Priority 4: 0% of remaining users (will increase gradually)
{ type: 'percentage', percentage: 0 },
]);
// Later: start rolling out to 5% of remaining users
await manager.increaseRollout('new-dashboard', 5);In this example:
qa-maria→ canary (matched by whitelist, stops here)- Enterprise user in France → canary (matched by attribute plan, stops here)
- Free user in US → canary (matched by attribute country, stops here)
- Free user in Germany → stable or canary (only if in the 5% bucket)
Once a user is assigned a variant, they always get the same variant for that experiment — even if you change the experiment config later. Assignments are persisted in storage.
await manager.getVariant(user, 'exp'); // 'canary' (first call: evaluates strategies, persists)
await manager.getVariant(user, 'exp'); // 'canary' (returned from storage, no re-evaluation)In multi-process deployments (Redis), sticky assignments use atomic SETNX operations to guarantee exactly one process wins the assignment race.
Increase the canary percentage over time without reassigning existing users:
// Start small
await manager.createExperiment('search-v2', [
{ type: 'percentage', percentage: 5 },
]);
// Monitor metrics, then increase
await manager.increaseRollout('search-v2', 10); // 5% → 10%
await manager.increaseRollout('search-v2', 25); // 10% → 25%
await manager.increaseRollout('search-v2', 50); // 25% → 50%
await manager.increaseRollout('search-v2', 100); // Full rolloutHow it works: The percentage strategy uses a deterministic hash. A user's bucket (0-99) never changes — only the threshold moves. So a user who was canary at 5% is still canary at 50%. Users who were stable at 5% might become canary at 50% if their bucket falls below the new threshold.
One call to move all users back to stable. No redeployment needed:
await manager.rollback('search-v2');This:
- Deletes all persisted assignments for the experiment
- Disables the experiment (so new requests also get
stable) - Fires the
onRollbackhook
To re-enable after a rollback:
await manager.updateExperiment('search-v2', { enabled: true });Best for: tests, single-process dev servers, prototyping.
import { InMemoryStorage } from '@futurmille/canary';
const storage = new InMemoryStorage();
// Test helper: wipe all data between tests
storage.clear();Best for: production, multi-process deployments (PM2, cluster mode, Kubernetes).
npm install ioredisimport Redis from 'ioredis';
import { RedisStorage } from '@futurmille/canary';
const storage = new RedisStorage({
client: new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
}),
prefix: 'myapp:canary:', // optional, defaults to "canary:"
});
const manager = new CanaryManager({ storage });Thread safety: saveAssignmentIfNotExists uses Redis SETNX (set-if-not-exists), guaranteeing that exactly one process wins the assignment race in concurrent deployments.
Implement the ICanaryStorage interface to use any backend (PostgreSQL, DynamoDB, MongoDB, etc.):
import { ICanaryStorage, CanaryExperiment, Assignment } from '@futurmille/canary';
class PostgresStorage implements ICanaryStorage {
constructor(private pool: Pool) {}
async getExperiment(name: string): Promise<CanaryExperiment | null> {
const { rows } = await this.pool.query(
'SELECT data FROM canary_experiments WHERE name = $1',
[name],
);
return rows[0]?.data ?? null;
}
async saveExperiment(experiment: CanaryExperiment): Promise<void> {
await this.pool.query(
`INSERT INTO canary_experiments (name, data) VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET data = $2`,
[experiment.name, experiment],
);
}
async deleteExperiment(name: string): Promise<void> { /* ... */ }
async listExperiments(): Promise<CanaryExperiment[]> { /* ... */ }
async getAssignment(userId: string, experimentName: string): Promise<Assignment | null> { /* ... */ }
async saveAssignment(assignment: Assignment): Promise<void> { /* ... */ }
async deleteAssignment(userId: string, experimentName: string): Promise<void> { /* ... */ }
async deleteAllAssignments(experimentName: string): Promise<number> { /* ... */ }
// Use INSERT ... ON CONFLICT DO NOTHING + check affected rows for atomicity
async saveAssignmentIfNotExists(assignment: Assignment): Promise<boolean> {
const { rowCount } = await this.pool.query(
`INSERT INTO canary_assignments (user_id, experiment_name, data)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[assignment.userId, assignment.experimentName, assignment],
);
return (rowCount ?? 0) > 0;
}
}Evaluates the experiment for every request and attaches the result to req.canaryVariant:
import express from 'express';
import { CanaryManager, InMemoryStorage, canaryMiddleware } from '@futurmille/canary';
const app = express();
const manager = new CanaryManager({ storage: new InMemoryStorage() });
// Apply globally
app.use(canaryMiddleware(manager, {
experimentName: 'checkout-v2',
getUserFromRequest: (req) => {
const user = (req as any).user; // from your auth middleware
if (!user) return null;
return {
id: user.id,
attributes: { plan: user.plan, country: user.country },
};
},
}));
// Use in any route handler
app.get('/checkout', (req, res) => {
const variant = (req as any).canaryVariant; // 'stable' | 'canary'
if (variant === 'canary') {
return res.render('checkout-v2');
}
return res.render('checkout');
});Middleware options:
| Option | Type | Default | Description |
|---|---|---|---|
experimentName |
string |
required | Experiment to evaluate |
getUserFromRequest |
(req) => CanaryUser | null |
required | Extract user from request |
requestProperty |
string |
'canaryVariant' |
Property name on req |
setHeader |
boolean |
true |
Set X-Canary-Variant response header |
Returns 404 for non-canary users — the route doesn't exist for them:
import { canaryGuard } from '@futurmille/canary';
app.get('/checkout/v2-preview',
canaryGuard(manager, {
experimentName: 'checkout-v2',
getUserFromRequest: (req) => {
const user = (req as any).user;
return user ? { id: user.id } : null;
},
}),
(req, res) => {
// Only canary users reach this handler
res.json({ message: 'Welcome to checkout v2!' });
},
);The package provides a proper CanaryModule with forRoot() and forRootAsync() — the standard NestJS dynamic module pattern.
// app.module.ts
import { Module } from '@nestjs/common';
import { CanaryModule, InMemoryStorage } from '@futurmille/canary';
@Module({
imports: [
CanaryModule.forRoot({
// Storage backend (swap to RedisStorage for production)
storage: new InMemoryStorage(),
// How to extract a user from the request — set once, used by all guards
getUserFromRequest: (req) => {
const user = req['user'] as any; // from your auth middleware / passport
if (!user) return null;
return {
id: user.id,
attributes: { plan: user.plan, country: user.country },
};
},
// Auto-create experiments on startup (won't overwrite existing)
experiments: [
{
name: 'product-page-v2',
strategies: [
{ type: 'whitelist', userIds: ['admin-1', 'qa-1'] },
{ type: 'attribute', attribute: 'plan', values: ['enterprise'] },
{ type: 'percentage', percentage: 10 },
],
},
],
// Observability hooks
hooks: {
onAssignment: (e) => console.log(`[canary] ${e.user.id} → ${e.variant}`),
onRollback: (e) => console.log(`[rollback] ${e.experiment}`),
},
}),
],
})
export class AppModule {}The CanaryGuard is resolved from DI — no new, no constructor args. The @CanaryExperiment() decorator tells the guard which experiment to evaluate.
// products.controller.ts
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { CanaryGuard, CanaryExperiment, CanaryManager, Variant } from '@futurmille/canary';
@Controller('products')
export class ProductsController {
constructor(private readonly canaryManager: CanaryManager) {}
@UseGuards(CanaryGuard) // ← resolved from DI, no manual instantiation
@CanaryExperiment('product-page-v2') // ← which experiment to evaluate
@Get(':id')
async getProduct(@Param('id') id: string, @Req() req: any) {
const variant: Variant = req.canaryVariant; // set by CanaryGuard
if (variant === 'canary') {
return {
id,
name: 'Widget',
price: 29.99,
reviews: { average: 4.5, count: 128 }, // new canary feature
aiSummary: 'Customers love this widget.', // new canary feature
};
}
return { id, name: 'Widget', price: 29.99 };
}
}// admin.controller.ts
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
import { CanaryManager } from '@futurmille/canary';
@Controller('admin/canary')
export class AdminController {
constructor(private readonly canaryManager: CanaryManager) {}
@Get('experiments')
listExperiments() {
return this.canaryManager.listExperiments();
}
@Post(':name/rollout')
increaseRollout(@Param('name') name: string, @Body() body: { percentage: number }) {
return this.canaryManager.increaseRollout(name, body.percentage);
}
@Post(':name/rollback')
rollback(@Param('name') name: string) {
return this.canaryManager.rollback(name);
}
}For when you need to inject ConfigService, Redis connections, etc.:
import { CanaryModule, RedisStorage } from '@futurmille/canary';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Module({
imports: [
ConfigModule.forRoot(),
CanaryModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
storage: new RedisStorage({
client: new Redis(config.get('REDIS_URL')),
prefix: `${config.get('APP_NAME')}:canary:`,
}),
getUserFromRequest: (req) => {
const user = req['user'] as any;
return user ? { id: user.sub, attributes: { plan: user.plan } } : null;
},
}),
}),
],
})
export class AppModule {}Module options:
| Option | Type | Default | Description |
|---|---|---|---|
storage |
ICanaryStorage |
required | Storage backend |
getUserFromRequest |
(req) => CanaryUser | null |
required | Extract user from request |
hooks |
CanaryHooks |
undefined |
Observability hooks |
defaultVariant |
Variant |
'stable' |
Fallback variant |
isGlobal |
boolean |
true |
Register globally (available in all modules) |
denyStable |
boolean |
false |
Guards deny non-canary users (403) |
experiments |
Array<{name, strategies}> |
undefined |
Auto-create experiments on init |
import Fastify from 'fastify';
import { CanaryManager, InMemoryStorage, canaryFastifyPlugin } from '@futurmille/canary';
const fastify = Fastify();
const manager = new CanaryManager({ storage: new InMemoryStorage() });
canaryFastifyPlugin(fastify, manager, {
experimentName: 'checkout-v2',
getUserFromRequest: (request) => {
const user = request.user as any; // from your auth plugin
return user ? { id: user.id, attributes: { plan: user.plan } } : null;
},
});
fastify.get('/checkout', async (request) => {
const variant = (request as any).canaryVariant; // set by plugin
if (variant === 'canary') {
return { checkout: 'v2', aiRecommendations: true };
}
return { checkout: 'v1' };
});Works on Cloudflare Workers, Vercel Edge, Deno, Bun, and Node.js:
import { Hono } from 'hono';
import { CanaryManager, InMemoryStorage, canaryHonoMiddleware } from '@futurmille/canary';
const app = new Hono();
const manager = new CanaryManager({ storage: new InMemoryStorage() });
app.use('*', canaryHonoMiddleware(manager, {
experimentName: 'checkout-v2',
getUserFromContext: (c) => {
const userId = c.req.header('x-user-id');
if (!userId) return null;
return { id: userId, attributes: { plan: c.req.header('x-user-plan') || 'free' } };
},
}));
app.get('/checkout', (c) => {
const variant = c.get('canaryVariant'); // set by middleware
if (variant === 'canary') {
return c.json({ checkout: 'v2', aiRecommendations: true });
}
return c.json({ checkout: 'v1' });
});For any framework without a dedicated adapter, use manager.getVariant() directly. This also works for non-HTTP contexts like WebSockets, gRPC, or message queues:
// Hapi example
server.ext('onPreHandler', async (request, h) => {
const userId = request.headers['x-user-id'];
if (userId) {
request.app.canaryVariant = await manager.getVariant(
{ id: userId },
'checkout-v2',
);
} else {
request.app.canaryVariant = 'stable';
}
return h.continue;
});
// WebSocket example
ws.on('message', async (data) => {
const variant = await manager.getVariant(
{ id: socket.userId },
'realtime-v2',
);
// use variant to decide response format
});
// Message queue / worker example
async function processJob(job) {
const variant = await manager.getVariant(
{ id: job.userId, attributes: { plan: job.userPlan } },
'new-pipeline',
);
// use variant to decide processing logic
}Built-in browser dashboard for monitoring experiments and making rollout/rollback decisions. Self-contained HTML — zero frontend dependencies.
import {
CanaryManager,
CanaryMetricsCollector,
canaryDashboard,
canaryMiddleware,
canaryMetricsMiddleware,
} from '@futurmille/canary';
const manager = new CanaryManager({ storage });
const metrics = new CanaryMetricsCollector();
// Canary middleware (resolves variant per request)
app.use(canaryMiddleware(manager, { experimentName: 'product-v2', getUserFromRequest }));
// Metrics middleware (records response time + errors per variant)
app.use(canaryMetricsMiddleware(metrics, { experimentName: 'product-v2' }));
// Dashboard — one line
app.use('/canary', canaryDashboard(manager, metrics));Open http://localhost:3000/canary in your browser.
For each experiment:
- Status — ENABLED / DISABLED badge
- Strategies — whitelist (3), plan: enterprise, rollout: 10%
- Verdict — "Canary is performing better — safe to increase rollout" / "consider rollback" / "not enough data"
- Side-by-side metrics — stable vs canary: requests, unique users, avg/p95 latency, error rate with visual bars
- Time & error diff — "+13.2ms, -0.87%"
- Increase Rollout — prompts for a new percentage (e.g., 10% → 50%)
- Rollback — clears all assignments, disables experiment, all users see stable immediately
- Re-enable — appears after rollback, re-enables the experiment
- Delete — removes the experiment and all its assignments permanently
The dashboard reloads every 10 seconds so metrics update in real time.
The dashboard also exposes a JSON API for programmatic access:
# All experiment data + metrics
GET /canary/api/data
# Increase rollout
POST /canary/api/product-v2/rollout { "percentage": 50 }
# Rollback
POST /canary/api/product-v2/rollback
# Re-enable
POST /canary/api/product-v2/enable
# Delete
DELETE /canary/api/product-v2In NestJS, mount the dashboard on any route using a controller:
import { Controller, All, Req, Res } from '@nestjs/common';
import { CanaryManager, CanaryMetricsCollector, canaryDashboard } from '@futurmille/canary';
@Controller('canary')
export class CanaryDashboardController {
private handler: ReturnType<typeof canaryDashboard>;
constructor(private manager: CanaryManager) {
this.handler = canaryDashboard(manager, new CanaryMetricsCollector(), {
basePath: '/canary',
});
}
@All('*')
handleDashboard(@Req() req: any, @Res() res: any) {
this.handler(req, res, () => {
res.status(404).json({ error: 'Not found' });
});
}
}Three hooks let you integrate with your metrics, analytics, and alerting systems:
const manager = new CanaryManager({
storage,
hooks: {
// Fires on every getVariant() call
onAssignment: (event) => {
// event.user — the CanaryUser
// event.experiment — experiment name
// event.variant — 'stable' | 'canary'
// event.reason — which strategy matched (e.g., 'percentage', 'whitelist')
// event.cached — true if this was a sticky session hit (no re-evaluation)
metrics.increment('canary.assignment', {
experiment: event.experiment,
variant: event.variant,
cached: String(event.cached),
});
},
// Fires when you call recordExposure() — when the user actually *sees* the feature
onExposure: (event) => {
analytics.track('canary_exposure', {
userId: event.user.id,
experiment: event.experiment,
variant: event.variant,
});
},
// Fires on rollback()
onRollback: (event) => {
// event.experiment — experiment name
// event.previousAssignments — how many assignments were cleared
slack.send(`Rolled back ${event.experiment}: cleared ${event.previousAssignments} assignments`);
},
},
});
// Track when a user actually sees the canary feature (not just assignment)
app.get('/checkout', async (req, res) => {
const variant = await manager.getVariant(user, 'checkout-v2');
if (variant === 'canary') {
await manager.recordExposure(user, 'checkout-v2'); // fires onExposure
return res.render('checkout-v2');
}
return res.render('checkout');
});Hook errors are caught silently — they never break the request pipeline or throw to the caller.
Register your own strategy by implementing the IAssignmentStrategy interface:
import { IAssignmentStrategy, CanaryUser, StrategyConfig, Variant } from '@futurmille/canary';
interface TimeWindowConfig extends StrategyConfig {
type: 'time-window';
startHour: number; // 0-23
endHour: number; // 0-23
}
class TimeWindowStrategy implements IAssignmentStrategy {
readonly type = 'time-window';
evaluate(user: CanaryUser, config: StrategyConfig): Variant | null {
if (config.type !== 'time-window') return null;
const { startHour, endHour } = config as TimeWindowConfig;
const hour = new Date().getUTCHours();
return hour >= startHour && hour < endHour ? 'canary' : null;
}
}
// Register it
manager.registerStrategy(new TimeWindowStrategy());
// Use it in an experiment
await manager.createExperiment('off-peak-feature', [
{ type: 'time-window', startHour: 2, endHour: 6 } as any,
]);If storage is unavailable (Redis down, network error), getVariant() returns the default variant ('stable') instead of throwing. Your application stays up.
// Customize the fallback variant
const manager = new CanaryManager({
storage,
defaultVariant: 'stable', // default; could also set to 'canary' if you want fail-open
});| Method | Returns | Description |
|---|---|---|
createExperiment(name, strategies, description?) |
Promise<CanaryExperiment> |
Create a new experiment |
getExperiment(name) |
Promise<CanaryExperiment | null> |
Get experiment by name |
listExperiments() |
Promise<CanaryExperiment[]> |
List all experiments |
updateExperiment(name, updates) |
Promise<CanaryExperiment> |
Update experiment config |
deleteExperiment(name) |
Promise<void> |
Delete experiment and all its assignments |
getVariant(user, experimentName) |
Promise<Variant> |
Resolve variant with sticky sessions |
recordExposure(user, experimentName) |
Promise<void> |
Fire the onExposure hook |
increaseRollout(experimentName, newPct) |
Promise<CanaryExperiment> |
Increase canary percentage |
rollback(experimentName) |
Promise<void> |
Clear assignments + disable experiment |
registerStrategy(strategy) |
void |
Add a custom assignment strategy |
type Variant = 'stable' | 'canary';
interface CanaryUser {
id: string;
attributes?: Record<string, string | number | boolean>;
}
interface CanaryConfig {
storage: ICanaryStorage;
hooks?: CanaryHooks;
defaultVariant?: Variant; // defaults to 'stable'
}
interface CanaryExperiment {
name: string;
description?: string;
enabled: boolean;
strategies: StrategyConfig[];
createdAt: string;
updatedAt: string;
}type StrategyConfig =
| { type: 'percentage'; percentage: number } // 0-100
| { type: 'whitelist'; userIds: string[] }
| { type: 'attribute'; attribute: string; values: Array<string | number | boolean> };interface AssignmentEvent {
user: CanaryUser;
experiment: string;
variant: Variant;
reason: string; // 'percentage' | 'whitelist' | 'attribute' | 'no-strategy-matched'
cached: boolean; // true = sticky session hit
}
interface ExposureEvent {
user: CanaryUser;
experiment: string;
variant: Variant;
}
interface RollbackEvent {
experiment: string;
previousAssignments: number; // how many assignments were cleared
}The package ships with InMemoryStorage specifically for test environments:
import { CanaryManager, InMemoryStorage } from '@futurmille/canary';
describe('checkout feature', () => {
let manager: CanaryManager;
let storage: InMemoryStorage;
beforeEach(async () => {
storage = new InMemoryStorage();
manager = new CanaryManager({ storage });
await manager.createExperiment('checkout-v2', [
{ type: 'percentage', percentage: 100 }, // everyone gets canary in tests
]);
});
afterEach(() => {
storage.clear(); // reset between tests
});
it('serves new checkout to canary users', async () => {
const variant = await manager.getVariant({ id: 'test-user' }, 'checkout-v2');
expect(variant).toBe('canary');
});
});Here's how a typical canary rollout works end-to-end:
// Day 1: Create experiment, internal team only
await manager.createExperiment('new-payment-flow', [
{ type: 'whitelist', userIds: ['eng-alice', 'eng-bob', 'qa-charlie'] },
{ type: 'percentage', percentage: 0 },
]);
// Day 2: QA passes, open to 1% of users
await manager.increaseRollout('new-payment-flow', 1);
// Day 3: Metrics look good, increase to 10%
await manager.increaseRollout('new-payment-flow', 10);
// Day 3 (later): Error rate spikes — instant rollback
await manager.rollback('new-payment-flow');
// All users immediately see stable. No deploy needed.
// Day 4: Bug fixed, re-enable at 5%
await manager.updateExperiment('new-payment-flow', { enabled: true });
await manager.increaseRollout('new-payment-flow', 5);
// Day 7: 50%, then 100%
await manager.increaseRollout('new-payment-flow', 50);
await manager.increaseRollout('new-payment-flow', 100);
// Day 14: Fully rolled out — clean up
await manager.deleteExperiment('new-payment-flow');The repo includes complete, runnable example apps:
examples/express-app/— Express server with canary middleware, guards, and admin endpointsexamples/nestjs-app/— NestJS app withCanaryModule.forRoot(), guard + decorator pattern, and admin controller
# Express
cd examples/express-app && npm install && npm start
# NestJS
cd examples/nestjs-app && npm install && npm startBoth examples run on http://localhost:3000 with curl-friendly endpoints for testing.
MIT
