next-evolve 0.3.3 → 0.3.5
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 +113 -86
- package/dist/cli/detect.js +1 -1
- package/dist/cli/files.js +4 -3
- package/dist/cli/init.js +6 -2
- package/dist/cli/load-app.js +1 -1
- package/dist/client/beacon.d.ts +7 -0
- package/dist/client/beacon.js +28 -1
- package/dist/client/evolve-script.js +2 -15
- package/dist/react/region.d.ts +12 -0
- package/dist/react/region.js +25 -12
- package/dist/store/memory.js +1 -0
- package/docs/api.md +238 -0
- package/docs/concepts.md +70 -0
- package/docs/configuration.md +74 -0
- package/docs/goals-and-attribution.md +81 -0
- package/docs/production.md +75 -0
- package/docs/stripe.md +91 -0
- package/docs/troubleshooting.md +90 -0
- package/docs/what-leaves-your-server.md +38 -0
- package/docs/without-an-account.md +28 -0
- package/package.json +3 -2
package/docs/stripe.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Stripe recipe: count paid subscriptions as a goal
|
|
2
|
+
|
|
3
|
+
Goal: "did the visitor who saw this variant start paying within 30 days, and how much?" Evolve doesn't connect to Stripe. You record the payment as an event from your own Stripe webhook handler, and link the Stripe customer to the visitor with `identify`.
|
|
4
|
+
|
|
5
|
+
## 1. Link the visitor to your user
|
|
6
|
+
|
|
7
|
+
When the visitor signs up (or signs in), link their browser to your user id. In a server action or route handler:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { evolve } from "next-evolve/server";
|
|
11
|
+
|
|
12
|
+
await evolve.identify(user.id);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 2. Put your user id on the Stripe objects
|
|
16
|
+
|
|
17
|
+
When you create the Checkout Session, carry your user id through to the subscription and its invoices:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
await stripe.checkout.sessions.create({
|
|
21
|
+
mode: "subscription",
|
|
22
|
+
client_reference_id: user.id,
|
|
23
|
+
subscription_data: { metadata: { userId: user.id } },
|
|
24
|
+
// line_items, success_url, …
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 3. Record the payment in your webhook handler
|
|
29
|
+
|
|
30
|
+
After you verify the signature, call `evolve.track` with the user id. Use the Stripe event id as `dedupeKey`: Stripe retries deliveries, and Evolve stores each key once.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// app/api/stripe/route.ts
|
|
34
|
+
import Stripe from "stripe";
|
|
35
|
+
import { evolve } from "next-evolve/server";
|
|
36
|
+
|
|
37
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
38
|
+
|
|
39
|
+
export async function POST(req: Request) {
|
|
40
|
+
const event = stripe.webhooks.constructEvent(
|
|
41
|
+
await req.text(),
|
|
42
|
+
req.headers.get("stripe-signature")!,
|
|
43
|
+
process.env.STRIPE_WEBHOOK_SECRET!,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (event.type === "invoice.paid") {
|
|
47
|
+
const invoice = event.data.object;
|
|
48
|
+
const userId = invoice.parent?.subscription_details?.metadata?.userId;
|
|
49
|
+
if (userId && invoice.billing_reason === "subscription_create") {
|
|
50
|
+
await evolve.track("subscription_started", {
|
|
51
|
+
userId,
|
|
52
|
+
source: "stripe",
|
|
53
|
+
props: { amount: invoice.amount_paid / 100, currency: invoice.currency },
|
|
54
|
+
dedupeKey: event.id,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return new Response(null, { status: 200 });
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Where the metadata sits on the invoice depends on your Stripe API version; check one `invoice.paid` payload in the Stripe dashboard. `billing_reason === "subscription_create"` counts the first payment only; drop it to count renewals too.
|
|
63
|
+
|
|
64
|
+
The webhook request has no Evolve cookie, so `userId` is what connects the payment to the visitor (through step 1).
|
|
65
|
+
|
|
66
|
+
## 4. Define the goal
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// evolve.goals.ts
|
|
70
|
+
import { defineGoals } from "next-evolve/config";
|
|
71
|
+
|
|
72
|
+
export default defineGoals({
|
|
73
|
+
paid: {
|
|
74
|
+
event: "subscription_started",
|
|
75
|
+
source: "stripe",
|
|
76
|
+
window: "30d",
|
|
77
|
+
value: "amount", // revenue per visitor; omit for conversion rate
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`value` can also be a function, e.g. to convert currencies: `value: (e) => toUsd(e.props.amount as number, e.props.currency as string)`. It runs in your app when the event is written.
|
|
83
|
+
|
|
84
|
+
Restart the app (or run `npx next-evolve push`) so the cloud sees the new goal, then pick `paid` as the primary or a secondary goal when you create an experiment.
|
|
85
|
+
|
|
86
|
+
## Notes
|
|
87
|
+
|
|
88
|
+
- Use `unit: "user"` if people often sign up on one device and pay on another.
|
|
89
|
+
- Payments are attributed to the variant the visitor first saw, if they happen within 30 days of that first exposure.
|
|
90
|
+
- Nothing about the payment leaves your server except the aggregate counts and sums per variant.
|
|
91
|
+
- Using Segment or another service instead? Send the same event to `POST /api/evolve/track` ([API](api.md#http-routes)).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Start with `npx next-evolve doctor`. It checks the env, key, database, bundle, proxy matcher, pages, `<EvolveScript />` and whether the cloud can reach your app, and says what to fix. The dashboard's **Connect** page shows the cloud's side.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
**`init`: "A secret key is needed" / "the secret key was rejected".** Pass `--key evk_live_…` (or set `EVOLVE_SECRET_KEY`). The key is shown once when you create the site; if you lost it, issue a new one on the Connect page.
|
|
8
|
+
|
|
9
|
+
**`init` printed "Add `<EvolveScript />` … to your root layout".** It couldn't find a place to insert it. Add `import { EvolveScript } from "next-evolve/client"` and put `<EvolveScript />` inside `<body>`.
|
|
10
|
+
|
|
11
|
+
**`init` printed instructions for your proxy.** You already have a `proxy.ts`/`middleware.ts`. Call `applyEvolve` first in it and add the routes plus `/(ev_.*)` to `config.matcher` ([API](api.md#next-evolveproxy)).
|
|
12
|
+
|
|
13
|
+
**A route stayed in dynamic mode.** `init` says why (top-level `[slug]`, route group, `force-dynamic`, `"use client"` page…). Dynamic mode works the same; the page just isn't cached per variant.
|
|
14
|
+
|
|
15
|
+
**`cacheComponents` errors.**
|
|
16
|
+
- `Route segment config "revalidate" is not compatible with nextConfig.cacheComponents`: the page was set up in static mode. Re-run `npx next-evolve init` with `next-evolve` 0.3.4 or newer (it uses dynamic mode with `cacheComponents`), or by hand: move the page back out of `app/[evolve]/`, remove the `withEvolve`/`evolveStaticParams`/`evolveMetadata` lines and set the route's mode to `"dynamic"`.
|
|
17
|
+
- `uncached or runtime data … accessed outside of <Suspense>` in `next build`: wrap `<Optimizable>` in `<Suspense>`.
|
|
18
|
+
- `encountered the unstable value Date.now()` in the dev overlay, pointing at `<Optimizable>`: upgrade to `next-evolve` 0.3.5 or newer.
|
|
19
|
+
|
|
20
|
+
**`doctor`: "Database: not migrated".** Run `npx next-evolve migrate`.
|
|
21
|
+
|
|
22
|
+
**`doctor`: "Proxy matcher … misses".** Every route in `evolve.config.ts` must be in `config.matcher`, plus `/(ev_.*)`.
|
|
23
|
+
|
|
24
|
+
## Nothing is being tested
|
|
25
|
+
|
|
26
|
+
**Every visitor sees control.** In order:
|
|
27
|
+
1. Is the experiment **running** (not draft or paused) and on this exact path? Paths match exactly (a trailing slash is ignored): an experiment on `/pricing` doesn't run on `/pricing/teams`.
|
|
28
|
+
2. Is the route in `evolve.config.ts` and the proxy matcher?
|
|
29
|
+
3. Does the app have a bundle? The log says `no bundle yet` or `bundle fetch failed (401)` (bad key) / `(network)` (cloud unreachable). `doctor` checks it.
|
|
30
|
+
4. Are you a "bot"? Headless browsers, `curl` without a browser user agent, and Lighthouse always get control.
|
|
31
|
+
5. Did `consent` return `false`?
|
|
32
|
+
6. Are you outside the traffic share? Those visitors see control and aren't counted.
|
|
33
|
+
7. Static mode: is the page under `app/[evolve]/` and wrapped with `withEvolve()`? In development, `<Optimizable>` warns "rendered without Evolve context" when it isn't.
|
|
34
|
+
|
|
35
|
+
**I keep seeing the same variant.** That's intended: a visitor keeps their assignment. Use a private window (each one is a new visitor), delete the `evolve_id` and `evolve_a` cookies, or use a **Preview** link from the experiment page.
|
|
36
|
+
|
|
37
|
+
**A change in the dashboard doesn't show up.** The app picks up a new bundle when the sync webhook reaches it (seconds), or by polling: within one poll interval (60 s ± 10% in production, 5 s in development) of an instance's last check, on its next request. The request that triggers the check is still served the old bundle. In local development the webhook can't reach `localhost`, so it's polling only. If production is slow, check the Connect page's "Sync webhook" status. On Vercel without Global Config, only the instance that gets the webhook updates at once.
|
|
38
|
+
|
|
39
|
+
**A variant renders control and isn't counted.** Its props failed the component's schema (the log says `variant … failed its schema`), usually because the schema changed in code after the variant was made. Add a new variant.
|
|
40
|
+
|
|
41
|
+
## Nothing is being measured
|
|
42
|
+
|
|
43
|
+
**No rows in `evolve.exposures`.**
|
|
44
|
+
- Is `<EvolveScript />` in the root layout? Check the browser's network tab for `POST /api/evolve/collect` (it batches every 2 s and on page hide).
|
|
45
|
+
- Is `EVOLVE_DATABASE_URL` set where the app runs? `GET /api/evolve/health` returns `"db": true` when it is.
|
|
46
|
+
- Was the visitor counted? Bots, preview sessions, visitors outside the traffic share and visitors without an `evolve_id` cookie aren't.
|
|
47
|
+
- Is the database up? On serverless hosts, events are dropped while it's down (one retry). On a Node server they're buffered (up to 10,000 rows) and written when it's back.
|
|
48
|
+
|
|
49
|
+
**Goal events don't count.**
|
|
50
|
+
- The event name must match the goal's `event` exactly, and `source`/`where` must match if set.
|
|
51
|
+
- Only events after the visitor's first exposure and within the goal's window count.
|
|
52
|
+
- Server events from webhooks or other devices need `evolve.identify(userId)` to have linked the visitor, and the event must carry that `userId`.
|
|
53
|
+
- New or changed goals reach the cloud when the app restarts (or `npx next-evolve push`).
|
|
54
|
+
|
|
55
|
+
## Results
|
|
56
|
+
|
|
57
|
+
**Results show nothing, or not the visit I just made.** Aggregates only count data older than **2 minutes**. Wait and click **Recompute** again (at most once a minute; a second click within a minute does nothing).
|
|
58
|
+
|
|
59
|
+
**Local development: results never update.** The cloud can't reach `localhost`. Run `npx next-evolve aggregate --upload` (2 minutes after the visits).
|
|
60
|
+
|
|
61
|
+
**Connect page: "Aggregate route ok · last error: …".** The last aggregate request failed and retries are still pending; the status turns `unhealthy` only after the retries run out. Results keep their last values (and bandit weights stay where they were) until a run succeeds. `HTTP 503 {"error":"database_error"}` means your app couldn't reach its database.
|
|
62
|
+
|
|
63
|
+
**Connection test fails.**
|
|
64
|
+
- `401` / `bad_signature`: `EVOLVE_WEBHOOK_SECRET` in your app doesn't match the site's. Issue a new one on the Connect page or fix the env var, then redeploy.
|
|
65
|
+
- `403`, `429` or an HTML challenge page: a firewall or bot protection in front of your app blocked the cloud. Let `/api/evolve/*` through ([production.md](production.md#checklist)); the routes check signatures themselves.
|
|
66
|
+
- `404`: the app URL is wrong, or the deploy doesn't have the `app/api/evolve/[...evolve]/route.ts` file.
|
|
67
|
+
- `503 not_configured`: `EVOLVE_SECRET_KEY` or `EVOLVE_WEBHOOK_SECRET` isn't set in the deployed app.
|
|
68
|
+
- Timeout or "no app URL": register it with `npx next-evolve connect --app-url https://…`. It must be a public https URL.
|
|
69
|
+
|
|
70
|
+
**"Window not elapsed for X% of visitors".** Those visitors' goal windows are still open, so their conversions may still arrive. Not an error.
|
|
71
|
+
|
|
72
|
+
## The site serves control after working for a while
|
|
73
|
+
|
|
74
|
+
- **The bundle expired.** If the app couldn't reach the cloud for 14 days, or the key was revoked or the site deleted, the last bundle expires and every visitor gets control. New visitors aren't recorded; returning visitors' events and server events still are. Fix the connection (check `EVOLVE_SECRET_KEY` and outbound access to `api.evolveab.com`); the next poll restores serving.
|
|
75
|
+
- `doctor` fails the bundle check when the current bundle is less than 7 days from expiry.
|
|
76
|
+
|
|
77
|
+
## Logs
|
|
78
|
+
|
|
79
|
+
The SDK logs with an `[evolve]` prefix, at most once a minute per message:
|
|
80
|
+
|
|
81
|
+
| Message | Meaning |
|
|
82
|
+
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
|
83
|
+
| `no bundle yet (no experiment has started): serving control` | Normal for a new site |
|
|
84
|
+
| `bundle fetch failed (401)` | The key is wrong or revoked |
|
|
85
|
+
| `bundle fetch failed (network)` | The cloud is unreachable; the last bundle keeps serving |
|
|
86
|
+
| `bundle rejected: …` | A bundle failed verification (wrong site, bad signature, older version) and was ignored |
|
|
87
|
+
| `manifest push failed (…)` | Startup couldn't send routes and goals; retried on the next start, or run `npx next-evolve push` |
|
|
88
|
+
| `customer DB write failed; will retry` | The database is unreachable (Node keeps buffering) |
|
|
89
|
+
| `customer DB write failed; batch dropped` | The database is unreachable (serverless: the retry failed, those events are lost) |
|
|
90
|
+
| `customer DB unavailable; dropping N buffered rows` | The Node buffer is full (10,000 rows) |
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# What leaves your server
|
|
2
|
+
|
|
3
|
+
Raw events (exposures, goal events, identities, page views) are written to **your** Postgres and stay there. Evolve Cloud never connects to your database. This page lists every network call the SDK makes and every call it accepts.
|
|
4
|
+
|
|
5
|
+
## Browser → your app
|
|
6
|
+
|
|
7
|
+
`<EvolveScript />` sends page views, exposures, goal events and `identify` calls to `/api/evolve/collect` **on your own origin**. Nothing goes to Evolve or any third party from the browser.
|
|
8
|
+
|
|
9
|
+
## Your app → Evolve Cloud
|
|
10
|
+
|
|
11
|
+
All calls go to `EVOLVE_CLOUD_URL` (default `https://api.evolveab.com`) with your secret key as a bearer token.
|
|
12
|
+
|
|
13
|
+
| Call | When | What it carries |
|
|
14
|
+
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
15
|
+
| `GET /v1/bundle` | Every 60 s per instance (5 s in dev), and when the sync webhook arrives | Nothing beyond the key and an `If-None-Match` version. Gets back the signed bundle |
|
|
16
|
+
| `POST /v1/manifest` | Once per process at startup (`registerEvolve`), `next-evolve push`, `init` | `routes` and their modes from `evolve.config.ts`; goal definitions from `evolve.goals.ts` (value functions become `{ kind: "local-fn" }`); `next-evolve` and Next.js versions; the protocol versions the SDK speaks; your public app URL if known (`EVOLVE_APP_URL`, or Vercel's production URL) |
|
|
17
|
+
| `POST /v1/regions` | The first time a page renders a region, once per process | The region id, the JSON Schema of its editable props, the control props (schema fields only, as written in your code) and the page path |
|
|
18
|
+
| Aggregate responses | Answering the cloud's aggregate request (below) | Signed counts and sums |
|
|
19
|
+
| `GET /v1/site`, `PUT /v1/site/app-url`, `POST /v1/site/test`, `POST /v1/aggregates/request`, `POST /v1/aggregates` | Only from the CLI (`init`, `connect`, `doctor`, `aggregate`) | Your app URL; the same aggregate counts as below |
|
|
20
|
+
|
|
21
|
+
## Evolve Cloud → your app
|
|
22
|
+
|
|
23
|
+
Both routes check an HMAC signature made with `EVOLVE_WEBHOOK_SECRET` (shared only between your app and the cloud), a 5-minute clock window and a replay check.
|
|
24
|
+
|
|
25
|
+
| Call | What it carries | What your app sends back |
|
|
26
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
27
|
+
| `POST /api/evolve/sync` | "The bundle changed" (site, version, time), or a ping. No configuration: a forged call can at most cause one extra bundle fetch | `202` |
|
|
28
|
+
| `POST /api/evolve/aggregate` | Which experiments, variants and goal definitions to count, from which day, up to which time | Per experiment × variant × goal × day of first exposure: units, conversions, value sum and sum of squares (and the same for "mature" units whose window has passed). Tested page views per day. Per-goal error messages if a query failed. Duration and SDK version. Signed with the webhook secret |
|
|
29
|
+
|
|
30
|
+
**Never sent:** visitor ids (`evolve_id`), your user ids, IP addresses, user agents, event props, page-view paths, individual events or timestamps. The cloud's database has no column for them, and a test fails if one is added.
|
|
31
|
+
|
|
32
|
+
## Your app → Vercel (optional)
|
|
33
|
+
|
|
34
|
+
With Vercel Global Config configured (`EVOLVE_GLOBAL_CONFIG*`), the sync route writes the signed bundle to your own Global Config store through `api.vercel.com`. It's the same bundle the app downloaded.
|
|
35
|
+
|
|
36
|
+
## What the cloud stores about your site
|
|
37
|
+
|
|
38
|
+
Your account and sites, keys (hashed) and the webhook secret (encrypted), the manifest and regions above, experiments and variants, the signed bundles, the aggregate counts per day, computed results, and a log of its calls to your app (status, timing, and the first 200 bytes of an error response).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# What the SDK does without an account
|
|
2
|
+
|
|
3
|
+
`next-evolve` is MIT-licensed, but on its own it doesn't run experiments: the configuration (the signed bundle), the statistics and the dashboard come from Evolve Cloud. Without a working connection, your site keeps working as if Evolve weren't there.
|
|
4
|
+
|
|
5
|
+
## No account, no key, or no bundle yet
|
|
6
|
+
|
|
7
|
+
If `EVOLVE_SECRET_KEY` isn't set, the key is invalid, or the site has never started an experiment:
|
|
8
|
+
|
|
9
|
+
- Every page renders **control**: the props in your code.
|
|
10
|
+
- **No cookies** are set.
|
|
11
|
+
- **Nothing is recorded** (no assignments means no exposures; the collect route drops visitors without an `evolve_id` cookie).
|
|
12
|
+
- There's no network call on the request path. In the background the SDK tries to fetch a bundle (every 60 s) and logs `no bundle yet (no experiment has started): serving control` or `bundle fetch failed (…)`, at most once a minute.
|
|
13
|
+
- `npx next-evolve pull` writes an empty build bundle and never fails the build.
|
|
14
|
+
|
|
15
|
+
Removing Evolve is the same as never having added it: delete the `<Optimizable>` wrappers (render the component directly) or leave them; they render control.
|
|
16
|
+
|
|
17
|
+
## The cloud becomes unreachable
|
|
18
|
+
|
|
19
|
+
- **Up to 14 days:** the app serves the last bundle it verified. Experiments keep running with their last weights, events keep being written to your database, and the next aggregate after the outage counts them.
|
|
20
|
+
- **After 14 days** (the bundle's expiry): control for everyone, no new cookies. Events from visitors who already have an `evolve_id` cookie, and server events (`evolve.track`, `/api/evolve/track`), are still written; new visitors aren't recorded.
|
|
21
|
+
|
|
22
|
+
## The key is revoked or the site deleted
|
|
23
|
+
|
|
24
|
+
The cloud answers `401`, which the SDK treats like an outage: the last bundle serves until it expires (at most 14 days), then control. There's no instant kill switch; to stop an experiment at once, pause it in the dashboard.
|
|
25
|
+
|
|
26
|
+
## Your database is missing or down
|
|
27
|
+
|
|
28
|
+
Serving never touches the database, so variants keep serving. Measurement stops: on serverless hosts events are dropped (one retry); on long-running Node up to 10,000 rows are buffered and written when the database is back (oldest dropped first beyond that). The aggregate route answers `503 database_error`.
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-evolve",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Self-improving Next.js sites: server-side variants with no flicker, measured in your own Postgres.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
|
-
"migrations"
|
|
10
|
+
"migrations",
|
|
11
|
+
"docs"
|
|
11
12
|
],
|
|
12
13
|
"exports": {
|
|
13
14
|
".": {
|