Skip to content

Futurmille/canary

Repository files navigation

@futurmille/canary

npm version npm downloads CI TypeScript Node.js License: MIT Zero Dependencies

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

Canary Dashboard

Table of Contents

Architecture

┌─────────────────────────────────────────────────────────┐
│                    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 ICanaryStorage and IAssignmentStrategy, 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

Quick Start

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
}

Core Concepts

Experiments

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

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.

Percentage

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 canary

Whitelist

Explicit user IDs. Use for internal team testing, beta users, or specific accounts.

{ type: 'whitelist', userIds: ['alice', 'bob', 'qa-account-1'] }

Attribute

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] }

Combining strategies

Strategies compose naturally. This configuration means:

  1. Internal testers always get canary
  2. Enterprise users always get canary
  3. 10% of remaining users get canary
  4. 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 },
]);

How user targeting works

The system needs two things to decide who gets canary:

  1. getUserFromRequest — extracts user identity + attributes from the incoming request
  2. 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'

Real-world getUserFromRequest examples

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',
    },
  };
},

Targeting scenarios

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] }

Combining strategies (priority chain)

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-mariacanary (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)

Sticky Sessions

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.

Gradual Rollout

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 rollout

How 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.

Instant Rollback

One call to move all users back to stable. No redeployment needed:

await manager.rollback('search-v2');

This:

  1. Deletes all persisted assignments for the experiment
  2. Disables the experiment (so new requests also get stable)
  3. Fires the onRollback hook

To re-enable after a rollback:

await manager.updateExperiment('search-v2', { enabled: true });

Storage Adapters

InMemoryStorage

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();

RedisStorage

Best for: production, multi-process deployments (PM2, cluster mode, Kubernetes).

npm install ioredis
import 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.

Custom Adapter

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;
  }
}

Framework Integration

Express

Middleware (recommended for global experiments)

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

Guard (for canary-only routes)

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!' });
  },
);

NestJS

The package provides a proper CanaryModule with forRoot() and forRootAsync() — the standard NestJS dynamic module pattern.

Step 1: Register the module

// 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 {}

Step 2: Use in controllers

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 };
  }
}

Step 3 (optional): Admin endpoints for runtime control

// 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);
  }
}

Async configuration (production)

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

Fastify

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' };
});

Hono

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' });
});

Other Frameworks (Hapi, Koa, etc.)

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
}

Dashboard

Built-in browser dashboard for monitoring experiments and making rollout/rollback decisions. Self-contained HTML — zero frontend dependencies.

Setup

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.

What it shows

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%"

Action buttons

  • 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

Auto-refresh

The dashboard reloads every 10 seconds so metrics update in real time.

JSON API

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-v2

NestJS

In 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' });
    });
  }
}

Observability Hooks

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.

Custom Strategies

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,
]);

Graceful Degradation

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
});

API Reference

CanaryManager

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

Core Types

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;
}

Strategy Configs

type StrategyConfig =
  | { type: 'percentage'; percentage: number }          // 0-100
  | { type: 'whitelist'; userIds: string[] }
  | { type: 'attribute'; attribute: string; values: Array<string | number | boolean> };

Hook Event Types

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
}

Testing

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');
  });
});

Real-World Scenario

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');

Runnable Examples

The repo includes complete, runnable example apps:

  • examples/express-app/ — Express server with canary middleware, guards, and admin endpoints
  • examples/nestjs-app/ — NestJS app with CanaryModule.forRoot(), guard + decorator pattern, and admin controller
# Express
cd examples/express-app && npm install && npm start

# NestJS
cd examples/nestjs-app && npm install && npm start

Both examples run on http://localhost:3000 with curl-friendly endpoints for testing.

License

MIT

About

Production-ready, feature-level canary releases for Node.js. Route specific users to specific features without affecting the rest of your user base.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors