@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 +70 -2
- package/dist/esm/index.js +175 -7
- package/dist/esm/management.js +395 -3
- package/dist/esm/plg-events.js +45 -0
- package/dist/index.d.ts +254 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +182 -8
- package/dist/index.js.map +1 -1
- package/dist/management.d.ts +285 -2
- package/dist/management.d.ts.map +1 -1
- package/dist/management.js +395 -3
- package/dist/management.js.map +1 -1
- package/dist/plg-events.d.ts +131 -0
- package/dist/plg-events.d.ts.map +1 -0
- package/dist/plg-events.js +50 -0
- package/dist/plg-events.js.map +1 -0
- package/package.json +6 -1
- package/skills/apex-communications/SKILL.md +217 -1
- package/skills/apex-experimentation/SKILL.md +2 -2
- package/skills/apex-growth-tracking/SKILL.md +93 -2
- package/skills/apex-integration-cookbook/SKILL.md +141 -26
- package/skills/apex-partner-network/SKILL.md +191 -0
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|