abmeter
Version:
ABMeter browser SDK — feature flags and A/B experiments with server-side pre-evaluated assignments
2 lines (1 loc) • 13.5 kB
JavaScript
"use strict";var abmeter=(()=>{var f=Object.defineProperty;var Y=Object.getOwnPropertyDescriptor;var X=Object.getOwnPropertyNames;var z=Object.prototype.hasOwnProperty;var V=(n,e)=>{for(var t in e)f(n,t,{get:e[t],enumerable:!0})},G=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of X(e))!z.call(n,s)&&s!==t&&f(n,s,{get:()=>e[s],enumerable:!(r=Y(e,s))||r.enumerable});return n};var J=n=>G(f({},"__esModule",{value:!0}),n);var ge={};V(ge,{ApiError:()=>u,VERSION:()=>Q,configure:()=>L,flush:()=>F,getExposure:()=>N,ready:()=>$,reset:()=>H,resolveParameter:()=>D,trackEvent:()=>B});var S="https://abmeter.ai";var A=Math.floor(52428.8),w="abmeter:assignments:",l="abmeter:track_id",y="abmeter_track_id",I=365*24*60*60,k="/api/v1/user-assignments",v="/api/v1/exposures",b="/api/v1/events";var p=class{constructor({http:e,user:t,logger:r,storage:s}){this.assignments={};this.http=e,this.user=t,this.logger=r,this.storage=s}async hydrateFromStorage(){let e;try{e=await this.storage.getItem(this.storageKey())}catch{return!1}if(!e)return!1;try{let t=JSON.parse(e);return t?.payload?.assignments?(this.assignments=t.payload.assignments,this.etag=t.etag,!0):!1}catch{return!1}}async refresh(){let e=this.user.email===void 0?{user_id:this.user.userId}:{user_id:this.user.userId,email:this.user.email},t=await this.http.fetchAssignments(e,this.etag);t.status!==304&&t.payload&&(this.assignments=t.payload.assignments,this.etag=t.etag,this.persist({etag:t.etag,payload:t.payload}))}resolveValue(e){return this.assignments[e]?.value}resolveAssignment(e){return this.assignments[e]}has(e){return e in this.assignments}persist(e){let t=r=>this.logger("assignments cache not persisted",r);try{this.storage.setItem(this.storageKey(),JSON.stringify(e)).catch(t)}catch(r){t(r)}}storageKey(){return`${w}${this.user.userId}`}};var P=429,u=class extends Error{constructor(e,t,r){let s=typeof t=="object"&&t!==null?t:{};super(typeof s.error=="string"?s.error:`HTTP ${e}`),this.name="ApiError",this.status=e,typeof s.code=="string"&&(this.code=s.code),this.details=s.details,e===P&&(this.retryAfterSeconds=j(r))}get retryable(){return this.status>=500||this.status===408||this.rateLimited}get rateLimited(){return this.status===P}get partialFailure(){return this.status===400&&Array.isArray(this.details?.failures)&&this.details.failures.length>0}get failureCount(){return this.details?.invalid_count??0}};function j(n){let e=n?.trim()??"";return/^\d+$/.test(e)?Math.min(Math.max(Number(e),1),600):30}var d=class{constructor(e){this.exposures=[];this.events=[];this.retryQueue=[];this.pausedUntil=0;this.timer=null;this.flushChain=Promise.resolve();this.queuedFlush=null;this.unsubscribeBackground=null;this.http=e.http,this.flushIntervalMs=e.flushIntervalMs,this.batchSize=e.batchSize??20,this.logger=e.logger,this.errorCallback=e.errorCallback,this.lifecycle=e.lifecycle}start(){this.timer===null&&(this.timer=setInterval(()=>{this.flush()},this.flushIntervalMs)),this.unsubscribeBackground??(this.unsubscribeBackground=this.lifecycle.onBackground(()=>this.drainOnHide()))}stop(){this.timer!==null&&(clearInterval(this.timer),this.timer=null),this.unsubscribeBackground?.(),this.unsubscribeBackground=null}queueExposure(e){this.exposures.push({kind:"exposure",data:e,attempts:0}),this.flushIfFull()}queueEvent(e){this.events.push({kind:"event",data:e,attempts:0}),this.flushIfFull()}pending(){return this.exposures.length+this.events.length+this.retryQueue.length}flush(){if(this.queuedFlush)return this.queuedFlush;let e=this.flushChain.then(()=>(this.queuedFlush=null,this.drain()));return this.queuedFlush=e,this.flushChain=e.catch(()=>{}),e}async drain(){if(!this.paused())for(await this.retryOneBatch();(this.exposures.length>0||this.events.length>0)&&!this.paused();)this.exposures.length>0&&await this.submitBatch("exposure",this.exposures.splice(0,this.batchSize)),this.events.length>0&&!this.paused()&&await this.submitBatch("event",this.events.splice(0,this.batchSize))}paused(){return Date.now()<this.pausedUntil}async reset(e={}){if(this.stop(),e.force){this.dropAll("reset(force)");return}let t=0;for(;this.pending()>0;){let r=this.pausedUntil-Date.now();if(r>0){if(t+r>5e3){this.dropAll("reset: rate-limit pause outlasts the drain budget");return}t+=r,await new Promise(s=>setTimeout(s,r))}await this.flush()}}dropAll(e){let t=this.pending();this.exposures=[],this.events=[],this.retryQueue=[],t>0&&this.logger(`${e}: dropped ${t} queued items`)}drainOnHide(){let e=[...this.retryQueue,...this.exposures,...this.events];this.retryQueue=[],this.exposures=[],this.events=[];for(let t of["exposure","event"]){let r=e.filter(s=>s.kind===t).map(s=>s.data);for(let s of re(r,this.batchSize))this.sendChunkOnHide(t,s)}}sendChunkOnHide(e,t){let r=()=>{(e==="exposure"?this.http.beaconExposures(t):this.http.beaconEvents(t))||this.logger(`beacon refused; dropping ${t.length} ${e}s`)};try{(e==="exposure"?this.http.postExposures(t,{keepalive:!0}):this.http.postEvents(t,{keepalive:!0})).catch(r)}catch{r()}}flushIfFull(){this.exposures.length+this.events.length>=this.batchSize&&this.flush()}async retryOneBatch(){if(this.retryQueue.length===0)return;let e=this.retryQueue.splice(0,this.batchSize),t=new Map;for(let r of e){let s=t.get(r.kind)??[];s.push(r),t.set(r.kind,s)}for(let[r,s]of t)this.paused()?this.enqueueRetry(s):await this.submitBatch(r,s)}async submitBatch(e,t){let r=t.map(s=>s.data);try{e==="exposure"?await this.http.postExposures(r):await this.http.postEvents(r)}catch(s){this.handleSubmitError(e,t,s)}}handleSubmitError(e,t,r){if(r instanceof u)if(r.rateLimited){let s=r.retryAfterSeconds??30;this.pausedUntil=Math.max(this.pausedUntil,Date.now()+s*1e3),this.logger(`rate limited, pausing submits for ${s}s; re-queueing ${t.length} ${e}s`),this.enqueueRetry(t)}else r.retryable?(this.logger(`retryable API error for ${t.length} ${e}s`,r.message),this.requeue(t)):r.partialFailure?this.logger(`partial failure, dropping batch of ${t.length} ${e}s`,r.message):this.logger(`permanent API error, dropping ${t.length} ${e}s`,r.message);else e==="exposure"?(this.logger(`network error, re-queueing ${t.length} exposures`,r),this.requeue(t)):this.logger(`network error, dropping ${t.length} events`,r);this.errorCallback?.(r)}requeue(e){let t=[];for(let r of e)r.attempts+=1,r.attempts>=3?this.logger(`max retries exceeded, dropping ${r.kind}`):t.push(r);this.enqueueRetry(t)}enqueueRetry(e){let t=Math.max(0,1e3-this.retryQueue.length);this.retryQueue.push(...e.slice(0,t));let r=e.length-t;r>0&&this.logger(`retry queue full, dropping ${r} items`)}};function re(n,e){let t=[],r=[],s=2;for(let i of n){let a=JSON.stringify(i).length+1;(s+a>A||r.length>=e)&&r.length>0&&(t.push(r),r=[],s=2),r.push(i),s+=a}return r.length>0&&t.push(r),t}var g=class{constructor({windowMs:e=6e5,maxKeys:t=1e3,now:r=Date.now}={}){this.entries=new Map;this.windowMs=e,this.maxKeys=t,this.now=r}seenRecently(e){let t=this.now(),r=this.entries.get(e),s=r!==void 0&&t-r<this.windowMs;return this.entries.delete(e),this.entries.set(e,s?r:t),this.evict(),s}evict(){for(;this.entries.size>this.maxKeys;){let e=this.entries.keys().next().value;if(e===void 0)return;this.entries.delete(e)}}};function C(n){return`${n.user_id}|${n.exposable_id}|${n.audience_id}|${JSON.stringify(n.resolved_value)}`}var h=class{constructor({baseUrl:e,apiKey:t}){this.baseUrl=e,this.apiKey=t}async fetchAssignments(e,t){let r={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};t&&(r["If-None-Match"]=t);let s=await fetch(`${this.baseUrl}${k}`,{method:"POST",headers:r,body:JSON.stringify(e)});if(s.status===304)return{status:304};if(!s.ok)throw new u(s.status,await T(s),s.headers.get("Retry-After"));return{status:200,payload:await s.json(),etag:s.headers.get("ETag")??void 0}}async postExposures(e,t){await this.post(v,e,t)}async postEvents(e,t){await this.post(b,e,t)}beaconExposures(e){return this.beacon(v,e)}beaconEvents(e){return this.beacon(b,e)}async post(e,t,r){let s=r?.keepalive??!1,i=await fetch(`${this.baseUrl}${e}`,{method:"POST",headers:{Authorization:`Bearer ${this.apiKey}`,"Content-Type":s?"text/plain":"application/json"},body:JSON.stringify(t),keepalive:s});if(!i.ok)throw new u(i.status,await T(i),i.headers.get("Retry-After"))}beacon(e,t){let r=globalThis.navigator?.sendBeacon?.bind(globalThis.navigator);if(!r)return!1;let s=`${this.baseUrl}${e}?api_key=${encodeURIComponent(this.apiKey)}`,i=new Blob([JSON.stringify(t)],{type:"text/plain"});try{return r(s,i)}catch{return!1}}};async function T(n){try{return await n.json()}catch{return}}async function U(n,e){let t=n?.userId??await ne(e);return n?.email===void 0?{userId:t}:{userId:t,email:n.email}}async function ne(n){let e=null;try{e=await n.getItem(l)}catch{e=null}let t=e??se();try{await n.setItem(l,t)}catch{}return t}function se(){let n=globalThis.crypto;if(n?.randomUUID)return n.randomUUID();let e=new Uint8Array(16);if(n?.getRandomValues)n.getRandomValues(e);else for(let r=0;r<e.length;r+=1)e[r]=Math.floor(Math.random()*256);e[6]=e[6]&15|64,e[8]=e[8]&63|128;let t=Array.from(e,r=>r.toString(16).padStart(2,"0")).join("");return`${t.slice(0,8)}-${t.slice(8,12)}-${t.slice(12,16)}-${t.slice(16,20)}-${t.slice(20)}`}function m(n,e,t,r){try{return r()}catch(s){return M(n,s,t),e}}async function E(n,e,t,r){try{return await r()}catch(s){return M(n,s,t),e}}function M(n,e,t){t.logger?t.logger(`${n} failed`,e):console.error(`[abmeter] ${n} failed`,e);try{t.errorCallback?.(e)}catch{}}function K(){return{name:"browser",storage:ie(),lifecycle:oe()}}function ie(){let n=ae();return{async getItem(e){if(e===l){let t=ue(y);if(t)return t}try{return n?.getItem(e)??null}catch{return null}},async setItem(e,t){e===l&&le(y,t),n?.setItem(e,t)},async removeItem(e){try{n?.removeItem(e)}catch{}}}}function oe(){return{onBackground(n){let e=()=>{globalThis.document?.visibilityState==="hidden"&&n()},t=()=>n();return globalThis.document?.addEventListener("visibilitychange",e),globalThis.window?.addEventListener("pagehide",t),()=>{globalThis.document?.removeEventListener("visibilitychange",e),globalThis.window?.removeEventListener("pagehide",t)}}}}function ae(){try{let n=globalThis.localStorage,e="abmeter:probe";return n.setItem(e,"1"),n.removeItem(e),n}catch{return null}}function ue(n){if(typeof document>"u")return null;let e=document.cookie.split(";").map(t=>t.trim()).find(t=>t.startsWith(`${n}=`));return e&&decodeURIComponent(e.slice(n.length+1))||null}function le(n,e){if(!(typeof document>"u"))try{document.cookie=`${n}=${encodeURIComponent(e)}; path=/; max-age=${I}; SameSite=Lax`}catch{}}var pe="pk_";function O(n){if(!n||typeof n.apiKey!="string"||n.apiKey.length===0)throw new Error("abmeter.configure: apiKey is required");if(!n.apiKey.startsWith(pe))throw new Error("abmeter.configure: apiKey must be a publishable key (pk_...) \u2014 mint one on the Lab API Keys page. Secret keys (api-...) must never be shipped in browser code: anyone can read them from your page source and gain full access to your ABMeter account.");return{apiKey:n.apiKey,baseUrl:(n.baseUrl??S).replace(/\/+$/,""),flushIntervalMs:n.flushInterval??1e3,logger:n.logger??de,errorCallback:n.errorCallback,platform:n.platform??K()}}function de(n,e){e===void 0?console.warn(`[abmeter] ${n}`):console.warn(`[abmeter] ${n}`,e)}var o=null;function L(n){let e=O(n),t=new h({baseUrl:e.baseUrl,apiKey:e.apiKey}),r=new d({http:t,flushIntervalMs:e.flushIntervalMs,logger:e.logger,errorCallback:e.errorCallback,lifecycle:e.platform.lifecycle});if(o){let i=o.submitter,a=i.pending();a>0&&o.config.logger(`configure: draining ${a} queued items from the previous configuration`),i.reset().catch(()=>{})}r.start();let s={config:e,user:null,cache:null,dedup:new g,submitter:r,pendingEvents:[],initPromise:Promise.resolve()};s.initPromise=(async()=>{let i=await U(n.user,e.platform.storage);s.user=i;let a=new p({http:t,user:i,logger:e.logger,storage:e.platform.storage});s.cache=a;for(let x of s.pendingEvents.splice(0))r.queueEvent({...x,user_id:i.userId});await a.hydrateFromStorage(),await a.refresh()})().catch(i=>{e.logger("assignments refresh failed",i);try{e.errorCallback?.(i)}catch{}}),o=s}function $(){return o?.initPromise??Promise.resolve()}function D(n){return m("resolveParameter",void 0,c(),()=>{let e=_(),t=e.cache?.resolveAssignment(n);if(!t){e.config.logger(`resolveParameter: unknown parameter '${n}'`);return}let r=q(e,n);return r&&!e.dedup.seenRecently(C(r))&&e.submitter.queueExposure({...r}),t.value})}function N(n){return m("getExposure",null,c(),()=>q(_(),n))}function B(n,e){m("trackEvent",void 0,c(),()=>{let t=_(),r={event_slug:n,occurred_at:new Date().toISOString(),custom_fields:e??{}};t.user?t.submitter.queueEvent({...r,user_id:t.user.userId}):t.pendingEvents.push(r)})}function F(){return E("flush",void 0,c(),async()=>{await o?.submitter.flush()})}function H(n={}){return E("reset",void 0,c(),async()=>{let e=o;o=null,await e?.submitter.reset(n)})}function q(n,e){if(!n.user)return null;let t=n.cache?.resolveAssignment(e);return t?.exposure?{parameter_id:t.parameter_id,space_id:t.space_id,resolved_value:t.value,user_id:n.user.userId,exposable_type:t.exposure.exposable_type,exposable_id:t.exposure.exposable_id,audience_id:t.exposure.audience_id,resolved_at:new Date().toISOString()}:null}function _(){if(!o)throw new Error("abmeter is not configured \u2014 call abmeter.configure(...) first");return o}function c(){return o?{logger:o.config.logger,errorCallback:o.config.errorCallback}:{}}var Q="0.4.0";return J(ge);})();