@apex-inc/sdk 0.1.0 → 0.5.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/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # @apex-inc/sdk
2
2
 
3
- Lightweight SDK for sending events and user data to Apex Growth Intelligence. Works in **Node.js** (18+) and **browser** environments. Zero runtime dependencies.
3
+ Event tracking and identity SDK for Apex — currently in **private beta**.
4
+
5
+ Works in Node.js 18+ and browser environments. Zero runtime dependencies.
6
+
7
+ ## Access
8
+
9
+ Apex is invite-only during the private-beta period. If you received an invite, your onboarding email contains your `projectKey` and `apiUrl`. Full API documentation is available in-app once your account is active.
10
+
11
+ Not in the beta yet? Request access at [apex.inc](https://apex.inc) (coming soon).
4
12
 
5
13
  ## Install
6
14
 
@@ -13,152 +21,82 @@ npm install @apex-inc/sdk
13
21
  ```typescript
14
22
  import { init, track, identify } from "@apex-inc/sdk";
15
23
 
16
- // Initialize (once, at app startup)
17
24
  init({
18
25
  projectKey: "your-project-key",
19
26
  apiUrl: "https://your-apex-instance.com",
20
27
  });
21
28
 
22
- // Identify a user
23
- identify("user_123", {
24
- email: "jane@example.com",
25
- plan: "starter",
26
- });
27
-
28
- // Track events
29
+ identify("user_123", { email: "jane@example.com" });
29
30
  track("page_viewed", { path: "/pricing" });
30
- track("button_clicked", { label: "Start Trial", section: "hero" });
31
- track("form_submitted", { formId: "signup", fields: 3 });
32
31
  ```
33
32
 
34
- ## API
35
-
36
- ### `init(config)`
33
+ ## PLG primitives
37
34
 
38
- | Option | Type | Default | Description |
39
- |-----------------|----------|--------------------------|-----------------------------------|
40
- | `projectKey` | string | **required** | Your Apex project key |
41
- | `apiUrl` | string | `window.location.origin` | Apex API URL |
42
- | `flushInterval` | number | `5000` | Auto-flush interval (ms) |
43
- | `flushAt` | number | `20` | Max queued events before flush |
44
- | `debug` | boolean | `false` | Log SDK activity to console |
45
-
46
- ### `track(event, properties?)`
47
-
48
- Send a named event with optional properties.
35
+ In addition to `track` / `identify` the SDK exposes three PLG-specific
36
+ primitives. Use them when you want Apex to derive activation,
37
+ expansion, and retention rollups automatically:
49
38
 
50
39
  ```typescript
51
- track("page_viewed", { path: "/pricing", referrer: "google" });
52
- ```
53
-
54
- ### `identify(userId, traits?)`
40
+ import { init, identify, group, feature, track } from "@apex-inc/sdk";
41
+ import { EVENTS, type ActivatedEvent } from "@apex-inc/sdk/plg-events";
55
42
 
56
- Associate subsequent events with a user. Also performs **identity stitching** — linking an anonymous visitor to a known email address.
57
-
58
- ```typescript
59
- identify("jane@example.com", {
60
- name: "Jane Smith",
61
- plan: "growth",
62
- company: "Acme Inc",
63
- });
64
- ```
43
+ init({ projectKey: "your-project-key", apiUrl: "https://app.apex.inc" });
65
44
 
66
- #### Identity Stitching
45
+ // Identify the authenticated user.
46
+ identify("chris@acme.com", { visitorId: "apex_vid_from_cookie" });
67
47
 
68
- When the `userId` contains `@` (or `traits.email` is set), the SDK automatically calls the `/api/identity/stitch` endpoint to link the email to a visitor profile. This connects anonymous browsing behavior (from the Apex snippet) to a known user.
48
+ // Group them into their account / team (B2B).
49
+ group("acct_acme", { plan: "growth", arr: 120_000 });
69
50
 
70
- For **server-side** identify calls (e.g., from a webhook handler), pass the browser's `apex_vid` cookie as `traits.visitorId` to ensure the stitch connects to the real browser session:
51
+ // Track feature usage. Include experiment variant if you have one.
52
+ feature("cohort-builder", { variant: "new-editor", enabled: true });
71
53
 
72
- ```typescript
73
- identify("jane@example.com", {
74
- visitorId: req.cookies.apex_vid, // From the browser cookie
75
- plan: "growth",
76
- });
54
+ // Emit curated PLG lifecycle events with compile-time-typed payloads.
55
+ track(EVENTS.ACTIVATED, {
56
+ daysToActivation: 3,
57
+ milestone: "first-experiment-shipped",
58
+ } satisfies ActivatedEvent);
77
59
  ```
78
60
 
79
- Without `visitorId`, a synthetic visitor ID is created that won't connect to marketing touchpoints.
80
-
81
- ### `trackForm(formData)`
82
-
83
- Track a form submission and identify the user in one call. Combines `track("form_submit", ...)` with `identify(email, ...)`.
84
-
85
- ```typescript
86
- trackForm({
87
- email: "jane@example.com",
88
- formId: "signup-form",
89
- action: "/api/register",
90
- fields: { name: "Jane", company: "Acme" },
91
- });
61
+ The PLG catalog ships eight curated events: `signup`, `activated`,
62
+ `feature_first_use`, `aha_moment`, `expansion`, `contraction`,
63
+ `reactivation`, `churn`. Each has a typed interface so your IDE
64
+ catches missing fields at build time. See the [PLG event catalog
65
+ docs](https://app.apex.inc/docs/plg-event-catalog) for the full
66
+ schema.
67
+
68
+ ## Server Events API (server-to-server events)
69
+
70
+ For backend workflows that don't have a browser session — Stripe webhooks,
71
+ CRM events, scheduled jobs — use `sendServerEvent` / `sendServerEvents`.
72
+ These hit the `/api/v1/events` endpoint with API-key auth and
73
+ idempotency-key support.
74
+
75
+ ```ts
76
+ import { sendServerEvent, newIdempotencyKey } from "@apex-inc/sdk";
77
+
78
+ await sendServerEvent(
79
+ { apiKey: process.env.APEX_API_KEY! }, // apex_sk_…
80
+ {
81
+ type: "purchase_completed",
82
+ email: customer.email,
83
+ data: { value: invoice.amount_paid / 100, currency: "USD", order_id: invoice.id },
84
+ },
85
+ { idempotencyKey: stripeEvent.id }, // or newIdempotencyKey()
86
+ );
92
87
  ```
93
88
 
94
- | Option | Type | Required | Description |
95
- |----------|--------|----------|----------------------------------|
96
- | `email` | string | Yes | User's email address |
97
- | `formId` | string | No | Form identifier |
98
- | `action` | string | No | Form action URL |
99
- | `fields` | object | No | Additional form field data |
100
-
101
- ### `flush()`
102
-
103
- Immediately send all queued events.
104
-
105
- ### `shutdown()`
106
-
107
- Flush remaining events and stop the background timer.
108
-
109
- ## Node.js Usage
110
-
111
- The SDK works in Node.js 18+ (which includes global `fetch`). For older Node.js versions, install `node-fetch`:
112
-
113
- ```bash
114
- npm install node-fetch
115
- ```
116
-
117
- ```typescript
118
- import { init, track, identify, shutdown } from "@apex-inc/sdk";
119
-
120
- init({
121
- projectKey: "your-project-key",
122
- apiUrl: "https://your-apex-instance.com",
123
- });
124
-
125
- // In your webhook handler or API route:
126
- identify("new-signup@example.com", { source: "stripe_webhook" });
127
- track("subscription_created", { plan: "growth", mrr: 99 });
128
-
129
- // On process exit:
130
- shutdown();
131
- ```
132
-
133
- ## Management API (API key)
134
-
135
- For **server-side** access to experiments (same auth model as the Apex MCP server), use `createManagementClient` with your **`APEX_API_KEY`** and project key. The client sends **`x-api-key`** and **`x-apex-project`** so routes resolve the tenant without a browser session.
136
-
137
- ```typescript
138
- import { createManagementClient, init, track } from "@apex-inc/sdk";
139
-
140
- const apex = createManagementClient({
141
- projectKey: "your-project-key",
142
- apiKey: process.env.APEX_API_KEY!,
143
- apiUrl: "https://your-apex-instance.com",
144
- });
145
-
146
- const experiments = await apex.listExperiments();
147
- const { variant } = await apex.assignVariant("experiment-id-123", "stable-visitor-id-per-user");
148
-
149
- init({ projectKey: "your-project-key", apiUrl: "https://your-apex-instance.com" });
150
- track("notification_sent", { experimentId: "experiment-id-123", variant });
151
- ```
152
-
153
- `assignVariant` uses a stable **visitor id** per end user (e.g. hashed internal user id) so variant assignment stays deterministic. Pass the same id on every call for that user and experiment.
154
-
155
- ## Shipped skills (AI assistants)
156
-
157
- Cursor-style skills live in **`skills/apex-growth-tracking/SKILL.md`**. Copy that folder into `.cursor/skills/` so your AI assistant gets Apex tracking conventions in context.
89
+ Batches of up to 100 events go through `sendServerEvents`. Use the same
90
+ visitorId / email you'd use in the browser snippet so cross-channel
91
+ stitching just works.
158
92
 
159
- ## How It Works
93
+ ## Multi-workspace orgs
160
94
 
161
- Events are queued in memory and flushed in batches to `POST /api/events`. The SDK auto-flushes on a timer, when the queue hits `flushAt`, and on `beforeunload` / `visibilitychange` in browsers.
95
+ If your organization runs more than one workspace (e.g. a SaaS product
96
+ plus a partner marketplace), each workspace has its own `projectKey`.
97
+ Initialize one SDK instance per workspace; event identity stitches at
98
+ the org level via `OrgPerson` so a single human tracked in both shows
99
+ up as one person in cross-workspace cohorts and reports.
162
100
 
163
101
  ## License
164
102
 
package/dist/esm/index.js CHANGED
@@ -17,6 +17,11 @@ async function sendBatch(events) {
17
17
  projectKey: config.projectKey,
18
18
  userId,
19
19
  events,
20
+ // MMP-070 — propagate the SDK-level environment override (when
21
+ // set by `apex.init({ environment })`). The server prefers this
22
+ // over auto-detection from the Referer/Origin host. Mobile SDKs
23
+ // don't send this field; they set `mobile.releaseChannel` instead.
24
+ ...(config.environment ? { webEnvironment: config.environment } : {}),
20
25
  });
21
26
  try {
22
27
  const fetchFn = typeof fetch !== "undefined" ? fetch : await loadNodeFetch();
@@ -89,6 +94,9 @@ export function track(event, properties) {
89
94
  * Identify a user with traits (email, name, plan, etc.).
90
95
  * Also performs identity stitching if the userId looks like an email.
91
96
  *
97
+ * Prefer canonical snake_case trait names (see `CanonicalUserTraits`) so
98
+ * templates resolve them cleanly.
99
+ *
92
100
  * Pass `traits.visitorId` (the browser's `apex_vid` cookie value) to stitch
93
101
  * server-side identify calls to the real browser session. Without it, the SDK
94
102
  * creates a synthetic visitor ID that is disconnected from marketing touchpoints.
@@ -111,10 +119,16 @@ export function identify(userId_, traits) {
111
119
  const email = userId_.includes("@") ? userId_ : traits?.email;
112
120
  if (email) {
113
121
  const browserVisitorId = traits?.visitorId;
114
- stitchIdentity(email, browserVisitorId, traits);
122
+ // Forward the caller's stable user id as `userId` whenever it is NOT
123
+ // itself an email — this becomes the highest-precedence
124
+ // `internal_user_id` identifier server-side, which is what makes
125
+ // anonymous→known reconciliation deterministic. Always identify with
126
+ // your own user id (e.g. Cognito sub / DB id), not just an email.
127
+ const stableUserId = userId_.includes("@") ? undefined : userId_;
128
+ stitchIdentity(email, browserVisitorId, traits, stableUserId);
115
129
  }
116
130
  }
117
- async function stitchIdentity(email, browserVisitorId, metadata) {
131
+ async function stitchIdentity(email, browserVisitorId, metadata, stableUserId) {
118
132
  if (!config)
119
133
  return;
120
134
  const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
@@ -129,6 +143,7 @@ async function stitchIdentity(email, browserVisitorId, metadata) {
129
143
  visitorId: effectiveVisitorId,
130
144
  email,
131
145
  projectKey: config.projectKey,
146
+ ...(stableUserId ? { userId: stableUserId } : {}),
132
147
  metadata: metadata ?? {},
133
148
  }),
134
149
  });
@@ -138,6 +153,56 @@ async function stitchIdentity(email, browserVisitorId, metadata) {
138
153
  log("Identity stitch failed:", err);
139
154
  }
140
155
  }
156
+ /**
157
+ * MWO-009 — Group a user into an account / company / team so B2B
158
+ * PQA rollups can be derived. Calling `group("acme-inc")` stamps
159
+ * every subsequent `track`/`feature` event with `groupId`.
160
+ */
161
+ let groupId = null;
162
+ export function group(groupId_, traits) {
163
+ if (!config) {
164
+ console.warn("[apex-sdk] Call init() before group()");
165
+ return;
166
+ }
167
+ groupId = groupId_;
168
+ log("Group:", groupId_, traits);
169
+ enqueue({
170
+ type: "group",
171
+ payload: { groupId: groupId_, traits },
172
+ timestamp: new Date().toISOString(),
173
+ });
174
+ }
175
+ /**
176
+ * MWO-009 — Feature usage. Fire from your app whenever a user
177
+ * touches a named feature. Variant + enabled support wiring to
178
+ * Apex experiment payloads — if Phase 8's experiment engine served
179
+ * a variant, pass it through here to close the loop between
180
+ * assignment and adoption.
181
+ */
182
+ export function feature(featureKey, opts = {}) {
183
+ if (!config) {
184
+ console.warn("[apex-sdk] Call init() before feature()");
185
+ return;
186
+ }
187
+ log("Feature:", featureKey, opts);
188
+ enqueue({
189
+ type: "feature",
190
+ payload: {
191
+ featureKey,
192
+ enabled: opts.enabled,
193
+ variant: opts.variant,
194
+ properties: opts.properties,
195
+ },
196
+ timestamp: new Date().toISOString(),
197
+ });
198
+ }
199
+ /**
200
+ * Returns the most recently set groupId (if any). Callers can use
201
+ * this to decorate their own payloads or assert in tests.
202
+ */
203
+ export function currentGroupId() {
204
+ return groupId;
205
+ }
141
206
  /**
142
207
  * Track a form submission event with the email for identity stitching.
143
208
  */
@@ -165,6 +230,34 @@ export function flush() {
165
230
  const batch = queue.splice(0, queue.length);
166
231
  sendBatch(batch);
167
232
  }
233
+ /**
234
+ * Helpers grouped under `trackCart.*` so call sites read naturally
235
+ * (`apex.trackCart.add({...})`). The implementation just delegates to
236
+ * `track()` with the canonical event names.
237
+ */
238
+ export const trackCart = {
239
+ /** Fired when a user adds an item to their cart. */
240
+ add(line) {
241
+ track("add_to_cart", { ...line });
242
+ },
243
+ /** Fired when a user removes an item from their cart. */
244
+ remove(opts) {
245
+ track("remove_from_cart", { ...opts });
246
+ },
247
+ /**
248
+ * Full-cart reconciliation snapshot. Use whenever the canonical
249
+ * cart state is loaded (login restore, app foreground, cart screen
250
+ * mount). The server-side rollup replaces all stored lines wholesale
251
+ * — this is the "source of truth" event when deltas drift.
252
+ */
253
+ snapshot(opts) {
254
+ track("cart_snapshot", { ...opts });
255
+ },
256
+ /** Fired on checkout success; clears the rolled-up cart. */
257
+ checkout(opts) {
258
+ track("checkout_completed", { ...opts });
259
+ },
260
+ };
168
261
  /**
169
262
  * Shut down the SDK — flush remaining events and stop the timer.
170
263
  */
@@ -178,3 +271,73 @@ export function shutdown() {
178
271
  log("Shut down");
179
272
  }
180
273
  export { createManagementClient, } from "./management";
274
+ /**
275
+ * Generate a stable idempotency key. Prefers `crypto.randomUUID()`
276
+ * when available (Node 19+, modern browsers). Falls back to a
277
+ * timestamp + random suffix that's good enough to avoid collisions
278
+ * within a single process.
279
+ */
280
+ export function newIdempotencyKey() {
281
+ const c = globalThis.crypto;
282
+ if (c?.randomUUID)
283
+ return c.randomUUID();
284
+ return `idk_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
285
+ }
286
+ /**
287
+ * Send a single event server-to-server. Useful for Stripe webhooks,
288
+ * CRM-driven conversions, and any backend signal that should land in
289
+ * Apex without going through the browser snippet.
290
+ *
291
+ * @example
292
+ * await sendServerEvent(
293
+ * { apiKey: process.env.APEX_API_KEY! },
294
+ * {
295
+ * type: "purchase_completed",
296
+ * email: "user@example.com",
297
+ * data: { value: 99, currency: "USD", order_id: "order_42" },
298
+ * },
299
+ * { idempotencyKey: stripeEvent.id }
300
+ * );
301
+ */
302
+ export async function sendServerEvent(config, event, options = {}) {
303
+ return sendServerEvents(config, [event], options);
304
+ }
305
+ /**
306
+ * Send up to 100 events server-to-server in one request. Larger
307
+ * batches must be sharded by the caller (the API returns 400
308
+ * `batch_too_large` otherwise).
309
+ */
310
+ export async function sendServerEvents(config, events, options = {}) {
311
+ if (!config.apiKey) {
312
+ throw new Error("apex-sdk: sendServerEvent requires apiKey");
313
+ }
314
+ if (events.length === 0) {
315
+ throw new Error("apex-sdk: sendServerEvent requires at least one event");
316
+ }
317
+ if (events.length > 100) {
318
+ throw new Error("apex-sdk: sendServerEvents batch_too_large — max 100 events; shard yourself");
319
+ }
320
+ const apiUrl = (config.apiUrl ?? "https://app.apex.inc").replace(/\/$/, "");
321
+ const url = `${apiUrl}/api/v1/events`;
322
+ const idempotencyKey = options.idempotencyKey ?? newIdempotencyKey();
323
+ const headers = {
324
+ "content-type": "application/json",
325
+ "x-api-key": config.apiKey,
326
+ "idempotency-key": idempotencyKey,
327
+ };
328
+ if (config.projectKey)
329
+ headers["x-apex-project"] = config.projectKey;
330
+ const fetchFn = typeof fetch !== "undefined" ? fetch : await loadNodeFetch();
331
+ const res = await fetchFn(url, {
332
+ method: "POST",
333
+ headers,
334
+ body: JSON.stringify({ events }),
335
+ });
336
+ const json = (await res.json().catch(() => ({})));
337
+ if (!res.ok) {
338
+ const code = json.error?.code ?? `http_${res.status}`;
339
+ const message = json.error?.message ?? `HTTP ${res.status}`;
340
+ throw new Error(`apex-sdk: sendServerEvent failed (${code}): ${message}`);
341
+ }
342
+ return json;
343
+ }