@apex-inc/sdk 0.1.1 → 0.6.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
@@ -6,7 +6,7 @@ Works in Node.js 18+ and browser environments. Zero runtime dependencies.
6
6
 
7
7
  ## Access
8
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.
9
+ Apex is invite-only during the private-beta period. If you received an invite, your onboarding email contains your `workspaceKey` and `apiUrl`. Full API documentation is available in-app once your account is active.
10
10
 
11
11
  Not in the beta yet? Request access at [apex.inc](https://apex.inc) (coming soon).
12
12
 
@@ -22,7 +22,7 @@ npm install @apex-inc/sdk
22
22
  import { init, track, identify } from "@apex-inc/sdk";
23
23
 
24
24
  init({
25
- projectKey: "your-project-key",
25
+ workspaceKey: "your-workspace-key",
26
26
  apiUrl: "https://your-apex-instance.com",
27
27
  });
28
28
 
@@ -30,6 +30,74 @@ identify("user_123", { email: "jane@example.com" });
30
30
  track("page_viewed", { path: "/pricing" });
31
31
  ```
32
32
 
33
+ ## PLG primitives
34
+
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:
38
+
39
+ ```typescript
40
+ import { init, identify, group, feature, track } from "@apex-inc/sdk";
41
+ import { EVENTS, type ActivatedEvent } from "@apex-inc/sdk/plg-events";
42
+
43
+ init({ workspaceKey: "your-workspace-key", apiUrl: "https://app.apex.inc" });
44
+
45
+ // Identify the authenticated user.
46
+ identify("chris@acme.com", { visitorId: "apex_vid_from_cookie" });
47
+
48
+ // Group them into their account / team (B2B).
49
+ group("acct_acme", { plan: "growth", arr: 120_000 });
50
+
51
+ // Track feature usage. Include experiment variant if you have one.
52
+ feature("cohort-builder", { variant: "new-editor", enabled: true });
53
+
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);
59
+ ```
60
+
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
+ );
87
+ ```
88
+
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.
92
+
93
+ ## Multi-workspace orgs
94
+
95
+ If your organization runs more than one workspace (e.g. a SaaS product
96
+ plus a partner marketplace), each workspace has its own `workspaceKey`.
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.
100
+
33
101
  ## License
34
102
 
35
103
  MIT — see [LICENSE](./LICENSE)
package/dist/esm/index.js CHANGED
@@ -14,9 +14,14 @@ async function sendBatch(events) {
14
14
  const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
15
15
  const url = `${apiUrl.replace(/\/$/, "")}/api/events`;
16
16
  const body = JSON.stringify({
17
- projectKey: config.projectKey,
17
+ workspaceKey: config.workspaceKey,
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();
@@ -63,11 +68,16 @@ function enqueue(event) {
63
68
  // ─── Public API ──────────────────────────────────────────────────────────────
64
69
  /**
65
70
  * Initialize the Apex SDK. Must be called before track() or identify().
71
+ * Accepts `workspaceKey` (canonical) or the deprecated `projectKey`.
66
72
  */
67
73
  export function init(options) {
68
- config = options;
74
+ const workspaceKey = options.workspaceKey ?? options.projectKey;
75
+ if (!workspaceKey) {
76
+ throw new Error("apex.init: `workspaceKey` is required");
77
+ }
78
+ config = { ...options, workspaceKey };
69
79
  queue = [];
70
- log("Initialized with project:", options.projectKey);
80
+ log("Initialized with workspace:", workspaceKey);
71
81
  startFlushTimer();
72
82
  }
73
83
  /**
@@ -89,6 +99,9 @@ export function track(event, properties) {
89
99
  * Identify a user with traits (email, name, plan, etc.).
90
100
  * Also performs identity stitching if the userId looks like an email.
91
101
  *
102
+ * Prefer canonical snake_case trait names (see `CanonicalUserTraits`) so
103
+ * templates resolve them cleanly.
104
+ *
92
105
  * Pass `traits.visitorId` (the browser's `apex_vid` cookie value) to stitch
93
106
  * server-side identify calls to the real browser session. Without it, the SDK
94
107
  * creates a synthetic visitor ID that is disconnected from marketing touchpoints.
@@ -111,15 +124,21 @@ export function identify(userId_, traits) {
111
124
  const email = userId_.includes("@") ? userId_ : traits?.email;
112
125
  if (email) {
113
126
  const browserVisitorId = traits?.visitorId;
114
- stitchIdentity(email, browserVisitorId, traits);
127
+ // Forward the caller's stable user id as `userId` whenever it is NOT
128
+ // itself an email — this becomes the highest-precedence
129
+ // `internal_user_id` identifier server-side, which is what makes
130
+ // anonymous→known reconciliation deterministic. Always identify with
131
+ // your own user id (e.g. Cognito sub / DB id), not just an email.
132
+ const stableUserId = userId_.includes("@") ? undefined : userId_;
133
+ stitchIdentity(email, browserVisitorId, traits, stableUserId);
115
134
  }
116
135
  }
117
- async function stitchIdentity(email, browserVisitorId, metadata) {
136
+ async function stitchIdentity(email, browserVisitorId, metadata, stableUserId) {
118
137
  if (!config)
119
138
  return;
120
139
  const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
121
140
  const url = `${apiUrl.replace(/\/$/, "")}/api/identity/stitch`;
122
- const effectiveVisitorId = browserVisitorId || `sdk-${config.projectKey}-${email}`;
141
+ const effectiveVisitorId = browserVisitorId || `sdk-${config.workspaceKey}-${email}`;
123
142
  try {
124
143
  const fetchFn = typeof fetch !== "undefined" ? fetch : await loadNodeFetch();
125
144
  await fetchFn(url, {
@@ -128,7 +147,8 @@ async function stitchIdentity(email, browserVisitorId, metadata) {
128
147
  body: JSON.stringify({
129
148
  visitorId: effectiveVisitorId,
130
149
  email,
131
- projectKey: config.projectKey,
150
+ workspaceKey: config.workspaceKey,
151
+ ...(stableUserId ? { userId: stableUserId } : {}),
132
152
  metadata: metadata ?? {},
133
153
  }),
134
154
  });
@@ -138,6 +158,56 @@ async function stitchIdentity(email, browserVisitorId, metadata) {
138
158
  log("Identity stitch failed:", err);
139
159
  }
140
160
  }
161
+ /**
162
+ * MWO-009 — Group a user into an account / company / team so B2B
163
+ * PQA rollups can be derived. Calling `group("acme-inc")` stamps
164
+ * every subsequent `track`/`feature` event with `groupId`.
165
+ */
166
+ let groupId = null;
167
+ export function group(groupId_, traits) {
168
+ if (!config) {
169
+ console.warn("[apex-sdk] Call init() before group()");
170
+ return;
171
+ }
172
+ groupId = groupId_;
173
+ log("Group:", groupId_, traits);
174
+ enqueue({
175
+ type: "group",
176
+ payload: { groupId: groupId_, traits },
177
+ timestamp: new Date().toISOString(),
178
+ });
179
+ }
180
+ /**
181
+ * MWO-009 — Feature usage. Fire from your app whenever a user
182
+ * touches a named feature. Variant + enabled support wiring to
183
+ * Apex experiment payloads — if Phase 8's experiment engine served
184
+ * a variant, pass it through here to close the loop between
185
+ * assignment and adoption.
186
+ */
187
+ export function feature(featureKey, opts = {}) {
188
+ if (!config) {
189
+ console.warn("[apex-sdk] Call init() before feature()");
190
+ return;
191
+ }
192
+ log("Feature:", featureKey, opts);
193
+ enqueue({
194
+ type: "feature",
195
+ payload: {
196
+ featureKey,
197
+ enabled: opts.enabled,
198
+ variant: opts.variant,
199
+ properties: opts.properties,
200
+ },
201
+ timestamp: new Date().toISOString(),
202
+ });
203
+ }
204
+ /**
205
+ * Returns the most recently set groupId (if any). Callers can use
206
+ * this to decorate their own payloads or assert in tests.
207
+ */
208
+ export function currentGroupId() {
209
+ return groupId;
210
+ }
141
211
  /**
142
212
  * Track a form submission event with the email for identity stitching.
143
213
  */
@@ -165,6 +235,34 @@ export function flush() {
165
235
  const batch = queue.splice(0, queue.length);
166
236
  sendBatch(batch);
167
237
  }
238
+ /**
239
+ * Helpers grouped under `trackCart.*` so call sites read naturally
240
+ * (`apex.trackCart.add({...})`). The implementation just delegates to
241
+ * `track()` with the canonical event names.
242
+ */
243
+ export const trackCart = {
244
+ /** Fired when a user adds an item to their cart. */
245
+ add(line) {
246
+ track("add_to_cart", { ...line });
247
+ },
248
+ /** Fired when a user removes an item from their cart. */
249
+ remove(opts) {
250
+ track("remove_from_cart", { ...opts });
251
+ },
252
+ /**
253
+ * Full-cart reconciliation snapshot. Use whenever the canonical
254
+ * cart state is loaded (login restore, app foreground, cart screen
255
+ * mount). The server-side rollup replaces all stored lines wholesale
256
+ * — this is the "source of truth" event when deltas drift.
257
+ */
258
+ snapshot(opts) {
259
+ track("cart_snapshot", { ...opts });
260
+ },
261
+ /** Fired on checkout success; clears the rolled-up cart. */
262
+ checkout(opts) {
263
+ track("checkout_completed", { ...opts });
264
+ },
265
+ };
168
266
  /**
169
267
  * Shut down the SDK — flush remaining events and stop the timer.
170
268
  */
@@ -178,3 +276,73 @@ export function shutdown() {
178
276
  log("Shut down");
179
277
  }
180
278
  export { createManagementClient, } from "./management";
279
+ /**
280
+ * Generate a stable idempotency key. Prefers `crypto.randomUUID()`
281
+ * when available (Node 19+, modern browsers). Falls back to a
282
+ * timestamp + random suffix that's good enough to avoid collisions
283
+ * within a single process.
284
+ */
285
+ export function newIdempotencyKey() {
286
+ const c = globalThis.crypto;
287
+ if (c?.randomUUID)
288
+ return c.randomUUID();
289
+ return `idk_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
290
+ }
291
+ /**
292
+ * Send a single event server-to-server. Useful for Stripe webhooks,
293
+ * CRM-driven conversions, and any backend signal that should land in
294
+ * Apex without going through the browser snippet.
295
+ *
296
+ * @example
297
+ * await sendServerEvent(
298
+ * { apiKey: process.env.APEX_API_KEY! },
299
+ * {
300
+ * type: "purchase_completed",
301
+ * email: "user@example.com",
302
+ * data: { value: 99, currency: "USD", order_id: "order_42" },
303
+ * },
304
+ * { idempotencyKey: stripeEvent.id }
305
+ * );
306
+ */
307
+ export async function sendServerEvent(config, event, options = {}) {
308
+ return sendServerEvents(config, [event], options);
309
+ }
310
+ /**
311
+ * Send up to 100 events server-to-server in one request. Larger
312
+ * batches must be sharded by the caller (the API returns 400
313
+ * `batch_too_large` otherwise).
314
+ */
315
+ export async function sendServerEvents(config, events, options = {}) {
316
+ if (!config.apiKey) {
317
+ throw new Error("apex-sdk: sendServerEvent requires apiKey");
318
+ }
319
+ if (events.length === 0) {
320
+ throw new Error("apex-sdk: sendServerEvent requires at least one event");
321
+ }
322
+ if (events.length > 100) {
323
+ throw new Error("apex-sdk: sendServerEvents batch_too_large — max 100 events; shard yourself");
324
+ }
325
+ const apiUrl = (config.apiUrl ?? "https://app.apex.inc").replace(/\/$/, "");
326
+ const url = `${apiUrl}/api/v1/events`;
327
+ const idempotencyKey = options.idempotencyKey ?? newIdempotencyKey();
328
+ const headers = {
329
+ "content-type": "application/json",
330
+ "x-api-key": config.apiKey,
331
+ "idempotency-key": idempotencyKey,
332
+ };
333
+ if (config.workspaceKey)
334
+ headers["x-apex-workspace"] = config.workspaceKey;
335
+ const fetchFn = typeof fetch !== "undefined" ? fetch : await loadNodeFetch();
336
+ const res = await fetchFn(url, {
337
+ method: "POST",
338
+ headers,
339
+ body: JSON.stringify({ events }),
340
+ });
341
+ const json = (await res.json().catch(() => ({})));
342
+ if (!res.ok) {
343
+ const code = json.error?.code ?? `http_${res.status}`;
344
+ const message = json.error?.message ?? `HTTP ${res.status}`;
345
+ throw new Error(`apex-sdk: sendServerEvent failed (${code}): ${message}`);
346
+ }
347
+ return json;
348
+ }