npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@apptegy/experiments

v0.0.2

Published

Framework-agnostic A/B test bucketing with deterministic MurmurHash3 assignment and pluggable Amplitude exposure reporting

Readme

@apptegy/experiments

Framework-agnostic A/B test bucketing for Apptegy frontends.

  • Deterministic assignment via vendored MurmurHash3 — the same user is consistently in or out of a test, and consistently gets the same option.
  • Decides inclusion (who is in the test) and variant selection (which option) with two independent hashes.
  • Reports in-test exposures to Amplitude through an injectable adapter — the package never bundles the Amplitude SDK.
  • Zero runtime dependencies, ESM-only, works in the browser and in Node/SSR.

Install

pnpm add @apptegy/experiments

How it works

Everything is derived from a stable userId plus the experiment key. No network calls, no shared state — given the same inputs you always get the same result, on any device, in any process.

1. Deterministic buckets

A string is hashed with MurmurHash3 (x86, 32-bit) and normalized into a bucket in the range [0, 1):

bucketOf(str) = murmur3_32(str) / 2^32

2. Two independent decisions

Each experiment makes two decisions, each keyed with a distinct salt suffix so they never interfere:

inclusion bucket = bucketOf(`${salt}:${userId}:inclusion`)
variant bucket   = bucketOf(`${salt}:${userId}:variant`)
  • Inclusion — is the user in the test? included = inclusionBucket < allocation (e.g. allocation: 0.01 ≈ 1% of users). enabled: false or allocation: 0 excludes everyone; allocation: 1 includes everyone.
  • Variant selection — which arm? The variant bucket is mapped onto the arms' cumulative normalized weights (equal weight by default), so a 2-arm test splits 50/50 among included users.

Because the two use separate hashes, changing allocation never moves a user's variant, and choosing a variant never affects who's included.

3. Excluded users get the control

The first arm you list is the control. Excluded users (and any error-safe fallback) always resolve to it, so callers can render a concrete option without null checks. assign() returns:

{ experiment: string; included: boolean; variant: string; bucket: number; forced: boolean }

Two properties worth knowing

  • Monotonic ramp-up. Increasing allocation (1% → 10% → 100%) only adds users; everyone already in the test keeps their variant. Safe to ramp live.
  • salt is the reshuffle knob. salt defaults to key. Changing key or salt re-randomizes membership and variants; keep them stable to keep results stable.

Quick start

The one-liner: give a user id, a test key, and a list of options, and get back the option that user should see.

import { getOption, amplitudeReporter } from '@apptegy/experiments';

const option = getOption(userId, 'checkout-cta', ['control', 'variant-b'], {
  reporter: amplitudeReporter(amplitude), // your existing Amplitude instance
});
// Users not in the test (default ~99%) get 'control' and nothing is reported.
// Users in the test (default ~1%) get a deterministic option, reported as a
// `$exposure` event.

For an app that runs several experiments for one user, create a client once:

import { createExperimentClient, amplitudeReporter, browserOverrides } from '@apptegy/experiments';

const experiments = createExperimentClient({
  context: { userId, clientId },
  reporter: amplitudeReporter(amplitude),
  overrides: browserOverrides(), // enables the QA console API (see below)
});

const { variant, included } = experiments.assign({
  key: 'checkout-cta',
  variants: ['control', 'variant-b'],
  allocation: 0.01, // fraction included; defaults to 0.01
});

Example implementation (Vue 3 / Nuxt, end-to-end)

A realistic wiring: create the client once per app, expose a reactive composable, render a variant in a component, and (optionally) drive allocation from a remote flag so you can ramp the rollout without a deploy.

1. Provide a client once (Nuxt plugin)

// plugins/experiments.client.js
import { defineNuxtPlugin } from '#app';
import { amplitudeReporter, browserOverrides, createExperimentClient } from '@apptegy/experiments/core';

export default defineNuxtPlugin((nuxtApp) => {
  const { $amplitude, $store } = nuxtApp;
  const userId = $store.getters['user/getUserId'];
  const clientId = $store.getters['user/getClientId'];

  const experiments = createExperimentClient({
    context: { userId, clientId },
    reporter: $amplitude ? amplitudeReporter($amplitude) : undefined,
    overrides: browserOverrides(), // QA console + URL/localStorage forcing
  });

  nuxtApp.provide('experiments', experiments); // -> useNuxtApp().$experiments
});

2. Wrap it in a reactive composable

// composables/useExperiment.ts
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useNuxtApp } from '#imports';

export function useExperiment(
  key: string,
  variants: string[] = ['control', 'variant-b'],
  allocation?: MaybeRefOrGetter<number | undefined>, // ref/getter -> reactive
) {
  const { $experiments } = useNuxtApp();

  const assignment = computed(() => {
    // Control fallback keeps SSR / pre-plugin renders safe.
    if (!$experiments) return { variant: variants[0], included: false };
    return $experiments.assign({ key, variants, allocation: toValue(allocation) });
  });

  return {
    variant: computed(() => assignment.value.variant),
    included: computed(() => assignment.value.included),
  };
}

3. Render the variant in a component

<script setup>
import { computed } from 'vue';
import { useExperiment } from '@/composables/useExperiment';

const { variant } = useExperiment('checkout-cta', ['control', 'variant-b']);
const isVariantB = computed(() => variant.value === 'variant-b');
</script>

<template>
  <PrimaryButton v-if="isVariantB" class="cta--wide">Get started</PrimaryButton>
  <IconButton v-else aria-label="Get started" />
</template>

Prefer to keep a shared component library generic? Compute the variant in the consuming app and pass it in as a plain prop (e.g. :cta-variant="variant"), so the library gains no dependency on @apptegy/experiments.

4. Drive allocation from a remote flag (optional)

allocation is read on every evaluation, so a reactive source (LaunchDarkly, remote config, env) lets you ramp the rollout live — no library republish, no consumer deploy once the plumbing exists:

const { $ld } = useNuxtApp();
// Flag serves an integer percentage (1 => 1% => 0.01); default 0% until it exists.
const { variant } = useExperiment('checkout-cta', ['control', 'variant-b'],
  () => ($ld?.flags?.checkoutCtaAllocation ?? 0) / 100,
);

Forcing a variant for QA

With a 1% allocation you cannot reliably land yourself in the treatment, so the package ships a browser console API (enabled by passing browserOverrides() as the overrides option). Open the JS console and run:

__experiments.force('checkout-cta', 'variant-b'); // pin a variant (persists)
__experiments.list();                              // show active overrides
__experiments.clear('checkout-cta');               // remove one
__experiments.clearAll();                           // remove all

You can also force a variant via a shareable URL: ?exp_checkout-cta=variant-b (URL takes precedence over localStorage). Forced assignments bypass hashing/allocation, return included: true, and report exposures flagged forced: true so QA traffic stays filterable.

Reporting to Amplitude

amplitudeReporter(instance) adapts any Amplitude-like object to the reporter interface. The instance only needs a track(name, props) method (the lms $amplitude wrapper and the raw @amplitude/analytics-browser namespace both qualify). On the first assign() for an included user it sends:

$exposure  { flag_key: '<key>', variant: '<variant>' }

Exposure is de-duplicated per (experiment, variant) for the client's lifetime, so calling assign() in a render loop won't spam Amplitude. If identify / Identify are present, a [Experiment] <key> user property is also set for chart segmentation.

Public API

import {
  getOption,
  createExperimentClient,
  amplitudeReporter,
  browserOverrides,
  noopReporter,
  bucketOf,
  murmur3_32,
  InvalidExperimentError,
} from '@apptegy/experiments';            // also '@apptegy/experiments/core'

import {
  createMockReporter,
  createMockExperimentClient,
  forceVariant,
} from '@apptegy/experiments/testing';

Testing your experiment code

The /testing entry lets consumers assert both variants without touching the hashing. createMockExperimentClient pins arms via forced; createMockReporter captures exposures.

import { createMockExperimentClient, createMockReporter } from '@apptegy/experiments/testing';

it('renders variant B and reports exposure', () => {
  const reporter = createMockReporter();
  const experiments = createMockExperimentClient({
    context: { userId: 'user-1' },
    reporter,
    forced: { 'checkout-cta': 'variant-b' }, // key -> variant
  });

  const { variant, included } = experiments.assign({ key: 'checkout-cta', variants: ['control', 'variant-b'] });

  expect(variant).toBe('variant-b');
  expect(included).toBe(true);
  expect(reporter.exposures).toContainEqual(
    expect.objectContaining({ experiment: 'checkout-cta', variant: 'variant-b' }),
  );
});

forceVariant(map) is also exported for building an OverrideStore to pass as overrides to a real createExperimentClient in tests.

Behavior

| Situation | variant | included | Exposure reported? | | --- | --- | --- | --- | | Not in test (default ~99%) | first option (control) | false | No | | In test (default ~1%) | deterministic option | true | Yes (once per experiment+variant) | | enabled: false or allocation: 0 | first option (control) | false | No | | Forced override (QA) | forced option | true | Yes, flagged forced: true | | Invalid config (empty variants, bad weights/allocation) | throws InvalidExperimentError | — | — |

Development

pnpm --filter @apptegy/experiments test       # run unit tests
pnpm --filter @apptegy/experiments build       # emit dist/ (js + d.ts)
pnpm --filter @apptegy/experiments lint
pnpm --filter @apptegy/experiments typecheck