@drip-apex/app-sdk
v0.2.0
Published
Apex flags and events for hybrid Ionic, Angular, Capacitor and Cordova apps.
Readme
Apex app SDK
@drip-apex/app-sdk adds server-assigned flags, exposures, goals and purchases to
hybrid apps built with Ionic, Angular, Capacitor or Cordova. It has zero runtime
dependencies and uses WebView APIs: fetch, timers, Web Crypto, localStorage and
visibility events. It does not change the DOM or load the storefront SDK.
npm install @drip-apex/app-sdkFor Cordova, allow the events host in your app's CSP before testing. Without
connect-src https://events.drip-apex.com, every SDK request fails with a network
error. Preserve any other origins your app needs:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' https://events.drip-apex.com">Cordova iOS apps need cordova-ios 8+ to build with current Xcode. Older shells
lack UIScene support and can crash at launch on current iOS; this is independent
of Apex. When reinstalling the same SDK version, clear Angular's .angular/cache
before rebuilding so the app loads the updated package.
Configure once at app initialization. Create a publishable apx_pk_… key in
Apex Settings → Developers. Never put a private API key or shop secret in the
app. Await configure for storage hydration and the first assignment fetch to settle.
initTimeoutMs (default 3,000 ms) bounds the wait for that fetch; initialization
never rejects. Reads before resolution use defaults or cached assignments, with
variation(key).reason reporting default, cached, or stale as appropriate.
import { Apex } from '@drip-apex/app-sdk';
await Apex.configure({
publishableKey: 'apx_pk_live_…',
shopId: 'YOUR_SHOP_UUID',
applicationId: 'ch.coop.supercard',
appVersion: '1.0.0',
screen: () => 'home',
qaMode: false,
});
await Apex.identify('app-user-id'); // Wait for this user's assignments (or initTimeoutMs).
const enabled = Apex.flag('welcome', false); // New user's value after a successful load.
// Use await Apex.identify(null) on logout to restore the device UUID.
await Apex.track('signup_completed');
await Apex.trackPurchase('order-42', 42.5, 'CHF');Attributes passed to identify(userId, attributes) are reserved for a future
contract and are not sent in v1. identify() never rejects and resolves when the
new identity's assignments load or initTimeoutMs elapses. Await it before reading
flags for the new user. Reads before the matching assignments load return the
previous user's values without counting exposures, including reads immediately
after calling identify. logExposure is also deferred during this period; it does
not replay an old assignment's exposure. Read the flag or call logExposure again
after the new assignments load to count them normally. If the wait times out,
previous values remain uncounted until a matching response succeeds. A later
identify supersedes an earlier response. The identified user is persisted per
shop/environment. On a cold launch, the SDK restores that user's cached
assignments immediately, including their exposure and purchase attribution,
before waiting for the network. Without a saved user it restores the anonymous
device cache. identify(null) clears the saved user for the next launch. A new
in-session identify still serves the previous assignments until its fetch succeeds.
Purchases during this pending period keep the existing attribution rule: the base
purchase uses the new current identity, while experiment copies use the previous
served assignment and its identity. After the new assignments load, both use the
new identity.
Angular service
Provide one service at the root, configure through Angular app initialization, and inject that service into components. Change callbacks execute asynchronously; update Angular state through your usual change detection or signals.
import { Injectable, NgZone, inject, provideAppInitializer } from '@angular/core';
import { Apex } from '@drip-apex/app-sdk';
@Injectable({ providedIn: 'root' })
export class ApexService {
private readonly zone = inject(NgZone);
init(): Promise<void> {
return this.zone.runOutsideAngular(() => Apex.configure({
publishableKey: 'apx_pk_live_…', shopId: 'YOUR_SHOP_UUID',
applicationId: 'ch.coop.supercard',
screen: () => 'home', // Replace with your router's route, excluding query/fragment.
}));
}
welcome() { return Apex.flag('welcome', false); }
subscribe(update: () => void) {
return Apex.onChange(change => {
if (change.type === 'changed') this.zone.run(update);
});
}
}
// Add this provider to ApplicationConfig.providers:
export const apexInitializer = provideAppInitializer(() => inject(ApexService).init());
// In a component:
// private readonly experiments = inject(ApexService);
// enabled = this.experiments.welcome();
// private readonly subscription = this.experiments.subscribe(() => {
// this.enabled = this.experiments.welcome();
// });
// ngOnDestroy() { this.subscription.cancel(); }For apps that react to onChange, start configuration in NgZone.runOutsideAngular
so SDK timers and network completions do not trigger unnecessary change detection.
Re-enter with zone.run only to update UI, as the service above does. Older Angular
versions without provideAppInitializer can provide APP_INITIALIZER with
multi: true and a factory that captures const service = inject(ApexService)
synchronously and returns () => service.init().
What your developers code
- Read a flag where the app selects its content or code variant. Exposures are
automatic on compatible flag reads, deduped per experiment, variation and epoch
in a session. A missing epoch still exposes with
assignment_epoch: null. - Call
track("goal_name")once at each goal action. Custom events are unattributed; purchase attribution is handled separately. - Call
trackPurchase(orderId, revenue, currency)at order confirmation with a stable order ID, a finite non-negative number in currency units and a three-letter currency code. The SDK enqueues a base row plus one row for every served assignment. These rows share an event ID and retain their IDs on retry. - Call
identify(userId)on login andidentify(null)on logout. - Set
qaMode: truein test builds. Non-production environments are also QA. Store builds useenvironment: "production"andqaMode: false. - After configure, use
Apex.deviceIdfor an anonymous QA registration or event lookup. After login useApex.userId. Register that identity with the flag's QA devices in Apex to pin a variation. Use the trusted-terminal tracking verify endpoint described in the wire contract to inspect recent events. During identify transitions, also check the previous assignment identity. The app's publishable key cannot read management APIs.
Content and configuration tests can use remote values. Layout and flow tests still require both versions in your app code behind the flag. Apex cannot remove that code from the app for you.
Typed objects and manual exposures
type Welcome = { title: string; button: { label: string; enabled: boolean } };
const fallback: Welcome = { title: 'Welcome', button: { label: 'Start', enabled: false } };
const validate = (v: unknown): v is Welcome => {
if (!v || typeof v !== 'object') return false;
const x = v as Partial<Welcome>;
return typeof x.title === 'string' && typeof x.button?.label === 'string'
&& typeof x.button?.enabled === 'boolean';
};
const content = Apex.flagObject('welcome', fallback, { validate, exposure: 'manual' });
// When the content is visible:
Apex.logExposure('welcome');A validator checks unknown JSON without throwing into app code. Without it,
flagObject checks the outer JSON type only; TypeScript types do not validate
nested fields at runtime. Returned values are copies. Unknown keys remain
present. Assigned object reads expose only after successful validation. Failed or
throwing validators return the default without exposure. This differs from the
native typed getter. Missing assignments do not expose. Primitive reads require
matching boolean/string/number types, finite numbers and safe integers; no string
coercion. Missing or incompatible reads return the default and log once per key
per session, only after an assignment set has loaded from cache or the network.
Reads before that first load use defaults without warnings.
variation(key) exposes by default. Use variation(key, { exposure: 'manual' }) to read the descriptor without recording an exposure, then call logExposure(key) when visible. A configured client returns a default variation
with reason: "default" when unassigned; the unconfigured facade returns null.
Storage and lifecycle
The default adapter uses localStorage. To use Capacitor Preferences, supply an adapter; Capacitor remains an app dependency, not an SDK dependency:
import { Preferences } from '@capacitor/preferences';
import { Apex, type ApexStorage } from '@drip-apex/app-sdk';
const storage: ApexStorage = {
async get(key) { return (await Preferences.get({ key })).value; },
async set(key, value) { await Preferences.set({ key, value }); },
};
await Apex.configure({ publishableKey: 'apx_pk_live_…', shopId: 'YOUR_SHOP_UUID', storage });Adapters must atomically replace a string value or reject without changing it.
The SDK stages queued events in memory and coalesces bursts into the latest queue
snapshot, with at most one storage write in flight and one pending snapshot.
track() and trackPurchase() wait for the write containing their events before
resolving. A failed write restores the previous durable queue and logs the failure;
purchase groups remain atomic. If an identify identity write fails, the SDK restores
the previous in-memory identity, logs once that the identity could not be saved, and stops assignment
fetches and event sends until a later identify successfully saves its identity.
Queue/cache read failures log and use available defaults.
Assignments refresh on configure, identify, explicit refresh(), and foreground
transitions, matching the native SDKs. There is no periodic assignment polling;
60 seconds is the cached assignment freshness window, not a network interval.
Foreground refresh uses the configured jitter and respects retry deadlines.
Startup schedules delivery of a hydrated nonempty queue on the first tick.
Foreground transitions also flush a nonempty queue, respecting event retry deadlines.
The SDK listens to browser visibilitychange, Cordova document pause/resume,
and globalThis.Capacitor?.Plugins?.App?.addListener('appStateChange', ...) when
available. It does not import or require a shell plugin. If your Capacitor version
exposes App only as an imported module, forward its callback explicitly:
import { App } from '@capacitor/app';
App.addListener('appStateChange', ({ isActive }) => {
Apex.setAppState(isActive ? 'foreground' : 'background');
});
// Or call Apex.setAppState('background' / 'foreground') from your own shell hooks.Repeated state signals and rapid overlay transitions coalesce within one second.
Backgrounding first completes any pending queue write, then flushes the persisted
queue with best-effort fetch keepalive if the UTF-8 request body fits 64 KiB; larger batches use normal fetch. Disk remains
authoritative until the server acknowledges a batch and the acknowledgement is
persisted. Thirty continuous minutes in the background rotate the session and
clear exposure/warning dedupe. Lifecycle listeners are removed on shutdown.
Every cold launch creates a new session ID and allows a new exposure for each
served assignment, even when relaunching within thirty minutes. This matches
both native clients: iOS initializes sessionId with UUID().uuidString and
Android with UUID.randomUUID().toString(); neither persists sessions across
launches. The thirty-minute rule applies within a running client.
track, trackPurchase, refresh and flush return promises for their local work,
not server delivery. identify additionally waits for matching assignments or
initTimeoutMs, without rejecting. configure resolves after storage hydration and
the first assignment fetch settles (including failures), or its configurable
3,000 ms fetch wait expires. A late response still updates flags. Use onChange
to observe changed keys or a fatal stop ({ type: "stopped", code }). Subscriptions
have an idempotent cancel().
To use a separate context, construct new ApexClient(options) and await its
ready promise. Call shutdown() when retiring it. Do not run multiple clients
with the same storage and shop/environment namespace simultaneously.
Delivery and options
| Option | Default / limit |
| --- | --- |
| environment | production |
| qaMode | false; other environments also stamp qa_mode: true |
| flushIntervalSeconds | 5; clamped to 0.1–86,400 |
| batchSize | 20; clamped to 1–20 |
| queueCapacity | 2,000; clamped to 1–2,000 |
| queueRetentionHours | 47; clamped to 0–47, below the 48-hour replay window |
| refreshJitterSeconds | [0, 10] |
| initTimeoutMs | 3,000; zero skips assignment waiting on configure and identify |
| platform | Detected ios, android, or web; explicit override takes precedence |
| applicationId / appVersion | unknown / empty string |
| screen | home; an event's string properties.screen takes precedence |
| logger | Optional; callback errors are isolated |
| fetch / baseUrl | WebView fetch / https://events.drip-apex.com |
Queue capacity evicts the oldest rows, dropping a whole older purchase group if
the cut would split it. A new purchase larger than capacity or 200 rows is dropped
whole with a diagnostic. Delivery keeps each purchase group in one request,
expanding the batch target when needed within the 200-event server request limit.
Expired rows are removed at enqueue/startup/flush. Future timestamps survive clock
rollback. Events use app://<lowercase applicationId>/<encoded screen path> with
no query or fragment. Platform detection uses Capacitor getPlatform(), then Cordova
device.platform (case-insensitive), then iPhone/iPad/iPod or Android user-agent
detection, finally web. Explicit platform overrides detection. The production
Worker already accepts all three values; no Worker change is required.
Fatal 401 codes invalid_publishable_key and publishable_key_not_allowed stop
both network lanes while reads retain values. Retryable responses respect
Retry-After seconds or HTTP dates. Missing lookup-unavailable retry headers
use five seconds; ordinary 429 responses use exponential backoff with jitter.
Other network/server failures use exponential 5/10/20/40/60-second
backoff with 0–20% positive jitter, capped at 60 seconds. Unknown 401/403 use
300 seconds; explicitly retryable assignment 4xx use 30 seconds. Other
non-retryable assignment 4xx end that request without automatic retries. Event
non-retryable 4xx drop the
batch, except 401/403/408/429 which are retained. Only HTTP 202 with ok: true
acknowledges events. A failed acknowledgement write retains the rows for replay.
Requests time out after 30 seconds and refuse redirects.
Supply logger: line => ... to receive developer diagnostics. Assignment fetch
and event delivery failures report network errors or HTTP status, plus whether
a retry is scheduled or the request/batch was dropped. Each lane and failure
class (network error or distinct HTTP status) logs at most once per sixty seconds
per client. Fatal auth logs its reason and that the SDK stopped; an obviously
malformed publishable-key prefix also logs at configure. Keys, Authorization
headers, response bodies and exception details are never included in these
diagnostics. Without a logger the SDK emits no logs, including to the console.
track rejects malformed revenue and property keys email, phone, password,
ssn, credit_card, card_number (case/diacritic insensitive). Numeric revenue
strings must match the Worker's strict decimal grammar, including comma decimals.
Purchases strip caller order/revenue/currency aliases before adding explicit
arguments. Properties must be JSON, including finite numbers.
Authentication uses only Authorization: Bearer <publishableKey> on assignment
and event requests. Cookie auth does not work from capacitor://localhost.
The SDK never sends X-Apex-Publishable-Key; that header is not allowed by the
production WebView CORS policy. No cookie credentials are needed.
Importing or configuring the SDK during SSR is safe: it logs once per context, performs no storage/network work, and returns default flags. Configure again in the browser to activate it.
Builds are ESM and CJS targeting ES2019, with declarations compatible with
TypeScript 4.9+ and Angular 15+ webpack/esbuild builders, with no Node APIs in
either runtime bundle. The package tests bundle consumers with esbuild, verify
both declaration formats, and use offline TypeScript 4.9 when installed. If the
4.9 check is skipped, the orchestrator must rerun the package tests with a locally
available TypeScript 4.9 installation in node_modules before claiming that compiler
was verified. Older Angular versions use APP_INITIALIZER as described above.
Publishing is gated on main and npm trusted publishing; the repository
owner must configure the @drip-apex/app-sdk trusted publisher for
publish-app-sdk.yml in the npm-production environment before the first release.
Public failure contracts
All instance and singleton methods contain invalid app input and app callback errors: no synchronous throw and no rejected SDK promise. Invalid input leaves identity, assignments, queue and lifecycle state unchanged. This is validation at the method boundary; a callback failure has its own recovery behavior below.
| Method | Invalid input / failure outcome | Return |
| --- | --- | --- |
| new ApexClient(options), Apex.configure(options) | Invalid options stop the new context before storage, listeners or network. One diagnostic through a callable logger. Reconfigure retires the previous context first. | ready / configure resolves undefined |
| identify(id, attributes?) | Only nonempty string IDs up to 1,024 characters or null (anonymous) are accepted. Attributes, when supplied, must be a JSON record of strings up to 1,024 characters; they remain unused by evaluation. Invalid input logs and does not start a pending identity change. | Resolves undefined |
| flag(key, default, options?) | Invalid key, primitive default or options returns the exact supplied default, without exposure. A missing or incompatible assignment warns once per key after assignments load. | Supplied default |
| flagObject(key, default, options?) | Invalid key, JSON default or options returns the exact supplied default without exposure. Failed or throwing validators return the default, warn once per key, and never expose. Successful decoding isolates the returned value from cached data. | Supplied default |
| variation(key, options?) | Invalid key or options returns null without exposure. A valid missing key retains the existing reason: 'default' descriptor. Unsafe assigned JSON returns null with the typed warning. | null / default descriptor |
| logExposure(key) | Invalid key is ignored without exposure. Pending identify suppresses all exposure entry points. | undefined |
| track(name, properties?) | Nonempty names up to 256 characters; properties omitted/undefined mean {}. Non-record properties (including null, arrays and primitives), unsafe JSON, PII or invalid revenue log and drop before enqueue. | Resolves undefined |
| trackPurchase(id, revenue, currency, properties?) | Nonempty string ID up to 1,024 characters, finite safe nonnegative revenue, three-letter currency after trimming/uppercasing. Properties have the same record contract as track. Invalid input logs and drops the whole purchase before enqueue. | Resolves undefined |
| onChange(handler) | Nonfunctions log and return an inert subscription. A throwing handler logs; every other handler still runs. | { cancel() }; cancel is idempotent |
| setAppState(state) | Only foreground and background are accepted. Invalid input logs and leaves state/timers unchanged. | undefined |
| refresh(), flush() | No arguments required; extra arguments ignored. Transport failure uses the documented lane retry policy; stopped contexts do nothing. | Resolves undefined |
| shutdown() | Extra arguments ignored; stops networking and settles waiters, then waits for outstanding serialized storage work. | Resolves undefined |
| sdkVersion, deviceId, userId | Read-only; no input or side effect. Singleton IDs are undefined before configure; instance IDs are empty before hydration. | String (or singleton undefined) |
JSON input rejects cycles, nonfinite/unsafe integer numbers, functions, custom
prototypes, accessors, and __proto__, constructor, or prototype keys at any
level. Limits are 32 nesting levels, 10,000 visited nodes, 10,000 array slots
(including sparse slots), and 1 MiB of combined
UTF-16 string/key characters. Primitive flag defaults follow these limits too.
Flag keys are nonempty strings up to 1,024 characters. Options are records with
only the documented keys; callbacks must be functions, numeric options finite
safe numbers, jitter a nonnegative ordered pair, and baseUrl an HTTP(S) URL
without embedded credentials. Existing numeric option clamping still applies.
Throwing loggers are isolated. Throwing screen() callbacks, unusable screen
strings, or malformed Unicode screen paths fall back to home. Storage adapter
get/set may throw synchronously or reject: device persistence failure stops
initialization, cache read failure gives defaults, event write failure keeps the
previous durable queue, acknowledgement write failure retains IDs for safe replay,
and identity write failure uses the suspension/recovery contract above. A queue
restored under a smaller capacity is activated only after successful trimming;
if that write fails, the new context stops with disk untouched. Reconfigure
after storage recovery to retry the restore. Atomic
replace-or-reject is required of the adapter; an adapter that hangs forever can
keep a storage operation (and shutdown) pending.
Capacity and retention removals log their drop reasons with each coalesced write.
Purchases larger than queue capacity or 200 rows are rejected whole with a diagnostic. A purchase group
is delivered in one request: the batch target may expand to include its last
whole group, without exceeding the server's 200-event request limit. Restored oversized
legacy groups are dropped whole with a diagnostic. Event IDs intentionally match
across the base and attributed purchase rows. Exactly-once server ingestion uses
(event_id, experiment_id, variation_id) as its delivery key; network retry or
failed local acknowledgement persistence can retransmit the same key.
Long sessions bound warning keys at 256, diagnostic rate-limit keys at 128, listeners at 1,024, identity cache entries at four, and exposure dedupe keys at 4,096 per session. Once the exposure bound is reached, new tuples are dropped with a diagnostic, preserving dedupe for tuples already counted. Session rotation clears exposure and typed-warning dedupe. At most three assignment requests remain outstanding (older requests are aborted on saturation), plus one event request; abort/timeout releases SDK bookkeeping even if a custom fetch ignores its signal. A non-aborted superseded fatal auth reply still stops both lanes.
Deterministic stress verification
npm test includes 2,000 seeds × 200 operations (400,000 operations), input and
assigned-value fuzzing, simulated process death with atomic disk survival, slow /
failed persistence, unordered and abort-ignoring transports, and all lifecycle
sources. Tracking-promise resolution and process death both check that resolved
events remain on disk, delivered, or removed by a logged drop rule. Slow-adapter
burst tests cover 500 and 2,000 events and require at most two writes, including
bursts arriving during an existing write. Failures report the seed and operation.
Replay one seed with
APEX_CHAOS_FIRST_SEED=424 APEX_CHAOS_SEEDS=424 npm test; run a larger soak with
APEX_CHAOS_SEEDS=20000 APEX_CHAOS_OPERATIONS=1000 npm test.
The CI performance guard starts with a saturated 2,000-row durable backlog, then feeds 10,000 additional events through flush and atomic storage writes. It requires all 12,000 rows delivered within a generous 15-second wall-clock budget on the CI runner. This exercises the shipped 2,000-row capacity rather than allowing an impossible 10,000-row simultaneous queue. A separate 5,000-cycle session checks internal map/set, waiter, request and timer bounds.
