@99ways/posthog-page-experiments 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.2.0] - 2026-10-14
6
+
7
+ ### Added
8
+
9
+ - An assignment summary: every run logs one `console.info` line with the applied variant, its source (`posthog`, `url`, `fallback` or `ineligible`), the raw flag value, PostHog's evaluation reason, the device and distinct IDs, and a plain-language note when the default variant was shown for a specific cause, such as a timeout, an unknown variant or a browser without a PostHog device ID.
10
+ - A `log` option: `'summary'` (default), `'debug'` or `'silent'`.
11
+ - A `?experiment-debug` URL parameter that turns on debug logs for every experiment on the page, or `?experiment-debug=flag-a,flag-b` for the listed flags only.
12
+
13
+ ### Changed
14
+
15
+ - The package is no longer silent by default: each run logs its one-line assignment summary. Set `log: 'silent'` to restore the 0.1.0 output. `debug: true` still enables step-by-step diagnostics, as shorthand for `log: 'debug'`.
16
+
5
17
  ## [0.1.0] - 2026-09-17
6
18
 
7
19
  ### Added
@@ -14,4 +26,5 @@ All notable changes to this project will be documented here. The project follows
14
26
  - ESM, browser/IIFE, and TypeScript declaration builds.
15
27
  - Unit and real-browser tests using an actual `posthog-js` client.
16
28
 
29
+ [0.2.0]: https://github.com/99ways-io/posthog-page-experiments/releases/tag/v0.2.0
17
30
  [0.1.0]: https://github.com/99ways-io/posthog-page-experiments/releases/tag/v0.1.0
package/README.md CHANGED
@@ -64,10 +64,10 @@ The default variant must be present in `variants`. An empty `control: []` keeps
64
64
 
65
65
  ### Direct browser script
66
66
 
67
- After `0.1.0` is published, the minified browser build will be available from npm CDNs:
67
+ The minified browser build is available from npm CDNs:
68
68
 
69
69
  ```html
70
- <script src="https://cdn.jsdelivr.net/npm/@99ways/posthog-page-experiments@0.1.0/dist/posthog-page-experiments.min.js"></script>
70
+ <script src="https://cdn.jsdelivr.net/npm/@99ways/posthog-page-experiments@0.2.0/dist/posthog-page-experiments.min.js"></script>
71
71
  <script>
72
72
  window.runExperiment('pricing-page-headline', {
73
73
  isEligible: () => document.querySelector('.pricing-hero') !== null,
@@ -123,6 +123,31 @@ https://example.com/pricing?pricing-page-headline=test
123
123
 
124
124
  A configured URL variant takes precedence over PostHog, which makes review possible before a rollout exists. Eligibility still runs first. An unknown variant falls back to the configured default.
125
125
 
126
+ ## See why a variant was shown
127
+
128
+ Each run logs one assignment summary to the browser console:
129
+
130
+ ```text
131
+ [Experiment:pricing-page-headline] Variant 'control' (from posthog). {
132
+ featureFlag: 'pricing-page-headline',
133
+ variant: 'control',
134
+ source: 'posthog',
135
+ flagValue: false,
136
+ reason: 'Out of rollout bound',
137
+ deviceId: null,
138
+ distinctId: 'anonymous-visitor-id',
139
+ note: "PostHog returned no variant, and this browser has no PostHog device ID. Flags that bucket by device cannot assign it, so 'control' is shown."
140
+ }
141
+ ```
142
+
143
+ - `source` is `posthog`, `url` (a URL override), `fallback` (PostHog unavailable, timed out or failed) or `ineligible`.
144
+ - `reason`, `deviceId` and `distinctId` appear when the PostHog client exposes them. Recent `posthog-js` versions do.
145
+ - `note` explains plainly when the default variant was shown for a specific cause.
146
+
147
+ Reading these values does not record a feature-flag call.
148
+
149
+ For the step-by-step lifecycle, add `?experiment-debug` to the URL; `?experiment-debug=flag-a,flag-b` limits it to the listed flags. Alternatively, pass `log: 'debug'`. Use `log: 'silent'` to turn all output off.
150
+
126
151
  ## Choose the right tool
127
152
 
128
153
  Use this package when:
@@ -152,7 +177,8 @@ The promise rejects if eligibility or a variant handler throws.
152
177
  | `isEligible` | `() => boolean \| PromiseLike<boolean>` | `() => true` | Runs before the flag is evaluated. |
153
178
  | `posthog` | `PostHogClient` | `window.posthog` | Explicit initialized client for module use. |
154
179
  | `featureFlagTimeoutMs` | `number` | `4000` | Maximum wait for PostHog and its first feature-flag notification. |
155
- | `debug` | `boolean` | `false` | Enables lifecycle and fallback diagnostics. |
180
+ | `log` | `'summary' \| 'debug' \| 'silent'` | `'summary'` | Console output: one assignment summary, plus step-by-step diagnostics with `debug`, or nothing. |
181
+ | `debug` | `boolean` | `false` | Shorthand for `log: 'debug'`. Ignored when `log` is set. |
156
182
 
157
183
  Boolean flags map `true` to `test` and `false` to the default variant. String-valued flags map directly to the matching configured variant.
158
184
 
@@ -218,7 +244,7 @@ The package does not inject a global anti-flicker snippet because a generic page
218
244
 
219
245
  The distributed JavaScript targets modern evergreen browsers and uses standard browser APIs including `Promise`, `URLSearchParams`, and `querySelectorAll`.
220
246
 
221
- There is no runtime dependency on `posthog-js`. Compatibility is structural: the client must expose `__loaded`, `onFeatureFlags()`, and `getFeatureFlag()`. CI tests the current release against `posthog-js` 1.433.9 in Chromium.
247
+ There is no runtime dependency on `posthog-js`. Compatibility is structural: the client must expose `__loaded`, `onFeatureFlags()`, and `getFeatureFlag()`. The assignment summary also reads `featureFlags.getFeatureFlagDetails()`, `get_property()`, and `get_distinct_id()` when they exist. CI tests the current release against `posthog-js` 1.433.9 in Chromium.
222
248
 
223
249
  The project follows Semantic Versioning. Before `1.0.0`, breaking public-API changes may ship in a minor release; they will be called out in the changelog. The internal experiment class is not part of the public contract.
224
250
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  export type Awaitable<T> = T | PromiseLike<T>;
2
+ export interface PostHogFeatureFlagDetails {
3
+ reason?: {
4
+ code?: string | null;
5
+ description?: string | null;
6
+ } | null;
7
+ }
2
8
  export interface PostHogClient {
3
9
  __loaded?: boolean;
4
10
  getFeatureFlag(featureFlag: string): boolean | string | null | undefined;
5
11
  onFeatureFlags(callback: () => void): (() => void) | void;
12
+ /** Optional. Read for the assignment summary when the client provides it. */
13
+ featureFlags?: {
14
+ getFeatureFlagDetails?(featureFlag: string): PostHogFeatureFlagDetails | null | undefined;
15
+ };
16
+ /** Optional. Read for the assignment summary when the client provides it. */
17
+ get_property?(property: string): unknown;
18
+ /** Optional. Read for the assignment summary when the client provides it. */
19
+ get_distinct_id?(): string;
6
20
  }
7
21
  export type StyleUpdates = Record<string, string | number | null | undefined>;
8
22
  export interface ElementUpdates {
@@ -17,11 +31,34 @@ export interface ElementChange {
17
31
  }
18
32
  export type VariantStep = ElementChange | (() => unknown);
19
33
  export type VariantHandler = VariantStep[] | (() => unknown);
34
+ /**
35
+ * `summary` logs one assignment line per run, `debug` adds step-by-step diagnostics,
36
+ * and `silent` logs nothing.
37
+ */
38
+ export type ExperimentLogLevel = 'silent' | 'summary' | 'debug';
39
+ export type AssignmentSource = 'url' | 'posthog' | 'fallback' | 'ineligible';
40
+ /** What the assignment summary reports for one run. */
41
+ export interface ExperimentAssignment {
42
+ featureFlag: string;
43
+ /** The applied variant, or `null` when the visitor is ineligible. */
44
+ variant: string | null;
45
+ source: AssignmentSource;
46
+ /** The raw value PostHog returned. Present only when the source is `posthog`. */
47
+ flagValue?: boolean | string | null;
48
+ /** PostHog's evaluation reason, such as "Matched condition set 1", when the client exposes it. */
49
+ reason?: string;
50
+ deviceId?: string | null;
51
+ distinctId?: string | null;
52
+ /** A plain-language explanation when the default variant was shown for a specific cause. */
53
+ note?: string;
54
+ }
20
55
  export interface ExperimentOptions {
21
56
  variants: Record<string, VariantHandler>;
22
57
  defaultVariant?: string;
23
58
  isEligible?: () => Awaitable<boolean>;
24
59
  posthog?: PostHogClient;
60
+ log?: ExperimentLogLevel;
61
+ /** Shorthand for `log: 'debug'`. Ignored when `log` is set. */
25
62
  debug?: boolean;
26
63
  featureFlagTimeoutMs?: number;
27
64
  }
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- class m{featureFlag;variants;defaultVariant;isEligible;explicitPostHog;debug;featureFlagTimeoutMs;runPromise=null;constructor(e,t){this.featureFlag=e;if(!e.trim())throw TypeError("featureFlag must be a non-empty string.");if(this.defaultVariant=t.defaultVariant??"control",this.variants=new Map(Object.entries(t.variants)),this.isEligible=t.isEligible??(()=>!0),this.explicitPostHog=t.posthog,this.debug=t.debug??!1,this.featureFlagTimeoutMs=t.featureFlagTimeoutMs??4000,!Number.isFinite(this.featureFlagTimeoutMs)||this.featureFlagTimeoutMs<0)throw RangeError("featureFlagTimeoutMs must be a finite number greater than or equal to 0.");if(!this.defaultVariant.trim())throw TypeError("defaultVariant must be a non-empty string.");if(!this.variants.has(this.defaultVariant))throw TypeError(`variants must include the default variant '${this.defaultVariant}'.`)}run(){if(!this.runPromise)this.runPromise=this.runOnce();return this.runPromise}async runOnce(){if(typeof window>"u"||typeof document>"u")return this.log("Browser globals are unavailable; skipping the experiment."),null;if(await this.waitForDOMContentLoaded(),this.log("Checking eligibility before evaluating the feature flag."),!await this.isEligible())return this.log("Visitor is ineligible; skipping feature-flag evaluation and variant application."),null;let e=await this.resolveVariant(),t=this.getConfiguredVariantOrDefault(e);return this.log(`Activated variant '${t}'.`),this.applyVariant(t),t}getVariantFromQueryParam(){return new URLSearchParams(window.location.search).get(this.featureFlag)}getPostHog(){let e=this.explicitPostHog??window.posthog;if(e&&e.__loaded===!0&&typeof e.onFeatureFlags==="function"&&typeof e.getFeatureFlag==="function")return e;return}resolveVariant(){let e=this.getVariantFromQueryParam();if(e)return this.log(`Using query-string override variant '${e}'.`),Promise.resolve(e);return this.log("Waiting for initialized PostHog and its first feature-flag notification."),new Promise((t)=>{let i=!1,n,a=!1,r,o,u=()=>{if(!n)return!1;let s=n;n=void 0;try{s()}catch(l){this.warn(`Could not unsubscribe from feature flag '${this.featureFlag}'.`,l)}return!0},d=()=>{if(r!==void 0)clearInterval(r);if(o!==void 0)clearTimeout(o);if(!u())a=!0},g=(s)=>{if(i)return;i=!0,d(),t(s)},p=()=>{if(i)return!0;let s=this.getPostHog();if(!s)return!1;if(r!==void 0)clearInterval(r),r=void 0;try{this.log("PostHog is initialized; subscribing to feature flags.");let l=s.onFeatureFlags(()=>{try{let f=this.getPostHog();if(!f)throw Error("The initialized PostHog instance is no longer available.");let h=f.getFeatureFlag(this.featureFlag),c=this.mapFeatureFlagValue(h);this.log(`PostHog resolved variant '${c}' from raw value ${JSON.stringify(h)}.`),g(c)}catch(f){this.warn(`Could not read feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,f),g(this.defaultVariant)}});if(typeof l==="function")n=l;if(a)a=!1,u();return!0}catch(l){return this.warn(`Could not subscribe to feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,l),g(this.defaultVariant),!0}};if(o=setTimeout(()=>{this.warn(`Feature flag '${this.featureFlag}' was not resolved within ${this.featureFlagTimeoutMs}ms. Applying '${this.defaultVariant}'.`),g(this.defaultVariant)},this.featureFlagTimeoutMs),!p())r=setInterval(p,50)})}mapFeatureFlagValue(e){if(e===!0)return"test";if(e===!1||e===null||e===void 0||e==="")return this.defaultVariant;return e}getConfiguredVariantOrDefault(e){if(this.variants.has(e))return e;return this.warn(`Variant '${e}' is not configured for '${this.featureFlag}'. Applying '${this.defaultVariant}'.`),this.defaultVariant}waitForDOMContentLoaded(){if(document.readyState!=="loading")return this.log("DOM is already ready; skipping the DOMContentLoaded wait."),Promise.resolve();return this.log("Waiting for DOMContentLoaded."),new Promise((e)=>{document.addEventListener("DOMContentLoaded",()=>{this.log("DOMContentLoaded received."),e()},{once:!0})})}applyVariant(e){let t=this.variants.get(e);if(!t)return;this.log(`Applying variant '${e}'.`),this.applyHandler(t,e),this.log(`Finished applying variant '${e}'.`)}applyHandler(e,t){if(typeof e==="function"){e();return}e.forEach((i)=>{if(typeof i==="function"){i();return}this.applyUpdates(i.selector,i.updates,t)})}applyUpdates(e,t,i){let n=document.querySelectorAll(e);if(!n.length){this.warn(`No elements found for selector '${e}'.`);return}this.log(`Selector '${e}' matched ${n.length} element(s).`),n.forEach((a)=>{if(t.style)Object.entries(t.style).forEach(([r,o])=>{if(o===null||o===void 0)return;let u=r.startsWith("--")?r:r.replace(/[A-Z]/g,(d)=>`-${d.toLowerCase()}`);a.style.setProperty(u,String(o),"important")});if(t.innerText!==void 0)a.innerText=t.innerText;if(t.innerHTML!==void 0)a.innerHTML=t.innerHTML;t.callback?.(a,i)})}log(e,...t){if(this.debug)console.log(`[Experiment:${this.featureFlag}] ${e}`,...t)}warn(e,...t){if(this.debug)console.warn(`[Experiment:${this.featureFlag}] ${e}`,...t)}}function y(e,t){return new m(e,t).run()}export{y as runExperiment};
1
+ class m{featureFlag;variants;defaultVariant;isEligible;explicitPostHog;logLevel;featureFlagTimeoutMs;runPromise=null;constructor(e,t){this.featureFlag=e;if(!e.trim())throw TypeError("featureFlag must be a non-empty string.");if(this.defaultVariant=t.defaultVariant??"control",this.variants=new Map(Object.entries(t.variants)),this.isEligible=t.isEligible??(()=>!0),this.explicitPostHog=t.posthog,this.logLevel=t.log??(t.debug?"debug":"summary"),this.featureFlagTimeoutMs=t.featureFlagTimeoutMs??4000,!["silent","summary","debug"].includes(this.logLevel))throw TypeError("log must be 'silent', 'summary', or 'debug'.");if(!Number.isFinite(this.featureFlagTimeoutMs)||this.featureFlagTimeoutMs<0)throw RangeError("featureFlagTimeoutMs must be a finite number greater than or equal to 0.");if(!this.defaultVariant.trim())throw TypeError("defaultVariant must be a non-empty string.");if(!this.variants.has(this.defaultVariant))throw TypeError(`variants must include the default variant '${this.defaultVariant}'.`)}run(){if(!this.runPromise)this.runPromise=this.runOnce();return this.runPromise}async runOnce(){if(typeof window>"u"||typeof document>"u")return this.log("Browser globals are unavailable; skipping the experiment."),null;if(this.isDebugRequestedByUrl())this.logLevel="debug";if(await this.waitForDOMContentLoaded(),this.log("Checking eligibility before evaluating the feature flag."),!await this.isEligible())return this.log("Visitor is ineligible; skipping feature-flag evaluation and variant application."),this.summarize({featureFlag:this.featureFlag,variant:null,source:"ineligible",note:"The visitor is not eligible, so the feature flag was not evaluated."}),null;let e=await this.resolveVariant(),t=this.getConfiguredVariantOrDefault(e.value);return this.log(`Activated variant '${t}'.`),this.summarize(this.describeAssignment(e,t)),this.applyVariant(t),t}getVariantFromQueryParam(){return new URLSearchParams(window.location.search).get(this.featureFlag)}isDebugRequestedByUrl(){let e=new URLSearchParams(window.location.search).get("experiment-debug");if(e===null)return!1;let t=e.split(",").map((i)=>i.trim()).filter(Boolean);return t.length===0||t.includes("1")||t.includes("true")||t.includes(this.featureFlag)}getPostHog(){let e=this.explicitPostHog??window.posthog;if(e&&e.__loaded===!0&&typeof e.onFeatureFlags==="function"&&typeof e.getFeatureFlag==="function")return e;return}resolveVariant(){let e=this.getVariantFromQueryParam();if(e)return this.log(`Using query-string override variant '${e}'.`),Promise.resolve({value:e,source:"url"});return this.log("Waiting for initialized PostHog and its first feature-flag notification."),new Promise((t)=>{let i=!1,r,n=!1,a,s,o=()=>{if(!r)return!1;let l=r;r=void 0;try{l()}catch(u){this.warn(`Could not unsubscribe from feature flag '${this.featureFlag}'.`,u)}return!0},d=()=>{if(a!==void 0)clearInterval(a);if(s!==void 0)clearTimeout(s);if(!o())n=!0},f=(l)=>{if(i)return;i=!0,d(),t(l)},p=()=>{if(i)return!0;let l=this.getPostHog();if(!l)return!1;if(a!==void 0)clearInterval(a),a=void 0;try{this.log("PostHog is initialized; subscribing to feature flags.");let u=l.onFeatureFlags(()=>{try{let g=this.getPostHog();if(!g)throw Error("The initialized PostHog instance is no longer available.");let c=g.getFeatureFlag(this.featureFlag),h=this.mapFeatureFlagValue(c);this.log(`PostHog resolved variant '${h}' from raw value ${JSON.stringify(c)}.`),f({value:h,source:"posthog",flagValue:c??null,client:g})}catch(g){this.warn(`Could not read feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,g),f({value:this.defaultVariant,source:"fallback",fallback:"error"})}});if(typeof u==="function")r=u;if(n)n=!1,o();return!0}catch(u){return this.warn(`Could not subscribe to feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,u),f({value:this.defaultVariant,source:"fallback",fallback:"error"}),!0}};if(s=setTimeout(()=>{this.warn(`Feature flag '${this.featureFlag}' was not resolved within ${this.featureFlagTimeoutMs}ms. Applying '${this.defaultVariant}'.`),f({value:this.defaultVariant,source:"fallback",fallback:"timeout"})},this.featureFlagTimeoutMs),!p())a=setInterval(p,50)})}mapFeatureFlagValue(e){if(e===!0)return"test";if(e===!1||e===null||e===void 0||e==="")return this.defaultVariant;return e}getConfiguredVariantOrDefault(e){if(this.variants.has(e))return e;return this.warn(`Variant '${e}' is not configured for '${this.featureFlag}'. Applying '${this.defaultVariant}'.`),this.defaultVariant}describeAssignment(e,t){let i={featureFlag:this.featureFlag,variant:t,source:e.source},r=`'${t}' is shown`;if(e.source==="url")return i.note=t===e.value?"Variant forced by the URL parameter; PostHog was not called.":`URL variant '${e.value}' is not configured, so ${r}.`,i;if(e.source==="fallback")return i.note=e.fallback==="timeout"?`PostHog did not return feature flags within ${this.featureFlagTimeoutMs}ms, so ${r}.`:`The feature flag could not be read, so ${r}.`,i;let n=e.client;i.flagValue=e.flagValue??null;let a=this.readSafely(()=>{let o=n?.featureFlags?.getFeatureFlagDetails?.(this.featureFlag);return o?.reason?.description||o?.reason?.code||void 0});if(a)i.reason=a;if(typeof n?.get_property==="function")i.deviceId=this.readSafely(()=>this.asText(n.get_property?.("$device_id")));if(typeof n?.get_distinct_id==="function")i.distinctId=this.readSafely(()=>this.asText(n.get_distinct_id?.()));let s=i.flagValue;if(s===!1||s===null||s==="")i.note=i.deviceId===null?`PostHog returned no variant, and this browser has no PostHog device ID. Flags that bucket by device cannot assign it, so ${r}.`:`PostHog returned no variant for this visitor, so ${r}.`;else if(t!==e.value)i.note=`Flag value '${e.value}' is not a configured variant, so ${r}.`;return i}readSafely(e){try{return e()}catch{return}}asText(e){return typeof e==="string"&&e?e:null}summarize(e){if(this.logLevel==="silent")return;let t=e.source==="ineligible"?"not applied":`from ${e.source}`;console.info(`[Experiment:${this.featureFlag}] Variant ${e.variant===null?"none":`'${e.variant}'`} (${t}).`,e)}waitForDOMContentLoaded(){if(document.readyState!=="loading")return this.log("DOM is already ready; skipping the DOMContentLoaded wait."),Promise.resolve();return this.log("Waiting for DOMContentLoaded."),new Promise((e)=>{document.addEventListener("DOMContentLoaded",()=>{this.log("DOMContentLoaded received."),e()},{once:!0})})}applyVariant(e){let t=this.variants.get(e);if(!t)return;this.log(`Applying variant '${e}'.`),this.applyHandler(t,e),this.log(`Finished applying variant '${e}'.`)}applyHandler(e,t){if(typeof e==="function"){e();return}e.forEach((i)=>{if(typeof i==="function"){i();return}this.applyUpdates(i.selector,i.updates,t)})}applyUpdates(e,t,i){let r=document.querySelectorAll(e);if(!r.length){this.warn(`No elements found for selector '${e}'.`);return}this.log(`Selector '${e}' matched ${r.length} element(s).`),r.forEach((n)=>{if(t.style)Object.entries(t.style).forEach(([a,s])=>{if(s===null||s===void 0)return;let o=a.startsWith("--")?a:a.replace(/[A-Z]/g,(d)=>`-${d.toLowerCase()}`);n.style.setProperty(o,String(s),"important")});if(t.innerText!==void 0)n.innerText=t.innerText;if(t.innerHTML!==void 0)n.innerHTML=t.innerHTML;t.callback?.(n,i)})}log(e,...t){if(this.logLevel==="debug")console.log(`[Experiment:${this.featureFlag}] ${e}`,...t)}warn(e,...t){if(this.logLevel==="debug")console.warn(`[Experiment:${this.featureFlag}] ${e}`,...t)}}function v(e,t){return new m(e,t).run()}export{v as runExperiment};
@@ -1 +1 @@
1
- (()=>{class m{featureFlag;variants;defaultVariant;isEligible;explicitPostHog;debug;featureFlagTimeoutMs;runPromise=null;constructor(e,t){this.featureFlag=e;if(!e.trim())throw TypeError("featureFlag must be a non-empty string.");if(this.defaultVariant=t.defaultVariant??"control",this.variants=new Map(Object.entries(t.variants)),this.isEligible=t.isEligible??(()=>!0),this.explicitPostHog=t.posthog,this.debug=t.debug??!1,this.featureFlagTimeoutMs=t.featureFlagTimeoutMs??4000,!Number.isFinite(this.featureFlagTimeoutMs)||this.featureFlagTimeoutMs<0)throw RangeError("featureFlagTimeoutMs must be a finite number greater than or equal to 0.");if(!this.defaultVariant.trim())throw TypeError("defaultVariant must be a non-empty string.");if(!this.variants.has(this.defaultVariant))throw TypeError(`variants must include the default variant '${this.defaultVariant}'.`)}run(){if(!this.runPromise)this.runPromise=this.runOnce();return this.runPromise}async runOnce(){if(typeof window>"u"||typeof document>"u")return this.log("Browser globals are unavailable; skipping the experiment."),null;if(await this.waitForDOMContentLoaded(),this.log("Checking eligibility before evaluating the feature flag."),!await this.isEligible())return this.log("Visitor is ineligible; skipping feature-flag evaluation and variant application."),null;let e=await this.resolveVariant(),t=this.getConfiguredVariantOrDefault(e);return this.log(`Activated variant '${t}'.`),this.applyVariant(t),t}getVariantFromQueryParam(){return new URLSearchParams(window.location.search).get(this.featureFlag)}getPostHog(){let e=this.explicitPostHog??window.posthog;if(e&&e.__loaded===!0&&typeof e.onFeatureFlags==="function"&&typeof e.getFeatureFlag==="function")return e;return}resolveVariant(){let e=this.getVariantFromQueryParam();if(e)return this.log(`Using query-string override variant '${e}'.`),Promise.resolve(e);return this.log("Waiting for initialized PostHog and its first feature-flag notification."),new Promise((t)=>{let i=!1,n,a=!1,r,o,u=()=>{if(!n)return!1;let s=n;n=void 0;try{s()}catch(l){this.warn(`Could not unsubscribe from feature flag '${this.featureFlag}'.`,l)}return!0},d=()=>{if(r!==void 0)clearInterval(r);if(o!==void 0)clearTimeout(o);if(!u())a=!0},g=(s)=>{if(i)return;i=!0,d(),t(s)},p=()=>{if(i)return!0;let s=this.getPostHog();if(!s)return!1;if(r!==void 0)clearInterval(r),r=void 0;try{this.log("PostHog is initialized; subscribing to feature flags.");let l=s.onFeatureFlags(()=>{try{let f=this.getPostHog();if(!f)throw Error("The initialized PostHog instance is no longer available.");let h=f.getFeatureFlag(this.featureFlag),c=this.mapFeatureFlagValue(h);this.log(`PostHog resolved variant '${c}' from raw value ${JSON.stringify(h)}.`),g(c)}catch(f){this.warn(`Could not read feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,f),g(this.defaultVariant)}});if(typeof l==="function")n=l;if(a)a=!1,u();return!0}catch(l){return this.warn(`Could not subscribe to feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,l),g(this.defaultVariant),!0}};if(o=setTimeout(()=>{this.warn(`Feature flag '${this.featureFlag}' was not resolved within ${this.featureFlagTimeoutMs}ms. Applying '${this.defaultVariant}'.`),g(this.defaultVariant)},this.featureFlagTimeoutMs),!p())r=setInterval(p,50)})}mapFeatureFlagValue(e){if(e===!0)return"test";if(e===!1||e===null||e===void 0||e==="")return this.defaultVariant;return e}getConfiguredVariantOrDefault(e){if(this.variants.has(e))return e;return this.warn(`Variant '${e}' is not configured for '${this.featureFlag}'. Applying '${this.defaultVariant}'.`),this.defaultVariant}waitForDOMContentLoaded(){if(document.readyState!=="loading")return this.log("DOM is already ready; skipping the DOMContentLoaded wait."),Promise.resolve();return this.log("Waiting for DOMContentLoaded."),new Promise((e)=>{document.addEventListener("DOMContentLoaded",()=>{this.log("DOMContentLoaded received."),e()},{once:!0})})}applyVariant(e){let t=this.variants.get(e);if(!t)return;this.log(`Applying variant '${e}'.`),this.applyHandler(t,e),this.log(`Finished applying variant '${e}'.`)}applyHandler(e,t){if(typeof e==="function"){e();return}e.forEach((i)=>{if(typeof i==="function"){i();return}this.applyUpdates(i.selector,i.updates,t)})}applyUpdates(e,t,i){let n=document.querySelectorAll(e);if(!n.length){this.warn(`No elements found for selector '${e}'.`);return}this.log(`Selector '${e}' matched ${n.length} element(s).`),n.forEach((a)=>{if(t.style)Object.entries(t.style).forEach(([r,o])=>{if(o===null||o===void 0)return;let u=r.startsWith("--")?r:r.replace(/[A-Z]/g,(d)=>`-${d.toLowerCase()}`);a.style.setProperty(u,String(o),"important")});if(t.innerText!==void 0)a.innerText=t.innerText;if(t.innerHTML!==void 0)a.innerHTML=t.innerHTML;t.callback?.(a,i)})}log(e,...t){if(this.debug)console.log(`[Experiment:${this.featureFlag}] ${e}`,...t)}warn(e,...t){if(this.debug)console.warn(`[Experiment:${this.featureFlag}] ${e}`,...t)}}function y(e,t){return new m(e,t).run()}if(typeof window<"u")window.runExperiment=y;})();
1
+ (()=>{class m{featureFlag;variants;defaultVariant;isEligible;explicitPostHog;logLevel;featureFlagTimeoutMs;runPromise=null;constructor(e,t){this.featureFlag=e;if(!e.trim())throw TypeError("featureFlag must be a non-empty string.");if(this.defaultVariant=t.defaultVariant??"control",this.variants=new Map(Object.entries(t.variants)),this.isEligible=t.isEligible??(()=>!0),this.explicitPostHog=t.posthog,this.logLevel=t.log??(t.debug?"debug":"summary"),this.featureFlagTimeoutMs=t.featureFlagTimeoutMs??4000,!["silent","summary","debug"].includes(this.logLevel))throw TypeError("log must be 'silent', 'summary', or 'debug'.");if(!Number.isFinite(this.featureFlagTimeoutMs)||this.featureFlagTimeoutMs<0)throw RangeError("featureFlagTimeoutMs must be a finite number greater than or equal to 0.");if(!this.defaultVariant.trim())throw TypeError("defaultVariant must be a non-empty string.");if(!this.variants.has(this.defaultVariant))throw TypeError(`variants must include the default variant '${this.defaultVariant}'.`)}run(){if(!this.runPromise)this.runPromise=this.runOnce();return this.runPromise}async runOnce(){if(typeof window>"u"||typeof document>"u")return this.log("Browser globals are unavailable; skipping the experiment."),null;if(this.isDebugRequestedByUrl())this.logLevel="debug";if(await this.waitForDOMContentLoaded(),this.log("Checking eligibility before evaluating the feature flag."),!await this.isEligible())return this.log("Visitor is ineligible; skipping feature-flag evaluation and variant application."),this.summarize({featureFlag:this.featureFlag,variant:null,source:"ineligible",note:"The visitor is not eligible, so the feature flag was not evaluated."}),null;let e=await this.resolveVariant(),t=this.getConfiguredVariantOrDefault(e.value);return this.log(`Activated variant '${t}'.`),this.summarize(this.describeAssignment(e,t)),this.applyVariant(t),t}getVariantFromQueryParam(){return new URLSearchParams(window.location.search).get(this.featureFlag)}isDebugRequestedByUrl(){let e=new URLSearchParams(window.location.search).get("experiment-debug");if(e===null)return!1;let t=e.split(",").map((i)=>i.trim()).filter(Boolean);return t.length===0||t.includes("1")||t.includes("true")||t.includes(this.featureFlag)}getPostHog(){let e=this.explicitPostHog??window.posthog;if(e&&e.__loaded===!0&&typeof e.onFeatureFlags==="function"&&typeof e.getFeatureFlag==="function")return e;return}resolveVariant(){let e=this.getVariantFromQueryParam();if(e)return this.log(`Using query-string override variant '${e}'.`),Promise.resolve({value:e,source:"url"});return this.log("Waiting for initialized PostHog and its first feature-flag notification."),new Promise((t)=>{let i=!1,n,r=!1,a,s,o=()=>{if(!n)return!1;let l=n;n=void 0;try{l()}catch(u){this.warn(`Could not unsubscribe from feature flag '${this.featureFlag}'.`,u)}return!0},d=()=>{if(a!==void 0)clearInterval(a);if(s!==void 0)clearTimeout(s);if(!o())r=!0},f=(l)=>{if(i)return;i=!0,d(),t(l)},p=()=>{if(i)return!0;let l=this.getPostHog();if(!l)return!1;if(a!==void 0)clearInterval(a),a=void 0;try{this.log("PostHog is initialized; subscribing to feature flags.");let u=l.onFeatureFlags(()=>{try{let g=this.getPostHog();if(!g)throw Error("The initialized PostHog instance is no longer available.");let c=g.getFeatureFlag(this.featureFlag),h=this.mapFeatureFlagValue(c);this.log(`PostHog resolved variant '${h}' from raw value ${JSON.stringify(c)}.`),f({value:h,source:"posthog",flagValue:c??null,client:g})}catch(g){this.warn(`Could not read feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,g),f({value:this.defaultVariant,source:"fallback",fallback:"error"})}});if(typeof u==="function")n=u;if(r)r=!1,o();return!0}catch(u){return this.warn(`Could not subscribe to feature flag '${this.featureFlag}'. Applying '${this.defaultVariant}'.`,u),f({value:this.defaultVariant,source:"fallback",fallback:"error"}),!0}};if(s=setTimeout(()=>{this.warn(`Feature flag '${this.featureFlag}' was not resolved within ${this.featureFlagTimeoutMs}ms. Applying '${this.defaultVariant}'.`),f({value:this.defaultVariant,source:"fallback",fallback:"timeout"})},this.featureFlagTimeoutMs),!p())a=setInterval(p,50)})}mapFeatureFlagValue(e){if(e===!0)return"test";if(e===!1||e===null||e===void 0||e==="")return this.defaultVariant;return e}getConfiguredVariantOrDefault(e){if(this.variants.has(e))return e;return this.warn(`Variant '${e}' is not configured for '${this.featureFlag}'. Applying '${this.defaultVariant}'.`),this.defaultVariant}describeAssignment(e,t){let i={featureFlag:this.featureFlag,variant:t,source:e.source},n=`'${t}' is shown`;if(e.source==="url")return i.note=t===e.value?"Variant forced by the URL parameter; PostHog was not called.":`URL variant '${e.value}' is not configured, so ${n}.`,i;if(e.source==="fallback")return i.note=e.fallback==="timeout"?`PostHog did not return feature flags within ${this.featureFlagTimeoutMs}ms, so ${n}.`:`The feature flag could not be read, so ${n}.`,i;let r=e.client;i.flagValue=e.flagValue??null;let a=this.readSafely(()=>{let o=r?.featureFlags?.getFeatureFlagDetails?.(this.featureFlag);return o?.reason?.description||o?.reason?.code||void 0});if(a)i.reason=a;if(typeof r?.get_property==="function")i.deviceId=this.readSafely(()=>this.asText(r.get_property?.("$device_id")));if(typeof r?.get_distinct_id==="function")i.distinctId=this.readSafely(()=>this.asText(r.get_distinct_id?.()));let s=i.flagValue;if(s===!1||s===null||s==="")i.note=i.deviceId===null?`PostHog returned no variant, and this browser has no PostHog device ID. Flags that bucket by device cannot assign it, so ${n}.`:`PostHog returned no variant for this visitor, so ${n}.`;else if(t!==e.value)i.note=`Flag value '${e.value}' is not a configured variant, so ${n}.`;return i}readSafely(e){try{return e()}catch{return}}asText(e){return typeof e==="string"&&e?e:null}summarize(e){if(this.logLevel==="silent")return;let t=e.source==="ineligible"?"not applied":`from ${e.source}`;console.info(`[Experiment:${this.featureFlag}] Variant ${e.variant===null?"none":`'${e.variant}'`} (${t}).`,e)}waitForDOMContentLoaded(){if(document.readyState!=="loading")return this.log("DOM is already ready; skipping the DOMContentLoaded wait."),Promise.resolve();return this.log("Waiting for DOMContentLoaded."),new Promise((e)=>{document.addEventListener("DOMContentLoaded",()=>{this.log("DOMContentLoaded received."),e()},{once:!0})})}applyVariant(e){let t=this.variants.get(e);if(!t)return;this.log(`Applying variant '${e}'.`),this.applyHandler(t,e),this.log(`Finished applying variant '${e}'.`)}applyHandler(e,t){if(typeof e==="function"){e();return}e.forEach((i)=>{if(typeof i==="function"){i();return}this.applyUpdates(i.selector,i.updates,t)})}applyUpdates(e,t,i){let n=document.querySelectorAll(e);if(!n.length){this.warn(`No elements found for selector '${e}'.`);return}this.log(`Selector '${e}' matched ${n.length} element(s).`),n.forEach((r)=>{if(t.style)Object.entries(t.style).forEach(([a,s])=>{if(s===null||s===void 0)return;let o=a.startsWith("--")?a:a.replace(/[A-Z]/g,(d)=>`-${d.toLowerCase()}`);r.style.setProperty(o,String(s),"important")});if(t.innerText!==void 0)r.innerText=t.innerText;if(t.innerHTML!==void 0)r.innerHTML=t.innerHTML;t.callback?.(r,i)})}log(e,...t){if(this.logLevel==="debug")console.log(`[Experiment:${this.featureFlag}] ${e}`,...t)}warn(e,...t){if(this.logLevel==="debug")console.warn(`[Experiment:${this.featureFlag}] ${e}`,...t)}}function v(e,t){return new m(e,t).run()}if(typeof window<"u")window.runExperiment=v;})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@99ways/posthog-page-experiments",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Code-first page experiments for PostHog with eligibility checks, URL QA overrides, and safe fallbacks.",
5
5
  "type": "module",
6
6
  "license": "MIT",