@littlefriend/cli 0.1.1 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +8 -8
  2. package/dist/index.js +64 -18
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -23,7 +23,7 @@ littlefriend login
23
23
  littlefriend login
24
24
  littlefriend projects create --name "Acme" --domain acme.com --mode journey
25
25
  littlefriend snippet --framework next
26
- littlefriend keys create --kind server --env-file .env.local
26
+ littlefriend keys create --kind server --write-env .env.local
27
27
  littlefriend verify
28
28
  ```
29
29
 
@@ -188,7 +188,7 @@ littlefriend projects get --project acme.com
188
188
 
189
189
  #### `projects update`
190
190
 
191
- Changes a project's settings: `--name`, `--domain`, `--mode`, `--timezone`, `--events-days <1-7>`, `--aggregates-days <30-395>`, `--campaign-term` or `--no-campaign-term`. For allowed origins, `--origin` replaces the list, `--add-origin` and `--remove-origin` edit it, and `--any-origin` empties it.
191
+ Changes a project's settings: `--name`, `--domain`, `--mode`, `--timezone`, `--events-days <1-7>`, `--aggregates-days <30-395>`, `--campaign-term` or `--no-campaign-term`. For allowed origins, `--origin` replaces the list, `--add-origin` and `--remove-origin` edit it, and `--any-origin` empties it. Ignored routes work the same way: `--ignored-route` replaces the list, `--add-ignored-route` and `--remove-ignored-route` edit it, and `--no-ignored-routes` empties it. Requests a log drain or the door sees on an ignored route, `/api` by default, are counted and not kept.
192
192
 
193
193
  ```sh
194
194
  littlefriend projects update --add-origin https://staging.acme.com
@@ -264,17 +264,17 @@ littlefriend keys list
264
264
 
265
265
  Creates a secret key: `server` for `@littlefriend/node`, `edge` for `@littlefriend/edge` and the agent door, `read` for reading reports.
266
266
 
267
- With `--env-file`, the secret goes straight into the file as `NAME=secret` and is never printed. The name defaults to `LF_SERVER_KEY`, `LF_EDGE_KEY` or `LF_READ_KEY`; change it with `--env-name`. If the file already sets that name, no key is created. The CLI warns when git does not ignore the file.
267
+ With `--write-env`, the secret goes straight into the file as `NAME=secret` and is never printed. The name defaults to `LF_SERVER_KEY`, `LF_EDGE_KEY` or `LF_READ_KEY`; change it with `--env-name`. If the file already sets that name, no key is created. The CLI warns when git does not ignore the file.
268
268
 
269
269
  ```sh
270
- littlefriend keys create --kind server --env-file .env.local
270
+ littlefriend keys create --kind server --write-env .env.local
271
271
  ```
272
272
 
273
273
  ```ts
274
- // with --env-file
274
+ // with --write-env
275
275
  { created: true; key: ApiKey; envFile: string; envName: string }
276
276
  { created: false; envFile: string; envName: string; reason: 'already_set' }
277
- // without --env-file: the secret appears once, here
277
+ // without --write-env: the secret appears once, here
278
278
  { key: ApiKey; secret: string; notice: string }
279
279
  ```
280
280
 
@@ -294,10 +294,10 @@ littlefriend keys revoke key_... --yes
294
294
 
295
295
  Waits for the first event from the site and says what it was: time, name, route and source. Then it shows the project's health: whether the install is verified, when the last event arrived, which sources send, and requests refused in the last 24 hours with what each reason means. `origin_not_allowed`, for example, means a page on a host missing from the allowed origins sent it.
296
296
 
297
- `--wait <seconds>` (default 60, 0 checks once) and `--since <minutes>` (default 60) set how long to wait and how far back an event counts. Exits 1 when nothing arrived.
297
+ `--wait <seconds>` (default 60, 0 checks once) and `--since <minutes>` (default 60) set how long to wait and how far back an event counts. `--source browser|server|edge` counts only events from that source: on a site with a log drain, `--source browser` checks the tag itself. Exits 1 when nothing arrived.
298
298
 
299
299
  ```sh
300
- littlefriend verify --wait 120
300
+ littlefriend verify --source browser --wait 120
301
301
  ```
302
302
 
303
303
  ```ts
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
131
131
  import { createServer } from "node:http";
132
132
 
133
133
  // src/version.ts
134
- var VERSION = "0.1.1";
134
+ var VERSION = "0.1.3";
135
135
  var USER_AGENT = `littlefriend-cli/${VERSION}`;
136
136
 
137
137
  // src/oauth.ts
@@ -2826,7 +2826,13 @@ var REPLAY_KEPT_ATTRIBUTES = [
2826
2826
  "aria-disabled",
2827
2827
  "aria-pressed"
2828
2828
  ];
2829
- var REPLAY_TEXT_ATTRIBUTES = ["title", "alt", "placeholder", "aria-label", "aria-description"];
2829
+ var REPLAY_TEXT_ATTRIBUTES = [
2830
+ "title",
2831
+ "alt",
2832
+ "placeholder",
2833
+ "aria-label",
2834
+ "aria-description"
2835
+ ];
2830
2836
  var REPLAY_UNMASK_PRESET = [
2831
2837
  "nav",
2832
2838
  "header",
@@ -3618,7 +3624,7 @@ This copy of the CLI was built without the setup guide.
3618
3624
  Read it at ${DOCS_URL}
3619
3625
  `;
3620
3626
  function bundledGuide() {
3621
- return true ? { text: "# Set up Little Friend in a product: guide for coding agents\n\nYou are a coding agent working in a product's repository. This guide takes you from nothing to a verified Little Friend install: the project, the script, events, goals, funnels, session replay, server events and AI agent visibility. It ends with a report for the person who asked.\n\nWork through the steps in order. Each decision has a default: use it unless the repo or the person who asked says otherwise. When a step does not apply, skip it and say why in the report.\n\n## Contents\n\n0. [Conventions](#0-conventions)\n1. [What Little Friend is, and the rules](#1-what-little-friend-is-and-the-rules)\n2. [Sign in once per machine](#2-sign-in-once-per-machine)\n3. [Read the repo and plan the journeys](#3-read-the-repo-and-plan-the-journeys)\n4. [Workspace, project, mode and consent](#4-workspace-project-mode-and-consent)\n5. [Install the script](#5-install-the-script)\n6. [Content Security Policy](#6-content-security-policy)\n7. [Custom events](#7-custom-events)\n8. [Goals](#8-goals)\n9. [Funnels](#9-funnels)\n10. [Identify signed-in people](#10-identify-signed-in-people)\n11. [Session replay](#11-session-replay)\n12. [Server events](#12-server-events)\n13. [Crawlers and AI agents: the door and a log drain](#13-crawlers-and-ai-agents-the-door-and-a-log-drain)\n14. [Hybrid and native apps](#14-hybrid-and-native-apps)\n15. [Verify](#15-verify)\n16. [Launch checklist](#16-launch-checklist)\n17. [Report back](#17-report-back)\n\n## 0. Conventions\n\n- **The CLI.** Run it as `npx --yes @littlefriend/cli <command>`. It needs Node 22 or later. This guide writes `littlefriend <command>` for short: type the full `npx` form, unless the CLI is installed globally.\n- **Scope flags.** Commands that work on a project take `--project <id>`, and commands that work in a workspace take `--workspace <id or name>`. Agent shells often drop environment variables between calls, so pass the flags on every command. The examples below leave them out to stay short. The CLI also reads `LITTLEFRIEND_PROJECT` and `LITTLEFRIEND_WORKSPACE`.\n- **Reading output.** Add `--json` when you need a value from the output, such as an id. Errors go to stderr with a non-zero exit code. With `--json`, they are also printed on stdout as `{ \"error\": { \"code\", \"message\" } }`.\n- **Secrets.** Server keys (`lfs_`), edge keys (`lfe_`), read keys (`lfr_`) and drain secrets are shown once. Never put them in your messages, logs, commits or the report. Write keys to an env file with `--env-file` (step 12). The public site key (`lf_`) is fine to commit.\n- **Credentials.** `~/.littlefriend/credentials.json` holds the sign-in. Never read it, print it or copy it into a repo.\n- **Deletes.** Never delete or revoke a project, key, goal, funnel or drain you did not create in this session.\n- **Repo rules win.** Follow the product repo's own rules for branches, commits, dependencies and deploys.\n- **The code wins.** `littlefriend <command> --help` shows a command's flags. If this guide and the CLI disagree, trust the CLI and say so in the report.\n\n## 1. What Little Friend is, and the rules\n\nLittle Friend is privacy-first web analytics (littlefriend.io). A small script, `lf.js`, counts page views, sources, clicks and named events without cookies. Server events count outcomes your backend confirms. Log drains and `@littlefriend/edge` show crawlers and AI agents, and the door decides which agents get in. Reports live at app.littlefriend.io.\n\nThe product must keep these rules. If a task would break one, stop and ask.\n\n1. **No personal data** in event names, property keys or values, routes, ids, or goal and funnel names. Personal data means emails, names, usernames, phone numbers, addresses, account or card numbers, text a person typed, and anything else that points to one person.\n2. **`identify` takes an opaque id** from the product's own database, such as `usr_8f3k2`. Never an email, a phone number, a name, or a hash of any of them.\n3. **No typed text.** Never send form values, search queries or error messages as properties. Report a failed form with a short category, such as `validation` or `server`.\n4. **Secret keys stay on the server.** Never put `lfs_`, `lfe_` or `lfr_` keys in client code, or in a variable a bundler exposes to the browser (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`, `EXPO_PUBLIC_`).\n5. **No extra tracking.** Do not add cookies, storage or fingerprinting for analytics. Do not work around Global Privacy Control or a visitor's opt-out: the script already honors both.\n6. **Unknown stays unknown.** Never combine an IP address with device traits to guess who someone is.\n\nLittle Friend also strips query strings and fragments (except allowlisted `utm_*` values), turns id-like and personal-looking path segments into `:id` or `:redacted`, drops property values that look like an email or a long number, and never reads form values or page text. Treat that as a safety net. Rule 1 still applies.\n\n## 2. Sign in once per machine\n\n```sh\nlittlefriend whoami\n```\n\nIf it names an account, go to step 3. Otherwise:\n\n```sh\nlittlefriend login\n```\n\nIt opens a browser, prints the URL too, and waits up to 5 minutes. The person who owns the Little Friend account approves the login in the browser. Tell them a sign-in page is waiting, then wait. If nobody approves in time, stop and ask. Do not retry in a loop.\n\n- The CLI acts with that person's role in each workspace. Creating projects, keys, goals, funnels and settings needs the editor or owner role.\n- In CI, set `LITTLEFRIEND_TOKEN` to an access token instead of signing in.\n- `littlefriend logout` revokes the sign-in and deletes the local credentials.\n\n## 3. Read the repo and plan the journeys\n\nCollect these facts before you create anything. They drive every later decision, and most of them go into the report.\n\n| Fact | Where to look |\n|---|---|\n| Product name and production domain | README, deploy config (`vercel.json`, `fly.toml`, `netlify.toml`, `wrangler.toml`), site URL env vars, canonical tags, sitemap config |\n| Every host the product serves pages from | `www.`, `app.`, docs subdomains, redirect config |\n| Web framework | `package.json` and config files. The table in step 5 maps them |\n| Where the HTML `<head>` is rendered | Root layout, `index.html`, document template |\n| Sign-in | Auth libraries (Auth.js, Clerk, Supabase, Lucia, Better Auth, Passport), `/login` and `/signup` routes, a current-user hook |\n| Money | Checkout, pricing, plans, trials, payment webhooks |\n| Onboarding and activation | First-run screens, \"create your first ...\" flows, invites |\n| Pages with private data | Account, settings, billing, admin, messages, documents, and any health, legal or financial records |\n| Consent tool | A cookie banner or consent manager (OneTrust, Cookiebot, Osano, Klaro, a custom one) |\n| Content Security Policy | Search code, config and headers files for `Content-Security-Policy` |\n| Server runtime and host | API routes, server framework (Express, Celsian, Hono, Next.js route handlers), Workers, Fly or Vercel config |\n| Env files | `.env`, `.env.local`, `.env.example`, and what `.gitignore` ignores |\n| App shells | `capacitor.config.*`, `ionic.config.json`, Cordova `config.xml`, `src-tauri/`, an Electron main process, React Native or Expo |\n| Existing analytics | Google Analytics, Plausible, PostHog, Segment or Vercel Analytics calls |\n\nExisting analytics: leave them in place unless asked to remove them. Their event lists are good candidates for your named events and goals.\n\n### Plan the journeys\n\nBefore you add a single event, decide what this product exists to get people to do, and which paths lead there. Steps 7 to 9 build exactly this plan, so every event, goal and funnel has a reason.\n\n1. **Say the product's job in one sentence:** who arrives, and what a good visit ends with. Read the home page, the pricing page, the main call to action, and the signup or checkout code.\n2. **List the outcomes, most valuable first.** Usually 1 to 3: money (purchase, upgrade, trial), an account (signup), a lead (contact, demo booked), or activation, the first time someone gets value (created a project, sent an invite, published a page).\n3. **Write one journey per top outcome, at most 3.** A journey is the path a person takes from arriving to the outcome, in 3 to 5 steps they would recognize: where it really starts (a landing page, pricing, a docs page), the moment they commit (opened signup, started checkout), and the outcome itself.\n4. **Decide how each step is seen.** A page is a route step (`route:/pricing`). An action inside a page, or something the server confirms, is a named event you add in step 7 (`event:signup.start`). The outcome becomes a goal in step 8, and the journey becomes a saved funnel in step 9.\n5. **Check the routes.** Read the router for each route step. Ids and personal segments are stored as `:id` or `:redacted`, so `/projects/42/settings` is stored as `/projects/:id/settings`.\n\nStart from the row that fits, then change it to match what the code really does:\n\n| Product | Outcome | Journey |\n|---|---|---|\n| App or SaaS | Signup, then activation | `route:/` \u2192 `route:/pricing` \u2192 `event:signup.start` \u2192 `goal:<Signup id>` \u2192 `goal:<Activated id>` |\n| Store | Purchase | `route:/products/:id` \u2192 `event:cart.add` \u2192 `event:checkout.start` \u2192 `goal:<Purchase id>` |\n| Services or lead generation | Contact or booking | `route:/` \u2192 `route:/services` \u2192 `event:contact.open` \u2192 `goal:<Contact sent id>` |\n| Docs or open source | Adoption | `route:/docs` \u2192 `route:/docs/getting-started` \u2192 `goal:<Install copied id>`, a goal on `install.copy` |\n| Marketing site for an app on another host | A visitor heads to sign up | `route:/` \u2192 `route:/pricing` \u2192 `goal:<Signup click id>`, a goal on `cta.signup` |\n\n**A journey ends at the edge of its host.** A journey-mode session lives in one tab's session storage, which browsers keep apart for each host. A visit that moves from `example.com` to `app.example.com` becomes two sessions. Give each host its own funnel: end the marketing site's funnel on a goal for a named event fired when the signup link is clicked (`lf('track', 'cta.signup')`), and start the app's funnel at its signup page.\n\nKeep the plan small and useful:\n\n- Pick what someone running the product would ask about on a Monday: \"Of the people who saw pricing, how many started a trial?\" If nobody would act on a number, do not build it.\n- At most 3 funnels and 5 goals per project. Every funnel ends on a goal.\n- A step the code cannot see yet (no route, no event) needs an event in step 7. Note it in the plan.\n- A server-confirmed outcome joins the journey only when the server event carries the browser's correlation id (step 12). Plan that, or end the funnel on the browser event just before it.\n- Aggregate mode has no sessions, so it has no funnels. Plan goals only, and say so in the report.\n\nWrite the plan into the report (step 17) as a table before you build anything:\n\n| Journey | Outcome goal | Steps, and how each is seen |\n|---|---|---|\n| Signup | Signup (server) | `/pricing` route, `signup.start` event on the button, `signup.completed` server event with the browser's correlation id |\n\n## 4. Workspace, project, mode and consent\n\n### Workspace\n\n```sh\nlittlefriend workspaces list\n```\n\n| Situation | Workspace |\n|---|---|\n| The person who asked named one | That one |\n| The list shows one workspace | That one |\n| Several, and nobody named one | Stop and ask. Never guess |\n| The one you need is missing | Stop and ask. The CLI does not create workspaces |\n\n### Reuse before you create\n\n```sh\nlittlefriend projects list --workspace <workspace>\n```\n\nIf a project already has the product's domain, reuse it: note its id and site key, and run `littlefriend projects get` to see its mode and allowed origins. Never create a second project for the same domain.\n\nOne product is one project, even when its marketing pages and app live on different hosts of the same domain. List every host in allowed origins.\n\n### Mode\n\n| The product has | Mode |\n|---|---|\n| Sign-in, signup, onboarding, checkout, or any flow of several steps | `journey` |\n| Only marketing or content pages, and no sign-in | `aggregate` |\n| It is a site you build for a client | `aggregate`, unless the client asked for journeys |\n\n- Aggregate mode keeps counts only, with no session or visitor key anywhere. It still gives views, sources, pages, devices, countries, events and goals.\n- Journey mode adds sessions, timelines, funnels, paths, entry and exit pages, session replay and identify. Its session id lives in the tab's session storage and ends after 30 idle minutes (24 hours at most).\n\n### Consent\n\n| Situation | Do this |\n|---|---|\n| The product already runs a consent tool | Use `--consent required` in step 5. Call `lf('consent', 'journey')` when the tool grants analytics consent, and `lf('consent', 'aggregate')` when it is withdrawn |\n| No consent tool, journey mode | Ask the person who asked whether to ship without a banner. Do not add a consent tool yourself |\n| Aggregate mode | Nothing to wire |\n\nUntil consent arrives, the script counts in aggregate mode. A browser that sends Global Privacy Control always stays in aggregate mode.\n\nIf the product has a privacy page, add this text for journey mode:\n\n> We use Little Friend for privacy-friendly analytics. It sets no cookies and does not store IP addresses. To connect the pages you view in one visit, it keeps a random id in your browser tab's session storage. The visit ends after 30 minutes of inactivity, and the id is gone when you close the tab. If your browser sends Global Privacy Control, your visit is only counted, never connected.\n\nIn aggregate mode, use only the first two sentences. With session replay on (step 11), add:\n\n> Some visits are recorded as a masked copy of the pages you view. Text stays hidden unless we chose to show it, form entries are never recorded, and recordings are deleted within 7 days.\n\nIf there is no privacy page, say so in the report.\n\n### Create the project\n\n```sh\nlittlefriend projects create --name \"Acme\" --domain acme.com --mode journey \\\n --origin https://acme.com --origin https://www.acme.com --origin http://localhost:3000 \\\n --timezone America/New_York\n```\n\nIt prints the project id and the public site key. Keep both for the rest of the guide and the report.\n\n- `--domain`: the production host. Little Friend stores it lowercase, without a scheme, path or `www.`.\n- `--origin`, once per origin: every production host that serves pages, plus the local dev server's origin so you can verify before deploy (step 15). Add app origins from step 14. Preview deploys and any origin not listed get `403` and count nowhere, which is intended. An empty list accepts every origin.\n- `--timezone`: the time zone the business reports in. Without it, reports use UTC.\n- To change origins later, run `littlefriend projects update` and pass the complete list: every origin already there plus the new ones.\n\n## 5. Install the script\n\nPrint the exact code for the stack:\n\n```sh\nlittlefriend snippet --framework next --mode journey\n```\n\nIt prints what to add, with the site key filled in, and the CSP lines the site needs. Add `--consent required` when step 4 said so. Add `--replay` only in step 11.\n\n| The repo has | `--framework` | Where the code goes |\n|---|---|---|\n| `next` | `next` | The root layout: `app/layout.tsx` (App Router) or `pages/_document.tsx` (Pages Router) |\n| `nuxt` | `nuxt` | `nuxt.config.ts` |\n| `@sveltejs/kit` | `sveltekit` | `src/app.html` |\n| `astro` | `astro` | The layout every page shares, often `src/layouts/Layout.astro` |\n| `@remix-run/*`, or React Router with `app/root.tsx` | `remix` | `app/root.tsx` |\n| `react` with Vite or another bundler, no framework | `react` | `index.html` |\n| `vue` with Vite | `vue` | `index.html` |\n| `@angular/core` | `angular` | `src/index.html` |\n| A WordPress theme or plugin | `wordpress` | Where the snippet says |\n| `what-framework`, a Shopify theme, plain HTML, anything else | `html` | The `<head>` every page shares: `index.html`, the document template, `layout/theme.liquid` |\n| A bundled app where you want typed functions | `npm` | The client entry, once at startup |\n\nThe snippet is the source of truth for the code. The last column is where to look first.\n\nRules:\n\n- **One tracker per page.** Use the tag or the npm package (`@littlefriend/tracker`), never both. Load it on every page, once.\n- **Single-page apps need nothing extra.** History API navigations count as page views. If the router does not use the History API, call `lf('page')` after each navigation.\n- **Outside production, mark traffic as test.** Add `data-test` to the tag, or pass `test: true` to `init`, when the build is not production (for example `process.env.NODE_ENV !== 'production'` or `import.meta.env.DEV`). Test traffic shows in the live install check and never in reports.\n- **Name dynamic routes.** Id-like segments already become `:id`. For readable groups, such as `/blog/:slug`, add `data-routes='[\"/blog/:slug\"]'` to the tag, pass `routes` to `init`, or call `lf('route', '/blog/:slug')`. Goals and funnel steps match these routes exactly.\n- **Calls before the script loads.** If the product's own code calls `lf(...)`, add this stub above the tag, so `lf` exists before the deferred script runs. Calls wait in a queue until it loads.\n\n```html\n<script>window.lf=window.lf||function(){(lf.q=lf.q||[]).push(arguments)}</script>\n```\n\nWith the tag in a TypeScript app, declare the global once, for example in `src/lf.d.ts`:\n\n```ts\ndeclare global {\n function lf(command: string, ...args: unknown[]): void;\n}\nexport {};\n```\n\n## 6. Content Security Policy\n\nSkip this step if the product sets no CSP. Otherwise add:\n\n```text\nscript-src https://cdn.littlefriend.io\nconnect-src https://in.littlefriend.io\n```\n\n- With the npm package, the tracker is part of your bundle, so only `connect-src` is needed.\n- The replay script comes from the same host as `lf.js` and sends to the same collector. It needs no other entries.\n- A policy with a nonce or `'strict-dynamic'` ignores host entries for scripts. Give the tag the page's nonce, or use the npm package.\n- Update every copy of the policy: `Content-Security-Policy-Report-Only`, `<meta http-equiv>` tags, and the test files that assert headers.\n- Common places: `next.config.*` `headers()`, `middleware.ts` or `proxy.ts`, `vercel.json`, `netlify.toml`, `_headers`, Helmet options, server header code.\n\n## 7. Custom events\n\nThe script sends these on its own: page views, outbound links, downloads, engaged time and, with `data-scroll`, scroll depth. The `$` prefix is reserved for them.\n\nAdd the named events your journey plan (step 3) needs, and the few the product's key feature needs. Three ways to add more:\n\n```html\n<!-- A named click: sends $click with { \"id\": \"pricing.start_trial\", \"plan\": \"pro\" } -->\n<button data-lf=\"pricing.start_trial\" data-lf-plan=\"pro\">Start free trial</button>\n\n<!-- A tracked form: sends $form_start and $form_submit with { \"id\": \"signup\" } -->\n<form data-lf-form=\"signup\">...</form>\n```\n\n```ts\n// A named event, after the thing really happened\nlf('track', 'signup.completed', { plan: 'pro' });\n\n// A failed form: a short category, never the message\nlf('formError', 'signup', 'validation');\n```\n\nWith npm, import the same names: `track`, `formError`, `page`, `route`, `consent`, `optout`, `optin`.\n\n**Goals and funnel steps match an event name or a route, never a property.** Every `data-lf` click arrives as `$click`, and every tracked form as `$form_submit`. For anything that becomes a goal or a funnel step, fire its own named event with `track`.\n\n### Names\n\n- `area.action`, lowercase: dots between parts, underscores inside a part. `signup.start`, `signup.completed`, `onboarding.project_created`, `checkout.start`, `order.completed`, `plan.upgraded`, `invite.sent`.\n- Past tense for outcomes (`completed`, `created`, `sent`). `start` or `open` for intent.\n- The pattern is `^[a-z][a-z0-9_.:-]{0,63}$`. `data-lf` ids follow the same convention.\n- Fire outcome events after the server call succeeds, never on the button press. Fire intent events on the press.\n- Aim for 5 to 15 named events: the steps of signup, onboarding, activation and checkout, and the product's key feature. Do not name every click.\n\n| Good | Bad | Why the bad one fails |\n|---|---|---|\n| `signup.completed` | `Signup Completed` | Uppercase and spaces are refused |\n| `checkout.start` | `click_button_3` | Says nothing about the product |\n| `invite.sent` with `{ role: 'editor' }` | `invite.sent.jane@acme.com` | Personal data in the name |\n| `order.completed` with `{ plan: 'pro' }` | `order_8812_completed` | An id in the name makes a new event per order |\n| `search.submit` with `{ results: 12 }` | `search.submit` with `{ query: 'knee pain' }` | Typed text is personal data |\n| `project.created` | `$project_created` | `$` is reserved |\n\n### Properties\n\n- Up to 8 per event. Keys are lowercase snake case, up to 32 characters. Values are strings, numbers or booleans. Strings are cut to 64 characters.\n- Use small fixed sets (`plan`, `step`, `method`, `role`, `source`) and counts.\n- A value that looks like an email or a long run of digits is dropped, even when sent on purpose.\n\n## 8. Goals\n\nA goal is a conversion. Create 2 to 5, one for each outcome in your journey plan (step 3), most valuable first.\n\n| Product | Goals to start with |\n|---|---|\n| App or SaaS with sign-in | Signup (server), activation: the first key action, upgrade or trial started (server) |\n| Store | Purchase (server, with value), checkout started |\n| Lead generation | Contact form sent, demo booked, the thank-you page |\n| Content or docs | Newsletter signup, a named event on the link to the product's signup |\n\n- Prefer server-confirmed goals (`--source server`) for money and accounts. Use browser goals for intent, or when there is no backend.\n- A route goal matches the stored route exactly. Check the routes with `littlefriend report pages` first.\n- Create goals before launch. A goal counts from the moment it is saved.\n- Name goals in plain words: \"Signup\", \"Purchase\", \"Demo booked\".\n\n```sh\nlittlefriend goals create --name \"Signup\" --event signup.completed --source server\nlittlefriend goals create --name \"Purchase\" --event order.completed --source server\nlittlefriend goals create --name \"Activated\" --event project.created\nlittlefriend goals create --name \"Contact sent\" --route /contact/thanks\nlittlefriend goals list\n```\n\nKeep the goal ids (`goal_...`) for funnels and the report.\n\n## 9. Funnels\n\nJourney mode only. Save one funnel for each journey in your plan (step 3), 1 to 3 in all, named after the journey.\n\nA funnel has 1 to 8 steps in order. Each step is `route:/path`, `event:<name>` or `goal:<goalId>`. A session reaches a step when it has done the steps before it, in that order.\n\n| Flow | Steps |\n|---|---|\n| Signup | `route:/pricing` \u2192 `event:signup.start` \u2192 `goal:<Signup id>` |\n| Activation | `goal:<Signup id>` \u2192 `event:onboarding.profile_completed` \u2192 `goal:<Activated id>` |\n| Checkout | `route:/pricing` \u2192 `event:checkout.start` \u2192 `goal:<Purchase id>` |\n\n- Keep 3 to 5 steps. Start broad and end on a goal.\n- A server event joins a session only when it carries that session's correlation id or session id (step 12). Without one, a server-only goal as the last step is never reached. Pass a correlation id, or end on a browser event.\n\n```sh\nlittlefriend funnels create --name \"Signup\" \\\n --step route:/pricing --step event:signup.start --step goal:goal_XXXXXXXX\nlittlefriend funnels list\nlittlefriend funnels report fnl_XXXXXXXX --from 2026-10-01 --to 2026-10-07\n```\n\nDates are `YYYY-MM-DD` in the project's time zone.\n\n## 10. Identify signed-in people\n\nJourney mode only. `lf('identify', ...)` is handled by the replay script, so it needs step 11. Without replay, skip this step.\n\n```ts\n// Wherever the app knows the signed-in user: after sign-in and on each page load\nlf('identify', user.id); // your own opaque id, such as usr_8f3k2\n\n// On sign-out\nlf('identify', null);\n```\n\nWith npm: `import { identify } from '@littlefriend/replay'`, then `identify(user.id)` and `identify(null)`.\n\n- The ref is 1 to 64 letters, digits, `_`, `.`, `:` and `-`. UUIDs and prefixed ids work.\n- Prefix numeric ids, such as `usr_4821937`. A ref of seven or more bare digits is refused.\n- Never an email, a phone number, a name, a username, or a hash of any of them. Emails are refused.\n- The ref lasts for the journey session in that tab. Call it on each page load while signed in, so the next session carries it too.\n- Before consent it waits in memory, and a Global Privacy Control visitor sends nothing.\n- With a ref, a workspace owner can find one person's sessions and erase their sessions, events and recordings (Settings, Replay, Forget a person).\n\n## 11. Session replay\n\nReplay records a journey session as a masked copy of the page: layout, scrolling, clicks and page changes. Every word is masked until you choose to show it. Form values, checked boxes, chosen options, images, video, audio, canvas and iframes are never recorded.\n\n### Decide\n\n| Situation | Replay |\n|---|---|\n| Journey mode, and the person who asked wants replay | On |\n| Journey mode, and nobody said | Ask first. Each workspace records up to 10 sessions a month free. More needs a card on file (Settings, Billing). Past the allowance, recording stops until a card is added or the month ends. Your own test recordings count too |\n| Pages show health, legal or financial records, or other people's private messages | Off, unless the owner asks |\n| Aggregate mode | Not available |\n\n### Defaults\n\n| Setting | Default |\n|---|---|\n| Sample rate | `100`, unless the site expects more than about 1,000 recorded sessions a day. Then sample down, for example to `25`. Each project stores up to 1 GiB of recordings a day, about 2,000 typical recordings, and refuses more until midnight UTC |\n| Text shown | Nav, header, footer, headings, buttons, labels, table headers, legends, tabs, menu items |\n| Hidden elements | Support chat, third-party widgets, and any region that lists other people's data |\n| Pages never recorded | `/account`, `/settings`, `/billing`, `/checkout`, `/admin`, plus every private-data route from step 3 |\n| Minimum active time | 2 seconds, the built-in default. A shorter recording is dropped unless it has at least 3 clicks |\n\n### Turn it on\n\n```sh\nlittlefriend replay enable --rate 100\nlittlefriend replay set \\\n --unmask nav --unmask header --unmask footer \\\n --unmask h1 --unmask h2 --unmask h3 --unmask h4 \\\n --unmask button --unmask label --unmask th --unmask legend \\\n --unmask \"[role=button]\" --unmask \"[role=tab]\" --unmask \"[role=menuitem]\" \\\n --block .support-chat \\\n --exclude /account --exclude /settings --exclude /billing --exclude /checkout --exclude /admin\nlittlefriend snippet --framework next --mode journey --replay\n```\n\n- `replay set` replaces each list. Pass every item every time.\n- Add the replay code the snippet prints after `lf.js`, with the same site key. With npm, call `startReplay({ site })` from `@littlefriend/replay` after `init`.\n- An excluded route covers itself and every path below it: `/account` covers `/account/billing`. Use plain prefixes. Up to 50 routes, each at most 100 characters, with no query or fragment.\n- Selectors: tag names, classes, ids and attribute selectors with plain values, joined by spaces, `>` or commas. Pseudo-classes, sibling selectors and `*` are refused. Up to 50 per list, 200 characters each.\n\n### Mark the HTML\n\nEmails, phone numbers and card numbers stay masked even in shown text. Names do not. Search the shown regions (header, nav, account menu, buttons) for places that print the signed-in person's name, company, initials or avatar label, and mask them again:\n\n```html\n<header>\n <nav>...</nav>\n <button class=\"account-menu\" data-lf-mask>{user.name}</button>\n</header>\n\n<aside class=\"support-chat\" data-lf-block>...</aside>\n```\n\n- `data-lf-mask` masks text again inside a shown region.\n- `data-lf-block` leaves an element out, drawn as an empty box. Use it for regions whose shape alone says too much, and on sensitive parts of routes that draw their page late after navigation.\n- `data-lf-unmask` shows the text of one element, for plain product copy such as plan names and prices.\n\n`littlefriend replay get` shows the settings, and says the replay script is installed once the first recording arrives.\n\n## 12. Server events\n\nSend outcomes the backend confirms: account created, payment succeeded, plan changed. Default: yes for every product with sign-in or payments.\n\n### The key\n\n```sh\ngit check-ignore -q .env.local && echo ignored\nlittlefriend keys create --kind server --label \"acme server\" --env-file .env.local --env-name LF_SERVER_KEY\n```\n\n- Use the env file the framework loads (`.env.local` for Next.js, often `.env` elsewhere). It must be ignored by git. If `git check-ignore` prints nothing, add the file to `.gitignore` first.\n- With `--env-file`, the CLI appends `LF_SERVER_KEY=...` only if the name is not set yet, and never prints the secret.\n- Add `LF_SERVER_KEY=` with no value to `.env.example`, if the repo has one.\n- Production needs the same variable in the host's secret store. Copy it from the env file without printing it, if you have the host's CLI and the repo's rules allow it. Otherwise list it under \"Needs a person\". Check each host CLI's `--help` before you run these:\n\n```sh\n# Vercel\ngrep '^LF_SERVER_KEY=' .env.local | cut -d= -f2- | tr -d '\\n' | vercel env add LF_SERVER_KEY production\n\n# Fly\ngrep '^LF_SERVER_KEY=' .env | fly secrets import -a <app>\n\n# Cloudflare Workers (the edge key from step 13)\ngrep '^LF_EDGE_KEY=' .env | cut -d= -f2- | tr -d '\\n' | wrangler secret put LF_EDGE_KEY\n```\n\n### Send from Node.js\n\nInstall `@littlefriend/node` with the repo's package manager (Node 18.17 or later). Create one client at module scope:\n\n```ts\n// lib/little-friend.ts\nimport { LittleFriend } from '@littlefriend/node';\n\nconst key = process.env.LF_SERVER_KEY;\nexport const lf = key ? new LittleFriend({ key }) : null;\n```\n\nThen send each outcome where the backend confirms it: after the account row is written, in the payment webhook, after the plan change commits.\n\n```ts\nimport { createHash } from 'node:crypto';\nimport { lf } from './lib/little-friend';\n\nlf?.track({\n // 8 to 32 letters, digits, _ or -. The same id on a retry counts once.\n id: createHash('sha256').update(order.id).digest('base64url').slice(0, 32),\n name: 'order.completed',\n props: { plan: order.plan },\n value: { amount: order.totalCents, currency: 'USD' }, // minor units\n correlationId: order.checkoutRef, // optional, see below\n});\n\nawait lf?.flush(); // in a serverless function, before it returns\n```\n\n- `track` never throws. A bad event goes to `onError`, which logs a warning by default.\n- In serverless functions (Vercel, Next.js route handlers and server actions), `await lf.flush()` before returning. In a long-running server, `await lf.shutdown()` when the process exits.\n- Derive `id` from the record, as above. Raw UUIDs (36 characters) and short numeric ids fail the id rule.\n- Other languages: POST `{ \"v\": 1, \"events\": [ ... ] }` to `https://in.littlefriend.io/v1/server` with `Authorization: Bearer $LF_SERVER_KEY`. Retry `429` and `503` after `Retry-After`. Never retry a `400`. Details: https://littlefriend.io/docs/goals#http\n\n### Join server outcomes to journeys\n\nIn journey mode, a server event joins the visitor's session when it carries the same correlation id as a browser event:\n\n```ts\n// Browser, when checkout starts: a random id for this attempt, sent to the server with the form\nconst checkoutRef = crypto.randomUUID();\nlf('track', 'checkout.start', { plan: 'pro' }, checkoutRef);\n```\n\nThe server stores `checkoutRef` with the order and passes it as `correlationId`. A correlation id is 16 to 64 letters, digits, `_` or `-`, and never only digits and dashes. Aggregate mode drops it.\n\n## 13. Crawlers and AI agents: the door and a log drain\n\n`lf.js` sees only browsers. Crawlers and AI agents rarely run JavaScript, so Little Friend needs the requests themselves.\n\n| Where the product runs | See agents with | Door |\n|---|---|---|\n| Vercel, any framework, static too | A Vercel log drain | Next.js: `nextProxy` in `proxy.ts` (Next.js 16) or `middleware.ts` (Next.js 15). Other frameworks: skip it and say so |\n| Next.js on another host | `wrapFetch(edge, handler, { waitUntil })` in route handlers, or `nodeMiddleware(edge)` in a custom Node server | `nextProxy`, as on Vercel |\n| Cloudflare Workers | `withLittleFriend` from `@littlefriend/edge` | Add `door: createDoor` |\n| Node with Express, Connect or `node:http` | `nodeMiddleware(edge)` | `nodeDoor(createDoor(lf), createEdge(lf))` in its place |\n| Bun, Deno, Hono or another fetch handler | `wrapFetch(edge, handler)` | `wrapFetch(edge, handler, { door })` |\n| Celsian | `observeCelsian(app, edge)` | None built in. Skip it and say so |\n| Static hosting without functions, not on Vercel | Only with a Worker in front | Only with a Worker in front |\n\nA request both a drain and the edge package report is counted once.\n\n### A Vercel log drain\n\n```sh\nlittlefriend drains list\nlittlefriend drains create --provider vercel\n```\n\n- Check `drains list` first. Reuse an active drain.\n- `drains create` prints the endpoint, the header and the signing secret, once. Create it only when you can paste them into Vercel in the same sitting: in the team's settings, add a log drain for this project only, all sources, production, and 100% sampling. Drains need a Vercel Pro or Enterprise team.\n- If you cannot reach Vercel's settings, do not create the drain. List it under \"Needs a person\": they can do both halves on the dashboard's Agents page, with Add a Vercel log drain.\n- A lost secret: `littlefriend drains rotate <id>` issues a new pair and keeps the old one working for 24 hours.\n- Within minutes of traffic, the Coverage card on the Agents page shows the drain as Live.\n\n### `@littlefriend/edge` and the door\n\nUse this when the product has code that runs on every request. The door comes with `@littlefriend/edge` 0.2.0 and later.\n\n1. Create an edge key into the env file, as in step 12:\n\n```sh\nlittlefriend keys create --kind edge --label \"acme edge\" --env-file .env.local --env-name LF_EDGE_KEY\n```\n\n2. Install `@littlefriend/edge` and wire it for the runtime in the table above. The exact code for each runtime is on https://littlefriend.io/docs/agents (sections Any host and The door). For Next.js 16:\n\n```ts\n// proxy.ts\nimport { createDoor, createEdge, nextProxy } from '@littlefriend/edge';\nimport { NextResponse } from 'next/server';\n\nconst lf = { key: process.env.LF_EDGE_KEY!, ipHeader: 'x-forwarded-for' };\nexport const proxy = nextProxy(createDoor(lf), NextResponse, createEdge(lf));\nexport const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };\n```\n\n3. Set `ipHeader` to the header the host sets to the client address: `x-forwarded-for` on Vercel, `cf-connecting-ip` on Cloudflare, `fly-client-ip` on Fly. Without it, no address is sent and operator address checks cannot run.\n\n4. Pick a preset and keep dry run:\n\n```sh\nlittlefriend door set --preset verified_only --mode dry_run\nlittlefriend door simulate --ua \"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)\" --path /blog\n```\n\n| Product | Preset |\n|---|---|\n| A store with `/checkout`, `/cart` or `/account` | `commerce` |\n| An app or API with sign-in | `verified_only` |\n| Content, docs or marketing | `no_training` |\n| Unsure | `open` |\n\n- Dry run decides every request and records what it would have done. It never changes a response.\n- **Never switch the door to live.** The owner does that after reading 7 days of the door's report on the Agents page.\n- A browser always gets in, whatever the rules say.\n- `commerce` matches `/checkout`, `/cart` and `/account`. If the store uses other paths, say so in the report.\n\n## 14. Hybrid and native apps\n\n**Hybrid apps** (Capacitor, Ionic, Cordova, Tauri, Electron, React Native WebView) show web pages. Install the script in the app's web code as in step 5, then add the origin the web view sends to the project's allowed origins, with the complete list:\n\n```sh\nlittlefriend projects update --origin https://acme.com --origin https://www.acme.com \\\n --origin http://localhost:3000 --origin capacitor://localhost --origin https://localhost\n```\n\n| App shell | Origin to add |\n|---|---|\n| Capacitor on iOS | `capacitor://localhost` |\n| Capacitor on Android | `https://localhost` |\n| Ionic with its Cordova web view | `ionic://localhost` on iOS, `http://localhost` on Android |\n| Cordova | `app://localhost` on iOS (scheme preference set to `app`), `https://localhost` on Android |\n| Tauri | `tauri://localhost` on macOS, iOS and Linux. `http://tauri.localhost` on Windows and Android, or `https://tauri.localhost` with `useHttpsScheme` |\n| Electron | The scheme and host the app serves from, such as `app://myapp` |\n| React Native WebView | The origin of the site it loads |\n\n- If the shell's config changes the scheme or host, add the ones it sets.\n- An Electron app that loads pages from files sends no origin. Leave the list empty, or serve the app from its own scheme (for example `electron-serve`, which serves `app://-`).\n- If the repo deliberately skips analytics inside the shell, keep that and say so in the report.\n\n**Native screens** (SwiftUI, UIKit, Jetpack Compose, Flutter, React Native views) have no web page for the script. Send the outcomes they lead to from the backend with `@littlefriend/node` (step 12). The server key never goes inside the app.\n\n## 15. Verify\n\n1. Run the product: the local dev server (its origin is on the list from step 4), or the deployed site.\n2. Load the site in a real browser (Playwright, agent-browser, or a person). `curl` does not run the script. Then run `littlefriend verify --since 10 --wait 120`. It exits 0 once an accepted event from the last 10 minutes shows up, and prints its time, route and source. It exits 1 when none arrives in time. On a project that already has traffic, keep `--since` short, so an older event cannot pass for yours.\n3. In the browser's network panel, `POST https://in.littlefriend.io/v1/e` answers `202` with `{\"accepted\": n, \"dropped\": 0}`. The console shows no CSP errors.\n4. Click through each flow with a named event and confirm each name in the request bodies. Any `dropped` above 0 means a name or property broke a rule. The live install check (Settings, Install) shows every field kept, every property dropped, and why.\n5. Replay: `GET https://in.littlefriend.io/v1/r/config` returns `\"on\": true`, and `POST /v1/r` answers `202`. Then `littlefriend replay get` reads installed.\n6. Server events: run the flow that sends one. `verify` shows source `server`, or `await lf.flush()` resolves with `accepted: 1`.\n7. Agents: `littlefriend door get` shows the policy and its mode. On the dashboard's Agents page, the Coverage card shows the drain as Live, and the door page shows that the door has read its rules.\n8. A few minutes later: `littlefriend report overview` and `littlefriend report goals` for a sanity check.\n9. Walk each journey in your plan once, in one tab, step by step, on a page without `data-test` (test events stay out of reports). A few minutes later, `littlefriend funnels report fnl_XXXXXXXX` shows that session at every step. A step at 0 means its route or event name does not match what arrives: compare it with `littlefriend report pages` and the request bodies.\n\nVerify on production after the deploy too, without `data-test`.\n\n| Symptom | Cause |\n|---|---|\n| No request to `in.littlefriend.io` | CSP blocks the script, or the tag never rendered |\n| `403` `origin_not_allowed` | The page's origin is not in allowed origins |\n| `400` | Wrong or malformed site key, or a malformed batch |\n| Sessions unavailable, funnels empty | The project or the snippet is not in journey mode. Both must be |\n| Replay `403` `replay_off`, `session_in_aggregate_mode` or `route_excluded` | Replay is off, the project is in aggregate mode, or the page is excluded |\n| Replay `402` `replay_card_required` | The workspace used its free recordings this month and has no card on file |\n| Replay `429` `quota_exceeded` | The project stored its 1 GiB of recordings for the day, or one address used its share of that. It clears at midnight UTC. If it happens often, lower the sample rate |\n| Server events `rejected` in `onError` | Wrong kind of key, or a revoked one |\n\n## 16. Launch checklist\n\n- [ ] One project for the domain, in the right workspace, in the right mode.\n- [ ] Allowed origins list every production host, every app origin and the dev origin.\n- [ ] The script is on every page, once, with `data-test` outside production.\n- [ ] CSP updated, including report-only copies and header tests.\n- [ ] Every goal and funnel step has its own named event. No personal data in names, properties or routes.\n- [ ] The journey plan is written down, with one goal per outcome.\n- [ ] 2 to 5 goals and, in journey mode, 1 to 3 funnels saved, one per journey in the plan.\n- [ ] Each journey was walked once and shows at every step of its funnel report.\n- [ ] `identify` sends an opaque id, and `null` on sign-out (replay only).\n- [ ] Replay: shown regions checked for names, private routes excluded, widgets blocked.\n- [ ] Server key in an ignored env file and in the host's secrets. Serverless code flushes.\n- [ ] Door in dry run where the runtime allows it. Drain connected on Vercel.\n- [ ] Privacy page text added.\n- [ ] `littlefriend verify` exits 0 on production, and batches show `\"dropped\": 0`.\n- [ ] No secret in your changes: `git diff <base branch> | grep -E 'lf[ser]_[A-Za-z0-9]'` prints nothing.\n- [ ] Report sent.\n\n## 17. Report back\n\nEnd with this report, filled in. Use \"skipped\" with a reason where a step did not apply. Never include a secret.\n\n```markdown\n## Little Friend setup: <product>\n\n- Repo and branch:\n- Domain and hosts:\n- Workspace:\n- Project id and site key (lf_):\n- Mode and consent:\n- Allowed origins:\n- Snippet: --framework <name>, in <file>\n- CSP: <files changed, or none needed>\n- Journey plan: <the table from step 3: journey, outcome goal, steps and how each is seen>\n- Named events: <name, where it fires>\n- Goals: <id, name, match>\n- Funnels: <id, name, steps>\n- Identify: <where it is called, what id it sends, or skipped>\n- Replay: <on or off, rate, shown, hidden, excluded routes>\n- Server events: <events, files, env var names, host secrets set or not>\n- Agents: <drain id and status, door runtime, preset and mode, or skipped>\n- Hybrid or native: <origins added, backend events, or none>\n- Privacy page: <updated, or no privacy page>\n- Verify: <time, route and source that verify printed, local and production>\n- Skipped, and why:\n- Needs a person: <drains to connect, secrets to set, questions>\n- Guide or CLI problems found:\n```\n", bundled: true } : { text: GUIDE_PLACEHOLDER, bundled: false };
3627
+ return true ? { text: "# Set up Little Friend in a product: guide for coding agents\n\nYou are a coding agent working in a product's repository. This guide takes you from nothing to a verified Little Friend install: the project, the script, events, goals, funnels, session replay, server events and AI agent visibility. It ends with a report for the person who asked.\n\nWork through the steps in order. Each decision has a default: use it unless the repo or the person who asked says otherwise. When a step does not apply, skip it and say why in the report.\n\n## Contents\n\n0. [Conventions](#0-conventions)\n1. [What Little Friend is, and the rules](#1-what-little-friend-is-and-the-rules)\n2. [Sign in once per machine](#2-sign-in-once-per-machine)\n3. [Read the repo and plan the journeys](#3-read-the-repo-and-plan-the-journeys)\n4. [Workspace, project, mode and consent](#4-workspace-project-mode-and-consent)\n5. [Install the script](#5-install-the-script)\n6. [Content Security Policy](#6-content-security-policy)\n7. [Custom events](#7-custom-events)\n8. [Goals](#8-goals)\n9. [Funnels](#9-funnels)\n10. [Identify signed-in people](#10-identify-signed-in-people)\n11. [Session replay](#11-session-replay)\n12. [Server events](#12-server-events)\n13. [Crawlers and AI agents: the door and a log drain](#13-crawlers-and-ai-agents-the-door-and-a-log-drain)\n14. [Hybrid and native apps](#14-hybrid-and-native-apps)\n15. [Verify](#15-verify)\n16. [Launch checklist](#16-launch-checklist)\n17. [Report back](#17-report-back)\n\n## 0. Conventions\n\n- **The CLI.** Run it as `npx --yes @littlefriend/cli <command>`. It needs Node 22 or later. This guide writes `littlefriend <command>` for short: type the full `npx` form, unless the CLI is installed globally.\n- **Scope flags.** Commands that work on a project take `--project <id>`, and commands that work in a workspace take `--workspace <id or name>`. Agent shells often drop environment variables between calls, so pass the flags on every command. The examples below leave them out to stay short. The CLI also reads `LITTLEFRIEND_PROJECT` and `LITTLEFRIEND_WORKSPACE`.\n- **Reading output.** Add `--json` when you need a value from the output, such as an id. Errors go to stderr with a non-zero exit code. With `--json`, they are also printed on stdout as `{ \"error\": { \"code\", \"message\" } }`.\n- **Secrets.** Server keys (`lfs_`), edge keys (`lfe_`), read keys (`lfr_`) and drain secrets are shown once. Never put them in your messages, logs, commits or the report. Write keys to an env file with `--write-env` (step 12). The public site key (`lf_`) is fine to commit.\n- **Credentials.** `~/.littlefriend/credentials.json` holds the sign-in. Never read it, print it or copy it into a repo.\n- **Deletes.** Never delete or revoke a project, key, goal, funnel or drain you did not create in this session.\n- **Repo rules win.** Follow the product repo's own rules for branches, commits, dependencies and deploys.\n- **The code wins.** `littlefriend <command> --help` shows a command's flags. If this guide and the CLI disagree, trust the CLI and say so in the report.\n\n## 1. What Little Friend is, and the rules\n\nLittle Friend is privacy-first web analytics (littlefriend.io). A small script, `lf.js`, counts page views, sources, clicks and named events without cookies. Server events count outcomes your backend confirms. Log drains and `@littlefriend/edge` show crawlers and AI agents, and the door decides which agents get in. Reports live at app.littlefriend.io.\n\nThe product must keep these rules. If a task would break one, stop and ask.\n\n1. **No personal data** in event names, property keys or values, routes, ids, or goal and funnel names. Personal data means emails, names, usernames, phone numbers, addresses, account or card numbers, text a person typed, and anything else that points to one person.\n2. **`identify` takes an opaque id** from the product's own database, such as `usr_8f3k2`. Never an email, a phone number, a name, or a hash of any of them.\n3. **No typed text.** Never send form values, search queries or error messages as properties. Report a failed form with a short category, such as `validation` or `server`.\n4. **Secret keys stay on the server.** Never put `lfs_`, `lfe_` or `lfr_` keys in client code, or in a variable a bundler exposes to the browser (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`, `EXPO_PUBLIC_`).\n5. **No extra tracking.** Do not add cookies, storage or fingerprinting for analytics. Do not work around Global Privacy Control or a visitor's opt-out: the script already honors both.\n6. **Unknown stays unknown.** Never combine an IP address with device traits to guess who someone is.\n\nLittle Friend also strips query strings and fragments (except allowlisted `utm_*` values), turns id-like and personal-looking path segments into `:id` or `:redacted`, drops property values that look like an email or a long number, and never reads form values or page text. Treat that as a safety net. Rule 1 still applies.\n\n## 2. Sign in once per machine\n\n```sh\nlittlefriend whoami\n```\n\nIf it names an account, go to step 3. Otherwise:\n\n```sh\nlittlefriend login\n```\n\nIt opens a browser, prints the URL too, and waits up to 5 minutes. The person who owns the Little Friend account approves the login in the browser. Tell them a sign-in page is waiting, then wait. If nobody approves in time, stop and ask. Do not retry in a loop.\n\n- The CLI acts with that person's role in each workspace. Creating projects, keys, goals, funnels and settings needs the editor or owner role.\n- In CI, set `LITTLEFRIEND_TOKEN` to an access token instead of signing in.\n- `littlefriend logout` revokes the sign-in and deletes the local credentials.\n\n## 3. Read the repo and plan the journeys\n\nCollect these facts before you create anything. They drive every later decision, and most of them go into the report.\n\n| Fact | Where to look |\n|---|---|\n| Product name and production domain | README, deploy config (`vercel.json`, `fly.toml`, `netlify.toml`, `wrangler.toml`), site URL env vars, canonical tags, sitemap config |\n| Every host the product serves pages from | `www.`, `app.`, docs subdomains, redirect config |\n| Web framework | `package.json` and config files. The table in step 5 maps them |\n| Where the HTML `<head>` is rendered | Root layout, `index.html`, document template |\n| Sign-in | Auth libraries (Auth.js, Clerk, Supabase, Lucia, Better Auth, Passport), `/login` and `/signup` routes, a current-user hook |\n| Money | Checkout, pricing, plans, trials, payment webhooks |\n| Onboarding and activation | First-run screens, \"create your first ...\" flows, invites |\n| Pages with private data | Account, settings, billing, admin, messages, documents, and any health, legal or financial records |\n| Consent tool | A cookie banner or consent manager (OneTrust, Cookiebot, Osano, Klaro, a custom one) |\n| Content Security Policy | Search code, config and headers files for `Content-Security-Policy` |\n| Server runtime and host | API routes, server framework (Express, Celsian, Hono, Next.js route handlers), Workers, Fly or Vercel config |\n| Env files | `.env`, `.env.local`, `.env.example`, and what `.gitignore` ignores |\n| App shells | `capacitor.config.*`, `ionic.config.json`, Cordova `config.xml`, `src-tauri/`, an Electron main process, React Native or Expo |\n| Existing analytics | Google Analytics, Plausible, PostHog, Segment or Vercel Analytics calls |\n\nExisting analytics: leave them in place unless asked to remove them. Their event lists are good candidates for your named events and goals.\n\n### Plan the journeys\n\nBefore you add a single event, decide what this product exists to get people to do, and which paths lead there. Steps 7 to 9 build exactly this plan, so every event, goal and funnel has a reason.\n\n1. **Say the product's job in one sentence:** who arrives, and what a good visit ends with. Read the home page, the pricing page, the main call to action, and the signup or checkout code.\n2. **List the outcomes, most valuable first.** Usually 1 to 3: money (purchase, upgrade, trial), an account (signup), a lead (contact, demo booked), or activation, the first time someone gets value (created a project, sent an invite, published a page).\n3. **Write one journey per top outcome, at most 3.** A journey is the path a person takes from arriving to the outcome, in 3 to 5 steps they would recognize: where it really starts (a landing page, pricing, a docs page), the moment they commit (opened signup, started checkout), and the outcome itself.\n4. **Decide how each step is seen.** A page is a route step (`route:/pricing`). An action inside a page, or something the server confirms, is a named event you add in step 7 (`event:signup.start`). The outcome becomes a goal in step 8, and the journey becomes a saved funnel in step 9.\n5. **Check the routes.** Read the router for each route step. Ids and personal segments are stored as `:id` or `:redacted`, so `/projects/42/settings` is stored as `/projects/:id/settings`.\n\nStart from the row that fits, then change it to match what the code really does:\n\n| Product | Outcome | Journey |\n|---|---|---|\n| App or SaaS | Signup, then activation | `route:/` \u2192 `route:/pricing` \u2192 `event:signup.start` \u2192 `goal:<Signup id>` \u2192 `goal:<Activated id>` |\n| Store | Purchase | `route:/products/:id` \u2192 `event:cart.add` \u2192 `event:checkout.start` \u2192 `goal:<Purchase id>` |\n| Services or lead generation | Contact or booking | `route:/` \u2192 `route:/services` \u2192 `event:contact.open` \u2192 `goal:<Contact sent id>` |\n| Docs or open source | Adoption | `route:/docs` \u2192 `route:/docs/getting-started` \u2192 `goal:<Install copied id>`, a goal on `install.copy` |\n| Marketing site for an app on another host | A visitor heads to sign up | `route:/` \u2192 `route:/pricing` \u2192 `goal:<Signup click id>`, a goal on `cta.signup` |\n\n**A journey ends at the edge of its host.** A journey-mode session lives in one tab's session storage, which browsers keep apart for each host. A visit that moves from `example.com` to `app.example.com` becomes two sessions. Give each host its own funnel: end the marketing site's funnel on a goal for a named event fired when the signup link is clicked (`lf('track', 'cta.signup')`), and start the app's funnel at its signup page.\n\nKeep the plan small and useful:\n\n- Pick what someone running the product would ask about on a Monday: \"Of the people who saw pricing, how many started a trial?\" If nobody would act on a number, do not build it.\n- At most 3 funnels and 5 goals per project. Every funnel ends on a goal.\n- A step the code cannot see yet (no route, no event) needs an event in step 7. Note it in the plan.\n- A server-confirmed outcome joins the journey only when the server event carries the browser's correlation id (step 12). Plan that, or end the funnel on the browser event just before it.\n- Aggregate mode has no sessions, so it has no funnels. Plan goals only, and say so in the report.\n\nWrite the plan into the report (step 17) as a table before you build anything:\n\n| Journey | Outcome goal | Steps, and how each is seen |\n|---|---|---|\n| Signup | Signup (server) | `/pricing` route, `signup.start` event on the button, `signup.completed` server event with the browser's correlation id |\n\n## 4. Workspace, project, mode and consent\n\n### Workspace\n\n```sh\nlittlefriend workspaces list\n```\n\n| Situation | Workspace |\n|---|---|\n| The person who asked named one | That one |\n| The list shows one workspace | That one |\n| Several, and nobody named one | Stop and ask. Never guess |\n| The one you need is missing | Stop and ask. The CLI does not create workspaces |\n\n### Reuse before you create\n\n```sh\nlittlefriend projects list --workspace <workspace>\n```\n\nIf a project already has the product's domain, reuse it: note its id and site key, and run `littlefriend projects get` to see its mode and allowed origins. Never create a second project for the same domain.\n\nOne product is one project, even when its marketing pages and app live on different hosts of the same domain. List every host in allowed origins.\n\n### Mode\n\n| The product has | Mode |\n|---|---|\n| Sign-in, signup, onboarding, checkout, or any flow of several steps | `journey` |\n| Only marketing or content pages, and no sign-in | `aggregate` |\n| It is a site you build for a client | `aggregate`, unless the client asked for journeys |\n\n- Aggregate mode keeps counts only, with no session or visitor key anywhere. It still gives views, sources, pages, devices, countries, events and goals.\n- Journey mode adds sessions, timelines, funnels, paths, entry and exit pages, session replay and identify. Its session id lives in the tab's session storage and ends after 30 idle minutes (24 hours at most).\n\n### Consent\n\n| Situation | Do this |\n|---|---|\n| The product already runs a consent tool | Use `--consent required` in step 5. Call `lf('consent', 'journey')` when the tool grants analytics consent, and `lf('consent', 'aggregate')` when it is withdrawn |\n| No consent tool, journey mode | Ask the person who asked whether to ship without a banner. Do not add a consent tool yourself |\n| Aggregate mode | Nothing to wire |\n\nUntil consent arrives, the script counts in aggregate mode. A browser that sends Global Privacy Control always stays in aggregate mode.\n\nIf the product has a privacy page, add this text for journey mode:\n\n> We use Little Friend for privacy-friendly analytics. It sets no cookies and does not store IP addresses. To connect the pages you view in one visit, it keeps a random id in your browser tab's session storage. The visit ends after 30 minutes of inactivity, and the id is gone when you close the tab. If your browser sends Global Privacy Control, your visit is only counted, never connected.\n\nIn aggregate mode, use only the first two sentences. With session replay on (step 11), add:\n\n> Some visits are recorded as a masked copy of the pages you view. Text stays hidden unless we chose to show it, form entries are never recorded, and recordings are deleted within 7 days.\n\nIf there is no privacy page, say so in the report.\n\n### Create the project\n\n```sh\nlittlefriend projects create --name \"Acme\" --domain acme.com --mode journey \\\n --origin https://acme.com --origin https://www.acme.com --origin http://localhost:3000 \\\n --timezone America/New_York\n```\n\nIt prints the project id and the public site key. Keep both for the rest of the guide and the report.\n\n- `--domain`: the production host. Little Friend stores it lowercase, without a scheme, path or `www.`.\n- `--origin`, once per origin: every production host that serves pages, plus the local dev server's origin so you can verify before deploy (step 15). Add app origins from step 14. Preview deploys and any origin not listed get `403` and count nowhere, which is intended. An empty list accepts every origin.\n- `--timezone`: the time zone the business reports in. Without it, reports use UTC.\n- To change origins later, run `littlefriend projects update` and pass the complete list: every origin already there plus the new ones.\n\n## 5. Install the script\n\nPrint the exact code for the stack:\n\n```sh\nlittlefriend snippet --framework next --mode journey\n```\n\nIt prints what to add, with the site key filled in, and the CSP lines the site needs. Add `--consent required` when step 4 said so. Add `--replay` only in step 11.\n\n| The repo has | `--framework` | Where the code goes |\n|---|---|---|\n| `next` | `next` | The root layout: `app/layout.tsx` (App Router) or `pages/_document.tsx` (Pages Router) |\n| `nuxt` | `nuxt` | `nuxt.config.ts` |\n| `@sveltejs/kit` | `sveltekit` | `src/app.html` |\n| `astro` | `astro` | The layout every page shares, often `src/layouts/Layout.astro` |\n| `@remix-run/*`, or React Router with `app/root.tsx` | `remix` | `app/root.tsx` |\n| `react` with Vite or another bundler, no framework | `react` | `index.html` |\n| `vue` with Vite | `vue` | `index.html` |\n| `@angular/core` | `angular` | `src/index.html` |\n| A WordPress theme or plugin | `wordpress` | Where the snippet says |\n| `what-framework`, a Shopify theme, plain HTML, anything else | `html` | The `<head>` every page shares: `index.html`, the document template, `layout/theme.liquid` |\n| A bundled app where you want typed functions | `npm` | The client entry, once at startup |\n\nThe snippet is the source of truth for the code. The last column is where to look first.\n\nRules:\n\n- **One tracker per page.** Use the tag or the npm package (`@littlefriend/tracker`), never both. Load it on every page, once.\n- **Single-page apps need nothing extra.** History API navigations count as page views. If the router does not use the History API, call `lf('page')` after each navigation.\n- **Outside production, mark traffic as test.** Add `data-test` to the tag, or pass `test: true` to `init`, when the build is not production (for example `process.env.NODE_ENV !== 'production'` or `import.meta.env.DEV`). Test traffic shows in the live install check and never in reports.\n- **Name dynamic routes.** Id-like segments already become `:id`. For readable groups, such as `/blog/:slug`, add `data-routes='[\"/blog/:slug\"]'` to the tag, pass `routes` to `init`, or call `lf('route', '/blog/:slug')`. Goals and funnel steps match these routes exactly.\n- **Calls before the script loads.** If the product's own code calls `lf(...)`, add this stub above the tag, so `lf` exists before the deferred script runs. Calls wait in a queue until it loads.\n\n```html\n<script>window.lf=window.lf||function(){(lf.q=lf.q||[]).push(arguments)}</script>\n```\n\n- **Secret scanners.** The site key (`lf_`) is public, but gitleaks and similar scanners may flag it as an API key. Add an allowlist entry for that exact key rather than hiding it. A constant named `LITTLE_FRIEND_SITE` rather than `..._KEY` trips fewer rules.\n- **Monorepo build caches.** If the tag only renders in production (for example on `VERCEL_ENV`), declare that variable for the build task in `turbo.json` or the cache's equivalent. Otherwise a preview build and a production build can share one cached output.\n\nWith the tag in a TypeScript app, declare the global once, for example in `src/lf.d.ts`:\n\n```ts\ndeclare global {\n function lf(command: string, ...args: unknown[]): void;\n}\nexport {};\n```\n\n## 6. Content Security Policy\n\nSkip this step if the product sets no CSP. Otherwise add:\n\n```text\nscript-src https://cdn.littlefriend.io\nconnect-src https://in.littlefriend.io\n```\n\n- With the npm package, the tracker is part of your bundle, so only `connect-src` is needed.\n- The replay script comes from the same host as `lf.js` and sends to the same collector. It needs no other entries.\n- A policy with a nonce or `'strict-dynamic'` ignores host entries for scripts. Give the tag the page's nonce, or use the npm package.\n- Update every copy of the policy: `Content-Security-Policy-Report-Only`, `<meta http-equiv>` tags, and the test files that assert headers.\n- Common places: `next.config.*` `headers()`, `middleware.ts` or `proxy.ts`, `vercel.json`, `netlify.toml`, `_headers`, Helmet options, server header code.\n\n## 7. Custom events\n\nThe script sends these on its own: page views, outbound links, downloads, engaged time and, with `data-scroll`, scroll depth. The `$` prefix is reserved for them.\n\nAdd the named events your journey plan (step 3) needs, and the few the product's key feature needs. Three ways to add more:\n\n```html\n<!-- A named click: sends $click with { \"id\": \"pricing.start_trial\", \"plan\": \"pro\" } -->\n<button data-lf=\"pricing.start_trial\" data-lf-plan=\"pro\">Start free trial</button>\n\n<!-- A tracked form: sends $form_start and $form_submit with { \"id\": \"signup\" } -->\n<form data-lf-form=\"signup\">...</form>\n```\n\n```ts\n// A named event, after the thing really happened\nlf('track', 'signup.completed', { plan: 'pro' });\n\n// A failed form: a short category, never the message\nlf('formError', 'signup', 'validation');\n```\n\nWith npm, import the same names: `track`, `formError`, `page`, `route`, `consent`, `optout`, `optin`.\n\n**Goals and funnel steps match an event name or a route, never a property.** Every `data-lf` click arrives as `$click`, and every tracked form as `$form_submit`. For anything that becomes a goal or a funnel step, fire its own named event with `track`.\n\n### Names\n\n- `area.action`, lowercase: dots between parts, underscores inside a part. `signup.start`, `signup.completed`, `onboarding.project_created`, `checkout.start`, `order.completed`, `plan.upgraded`, `invite.sent`.\n- Past tense for outcomes (`completed`, `created`, `sent`). `start` or `open` for intent.\n- The pattern is `^[a-z][a-z0-9_.:-]{0,63}$`. `data-lf` ids follow the same convention.\n- Fire outcome events after the server call succeeds, never on the button press. Fire intent events on the press.\n- Aim for 5 to 15 named events: the steps of signup, onboarding, activation and checkout, and the product's key feature. Do not name every click.\n\n| Good | Bad | Why the bad one fails |\n|---|---|---|\n| `signup.completed` | `Signup Completed` | Uppercase and spaces are refused |\n| `checkout.start` | `click_button_3` | Says nothing about the product |\n| `invite.sent` with `{ role: 'editor' }` | `invite.sent.jane@acme.com` | Personal data in the name |\n| `order.completed` with `{ plan: 'pro' }` | `order_8812_completed` | An id in the name makes a new event per order |\n| `search.submit` with `{ results: 12 }` | `search.submit` with `{ query: 'knee pain' }` | Typed text is personal data |\n| `project.created` | `$project_created` | `$` is reserved |\n\n### Properties\n\n- Up to 8 per event. Keys are lowercase snake case, up to 32 characters. Values are strings, numbers or booleans. Strings are cut to 64 characters.\n- Use small fixed sets (`plan`, `step`, `method`, `role`, `source`) and counts.\n- A value that looks like an email or a long run of digits is dropped, even when sent on purpose.\n\n## 8. Goals\n\nA goal is a conversion. Create 2 to 5, one for each outcome in your journey plan (step 3), most valuable first.\n\n| Product | Goals to start with |\n|---|---|\n| App or SaaS with sign-in | Signup (server), activation: the first key action, upgrade or trial started (server) |\n| Store | Purchase (server, with value), checkout started |\n| Lead generation | Contact form sent, demo booked, the thank-you page |\n| Content or docs | Newsletter signup, a named event on the link to the product's signup |\n\n- Prefer server-confirmed goals for money and accounts: `--source server --server-confirmed`. `--source server` counts only events your server sends with a secret key, and `--server-confirmed` also marks the goal that way in reports, so a reader knows a browser cannot fake it. Use browser goals for intent, or when there is no backend.\n- A route goal matches the stored route exactly. Check the routes with `littlefriend report pages` first.\n- Create goals before launch. A goal counts from the moment it is saved.\n- Name goals in plain words: \"Signup\", \"Purchase\", \"Demo booked\".\n\n```sh\nlittlefriend goals create --name \"Signup\" --event signup.completed --source server --server-confirmed\nlittlefriend goals create --name \"Purchase\" --event order.completed --source server --server-confirmed\nlittlefriend goals create --name \"Activated\" --event project.created\nlittlefriend goals create --name \"Contact sent\" --route /contact/thanks\nlittlefriend goals list\n```\n\nKeep the goal ids (`goal_...`) for funnels and the report.\n\n## 9. Funnels\n\nJourney mode only. Save one funnel for each journey in your plan (step 3), 1 to 3 in all, named after the journey.\n\nA funnel has 1 to 8 steps in order. Each step is `route:/path`, `event:<name>` or `goal:<goalId>`. A session reaches a step when it has done the steps before it, in that order.\n\n| Flow | Steps |\n|---|---|\n| Signup | `route:/pricing` \u2192 `event:signup.start` \u2192 `goal:<Signup id>` |\n| Activation | `goal:<Signup id>` \u2192 `event:onboarding.profile_completed` \u2192 `goal:<Activated id>` |\n| Checkout | `route:/pricing` \u2192 `event:checkout.start` \u2192 `goal:<Purchase id>` |\n\n- Keep 3 to 5 steps. Start broad and end on a goal.\n- A server event joins a session only when it carries that session's correlation id or session id (step 12). Without one, a server-only goal as the last step is never reached. Pass a correlation id, or end on a browser event.\n\n```sh\nlittlefriend funnels create --name \"Signup\" \\\n --step route:/pricing --step event:signup.start --step goal:goal_XXXXXXXX\nlittlefriend funnels list\nlittlefriend funnels report fnl_XXXXXXXX --from 2026-10-01 --to 2026-10-07\n```\n\nDates are `YYYY-MM-DD` in the project's time zone.\n\n## 10. Identify signed-in people\n\nJourney mode only. `lf('identify', ...)` is handled by the replay script, so it needs step 11. Without replay, skip this step.\n\n```ts\n// Wherever the app knows the signed-in user: after sign-in and on each page load\nlf('identify', user.id); // your own opaque id, such as usr_8f3k2\n\n// On sign-out\nlf('identify', null);\n```\n\nWith npm: `import { identify } from '@littlefriend/replay'`, then `identify(user.id)` and `identify(null)`.\n\n- The ref is 1 to 64 letters, digits, `_`, `.`, `:` and `-`. UUIDs and prefixed ids work.\n- Prefix numeric ids, such as `usr_4821937`. A ref of seven or more bare digits is refused.\n- Never an email, a phone number, a name, a username, or a hash of any of them. Emails are refused.\n- The ref lasts for the journey session in that tab. Call it on each page load while signed in, so the next session carries it too.\n- Before consent it waits in memory, and a Global Privacy Control visitor sends nothing.\n- With a ref, a workspace owner can find one person's sessions and erase their sessions, events and recordings (Settings, Replay, Forget a person).\n\n## 11. Session replay\n\nReplay records a journey session as a masked copy of the page: layout, scrolling, clicks and page changes. Every word is masked until you choose to show it. Form values, checked boxes, chosen options, images, video, audio, canvas and iframes are never recorded.\n\n### Decide\n\n| Situation | Replay |\n|---|---|\n| Journey mode, and the person who asked wants replay | On |\n| Journey mode, and nobody said | Ask first. Each workspace records up to 10 sessions a month free. More needs a card on file (Settings, Billing). Past the allowance, recording stops until a card is added or the month ends. Your own test recordings count too |\n| Pages show health, legal or financial records, or other people's private messages | Off, unless the owner asks |\n| Aggregate mode | Not available |\n\n### Defaults\n\n| Setting | Default |\n|---|---|\n| Sample rate | `100`, unless the site expects more than about 1,000 recorded sessions a day. Then sample down, for example to `25`. Each project stores up to 1 GiB of recordings a day, about 2,000 typical recordings, and refuses more until midnight UTC |\n| Text shown | Nav, header, footer, headings, buttons, labels, table headers, legends, tabs, menu items |\n| Hidden elements | Support chat, third-party widgets, and any region that lists other people's data |\n| Pages never recorded | `/account`, `/settings`, `/billing`, `/checkout`, `/admin`, plus every private-data route from step 3 |\n| Minimum active time | 2 seconds, the built-in default. A shorter recording is dropped unless it has at least 3 clicks |\n\n### Turn it on\n\n```sh\nlittlefriend replay enable --rate 100\nlittlefriend replay set \\\n --unmask nav --unmask header --unmask footer \\\n --unmask h1 --unmask h2 --unmask h3 --unmask h4 \\\n --unmask button --unmask label --unmask th --unmask legend \\\n --unmask \"[role=button]\" --unmask \"[role=tab]\" --unmask \"[role=menuitem]\" \\\n --block .support-chat \\\n --exclude /account --exclude /settings --exclude /billing --exclude /checkout --exclude /admin\nlittlefriend snippet --framework next --mode journey --replay\n```\n\n- `replay set` replaces each list. Pass every item every time.\n- Add the replay code the snippet prints after `lf.js`, with the same site key. With npm, call `startReplay({ site })` from `@littlefriend/replay` after `init`.\n- An excluded route covers itself and every path below it: `/account` covers `/account/billing`. `*` stands for one segment: `/projects/*/settings` covers `/projects/acme/settings` and everything below it. Up to 50 routes, each at most 100 characters, with no query or fragment.\n- Selectors: tag names, classes, ids and attribute selectors with plain values, joined by spaces, `>` or commas. Pseudo-classes, sibling selectors and `*` are refused. Up to 50 per list, 200 characters each.\n\n### Mark the HTML\n\nEmails, phone numbers and card numbers stay masked even in shown text. Names do not. Search the shown regions (header, nav, account menu, buttons) for places that print the signed-in person's name, company, initials or avatar label, and mask them again:\n\n```html\n<header>\n <nav>...</nav>\n <button class=\"account-menu\" data-lf-mask>{user.name}</button>\n</header>\n\n<aside class=\"support-chat\" data-lf-block>...</aside>\n```\n\n- `data-lf-mask` masks text again inside a shown region.\n- `data-lf-block` leaves an element out, drawn as an empty box. Use it for regions whose shape alone says too much, and on sensitive parts of routes that draw their page late after navigation.\n- `data-lf-unmask` shows the text of one element, for plain product copy such as plan names and prices.\n\n`littlefriend replay get` shows the settings, and says the replay script is installed once the first recording arrives.\n\n## 12. Server events\n\nSend outcomes the backend confirms: account created, payment succeeded, plan changed. Default: yes for every product with sign-in or payments.\n\n### The key\n\n```sh\ngit check-ignore -q .env.local && echo ignored\nlittlefriend keys create --kind server --label \"acme server\" --write-env .env.local --env-name LF_SERVER_KEY\n```\n\n- Use the env file the framework loads (`.env.local` for Next.js, often `.env` elsewhere). It must be ignored by git. If `git check-ignore` prints nothing, add the file to `.gitignore` first.\n- With `--write-env`, the CLI appends `LF_SERVER_KEY=...` only if the name is not set yet, and never prints the secret.\n- Add `LF_SERVER_KEY=` with no value to `.env.example`, if the repo has one.\n- Production needs the same variable in the host's secret store. Copy it from the env file without printing it, if you have the host's CLI and the repo's rules allow it. Otherwise list it under \"Needs a person\". Check each host CLI's `--help` before you run these:\n\n```sh\n# Vercel\ngrep '^LF_SERVER_KEY=' .env.local | cut -d= -f2- | tr -d '\\n' | vercel env add LF_SERVER_KEY production\n\n# Fly\ngrep '^LF_SERVER_KEY=' .env | fly secrets import -a <app>\n\n# Cloudflare Workers (the edge key from step 13)\ngrep '^LF_EDGE_KEY=' .env | cut -d= -f2- | tr -d '\\n' | wrangler secret put LF_EDGE_KEY\n```\n\n### Send from Node.js\n\nInstall `@littlefriend/node` with the repo's package manager (Node 18.17 or later). Create one client at module scope:\n\n```ts\n// lib/little-friend.ts\nimport { LittleFriend } from '@littlefriend/node';\n\nconst key = process.env.LF_SERVER_KEY;\nexport const lf = key ? new LittleFriend({ key }) : null;\n```\n\nThen send each outcome where the backend confirms it: after the account row is written, in the payment webhook, after the plan change commits.\n\n```ts\nimport { createHash } from 'node:crypto';\nimport { lf } from './lib/little-friend';\n\nlf?.track({\n // 8 to 32 letters, digits, _ or -. The same id on a retry counts once.\n id: createHash('sha256').update(order.id).digest('base64url').slice(0, 32),\n name: 'order.completed',\n props: { plan: order.plan },\n value: { amount: order.totalCents, currency: 'USD' }, // minor units\n correlationId: order.checkoutRef, // optional, see below\n});\n\nawait lf?.flush(); // in a serverless function, before it returns\n```\n\n- `track` never throws. A bad event goes to `onError`, which logs a warning by default.\n- In serverless functions (Vercel, Next.js route handlers and server actions), `await lf.flush()` before returning. In a long-running server, `await lf.shutdown()` when the process exits.\n- Derive `id` from the record, as above. Raw UUIDs (36 characters) and short numeric ids fail the id rule.\n- Other languages: POST `{ \"v\": 1, \"events\": [ ... ] }` to `https://in.littlefriend.io/v1/server` with `Authorization: Bearer $LF_SERVER_KEY`. Retry `429` and `503` after `Retry-After`. Never retry a `400`. Details: https://littlefriend.io/docs/goals#http\n\n### Join server outcomes to journeys\n\nIn journey mode, a server event joins the visitor's session when it carries the same correlation id as a browser event:\n\n```ts\n// Browser, when checkout starts: a random id for this attempt, sent to the server with the form\nconst checkoutRef = crypto.randomUUID();\nlf('track', 'checkout.start', { plan: 'pro' }, checkoutRef);\n```\n\nThe server stores `checkoutRef` with the order and passes it as `correlationId`. A correlation id is 16 to 64 letters, digits, `_` or `-`, and never only digits and dashes. Aggregate mode drops it.\n\n## 13. Crawlers and AI agents: the door and a log drain\n\n`lf.js` sees only browsers. Crawlers and AI agents rarely run JavaScript, so Little Friend needs the requests themselves.\n\n| Where the product runs | See agents with | Door |\n|---|---|---|\n| Vercel, any framework, static too | A Vercel log drain | Next.js: `nextProxy` in `proxy.ts` (Next.js 16) or `middleware.ts` (Next.js 15). Other frameworks: skip it and say so |\n| Next.js on another host | `wrapFetch(edge, handler, { waitUntil })` in route handlers, or `nodeMiddleware(edge)` in a custom Node server | `nextProxy`, as on Vercel |\n| Cloudflare Workers | `withLittleFriend` from `@littlefriend/edge` | Add `door: createDoor` |\n| Node with Express, Connect or `node:http` | `nodeMiddleware(edge)` | `nodeDoor(createDoor(lf), createEdge(lf))` in its place |\n| Bun, Deno, Hono or another fetch handler | `wrapFetch(edge, handler)` | `wrapFetch(edge, handler, { door })` |\n| Celsian | `observeCelsian(app, edge)` | None built in. Skip it and say so |\n| Static hosting without functions, not on Vercel | Only with a Worker in front | Only with a Worker in front |\n\nA request both a drain and the edge package report is counted once.\n\nRequests to the project's ignored routes are counted and not kept. Every project starts with `/api`, because an app calling its own API (session checks, polling, presence) is not a crawler or an agent. Add the product's other API paths, such as `/trpc` or `/graphql`:\n\n```sh\nlittlefriend projects update --add-ignored-route /trpc\n```\n\nRemove `/api` only when the owner wants to watch who calls the API, and say so in the report.\n\n### A Vercel log drain\n\n```sh\nlittlefriend drains list\nlittlefriend drains create --provider vercel\n```\n\n- Check `drains list` first. Reuse an active drain.\n- `drains create` prints the endpoint, the header and the signing secret, once. Create it only when you can paste them into Vercel in the same sitting: in the team's settings, add a log drain for this project only, all sources, production, and 100% sampling. Drains need a Vercel Pro or Enterprise team.\n- If you cannot reach Vercel's settings, do not create the drain. List it under \"Needs a person\": they can do both halves on the dashboard's Agents page, with Add a Vercel log drain.\n- A lost secret: `littlefriend drains rotate <id>` issues a new pair and keeps the old one working for 24 hours.\n- Within minutes of traffic, the Coverage card on the Agents page shows the drain as Live.\n\n### `@littlefriend/edge` and the door\n\nUse this when the product has code that runs on every request. Install `@littlefriend/edge` 0.3.0 or later. The door came in 0.2.0, and 0.3.0 stops sending requests to ignored routes. If the repo already depends on `^0.2.0`, change it to `^0.3.0`: that range never picks up 0.3.0 on its own.\n\n1. Create an edge key into the env file, as in step 12:\n\n```sh\nlittlefriend keys create --kind edge --label \"acme edge\" --write-env .env.local --env-name LF_EDGE_KEY\n```\n\n2. Install `@littlefriend/edge` and wire it for the runtime in the table above. The exact code for each runtime is on https://littlefriend.io/docs/agents (sections Any host and The door). For Next.js 16:\n\n```ts\n// proxy.ts\nimport { createDoor, createEdge, nextProxy } from '@littlefriend/edge';\nimport { NextResponse } from 'next/server';\n\nconst lf = { key: process.env.LF_EDGE_KEY!, ipHeader: 'x-forwarded-for' };\nexport const proxy = nextProxy(createDoor(lf), NextResponse, createEdge(lf));\nexport const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };\n```\n\n3. Set `ipHeader` to the header the host sets to the client address: `x-forwarded-for` on Vercel, `cf-connecting-ip` on Cloudflare, `fly-client-ip` on Fly. Without it, no address is sent and operator address checks cannot run.\n\n4. Pick a preset and keep dry run:\n\n```sh\nlittlefriend door set --preset verified_only --mode dry_run\nlittlefriend door simulate --ua \"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)\" --path /blog\n```\n\n| Product | Preset |\n|---|---|\n| A store with `/checkout`, `/cart` or `/account` | `commerce` |\n| An app or API with sign-in | `verified_only` |\n| Content, docs or marketing | `no_training` |\n| Unsure | `open` |\n\n- Dry run decides every request and records what it would have done. It never changes a response.\n- **Never switch the door to live.** The owner does that after reading 7 days of the door's report on the Agents page.\n- A browser always gets in, whatever the rules say.\n- `commerce` matches `/checkout`, `/cart` and `/account`. If the store uses other paths, say so in the report.\n\n## 14. Hybrid and native apps\n\n**Hybrid apps** (Capacitor, Ionic, Cordova, Tauri, Electron, React Native WebView) show web pages. Install the script in the app's web code as in step 5, then add the origin the web view sends to the project's allowed origins, with the complete list:\n\n```sh\nlittlefriend projects update --origin https://acme.com --origin https://www.acme.com \\\n --origin http://localhost:3000 --origin capacitor://localhost --origin https://localhost\n```\n\n| App shell | Origin to add |\n|---|---|\n| Capacitor on iOS | `capacitor://localhost` |\n| Capacitor on Android | `https://localhost` |\n| Ionic with its Cordova web view | `ionic://localhost` on iOS, `http://localhost` on Android |\n| Cordova | `app://localhost` on iOS (scheme preference set to `app`), `https://localhost` on Android |\n| Tauri | `tauri://localhost` on macOS, iOS and Linux. `http://tauri.localhost` on Windows and Android, or `https://tauri.localhost` with `useHttpsScheme` |\n| Electron | The scheme and host the app serves from, such as `app://myapp` |\n| React Native WebView | The origin of the site it loads |\n\n- If the shell's config changes the scheme or host, add the ones it sets.\n- An Electron app that loads pages from files sends no origin. Leave the list empty, or serve the app from its own scheme (for example `electron-serve`, which serves `app://-`).\n- If the repo deliberately skips analytics inside the shell, keep that and say so in the report.\n\n**Native screens** (SwiftUI, UIKit, Jetpack Compose, Flutter, React Native views) have no web page for the script. Send the outcomes they lead to from the backend with `@littlefriend/node` (step 12). The server key never goes inside the app.\n\n## 15. Verify\n\n1. Run the product: the local dev server (its origin is on the list from step 4), or the deployed site.\n2. Load the site in a real browser (Playwright, agent-browser, or a person). `curl` does not run the script. Headless Chrome says so in its user agent and is counted as automation, so its visits stay out of page views and funnels, which count people. For the journey walks in item 9, run the browser headed or give it a desktop Chrome user agent. Then run `littlefriend verify --source browser --since 10 --wait 120`. It exits 0 once an accepted event from the browser in the last 10 minutes shows up, and prints its time, route and source. `--source browser` matters on a project with a log drain or the edge SDK, whose events would otherwise pass for the tag's. It exits 1 when none arrives in time. On a project that already has traffic, keep `--since` short, so an older event cannot pass for yours.\n3. In the browser's network panel, `POST https://in.littlefriend.io/v1/e` answers `202` with `{\"accepted\": n, \"dropped\": 0}`. The console shows no CSP errors.\n4. Click through each flow with a named event and confirm each name in the request bodies. Any `dropped` above 0 means a name or property broke a rule. The live install check (Settings, Install) shows every field kept, every property dropped, and why.\n5. Replay: in the browser's network panel, `GET https://in.littlefriend.io/v1/r/config?k=<site key>` returns `\"on\": true`, and `POST /v1/r` answers `202`. From `curl`, send the page's origin with `-H 'Origin: https://<domain>'`: without an allowed origin the answer is `\"on\": false`. Then `littlefriend replay get` reads installed.\n6. Server events: run the flow that sends one. `verify` shows source `server`, or `await lf.flush()` resolves with `accepted: 1`.\n7. Agents: `littlefriend door get` shows the policy and its mode. On the dashboard's Agents page, the Coverage card shows the drain as Live, and the door page shows that the door has read its rules.\n8. A few minutes later: `littlefriend report overview` and `littlefriend report goals` for a sanity check.\n9. Walk each journey in your plan once, in one tab, step by step, on a page without `data-test` (test events stay out of reports). A few minutes later, `littlefriend funnels report fnl_XXXXXXXX` shows that session at every step. A step at 0 means its route or event name does not match what arrives: compare it with `littlefriend report pages` and the request bodies.\n\nVerify on production after the deploy too, without `data-test`.\n\n| Symptom | Cause |\n|---|---|\n| No request to `in.littlefriend.io` | CSP blocks the script, or the tag never rendered |\n| `403` `origin_not_allowed` | The page's origin is not in allowed origins |\n| `400` | Wrong or malformed site key, or a malformed batch |\n| Sessions unavailable, funnels empty | The project or the snippet is not in journey mode. Both must be |\n| Replay `403` `replay_off`, `session_in_aggregate_mode` or `route_excluded` | Replay is off, the project is in aggregate mode, or the page is excluded |\n| Replay `402` `replay_card_required` | The workspace used its free recordings this month and has no card on file |\n| Replay `429` `quota_exceeded` | The project stored its 1 GiB of recordings for the day, or one address used its share of that. It clears at midnight UTC. If it happens often, lower the sample rate |\n| Server events `rejected` in `onError` | Wrong kind of key, or a revoked one |\n\n## 16. Launch checklist\n\n- [ ] One project for the domain, in the right workspace, in the right mode.\n- [ ] Allowed origins list every production host, every app origin and the dev origin.\n- [ ] The script is on every page, once, with `data-test` outside production.\n- [ ] CSP updated, including report-only copies and header tests.\n- [ ] Every goal and funnel step has its own named event. No personal data in names, properties or routes.\n- [ ] The journey plan is written down, with one goal per outcome.\n- [ ] 2 to 5 goals and, in journey mode, 1 to 3 funnels saved, one per journey in the plan.\n- [ ] Each journey was walked once and shows at every step of its funnel report.\n- [ ] `identify` sends an opaque id, and `null` on sign-out (replay only).\n- [ ] Replay: shown regions checked for names, private routes excluded, widgets blocked.\n- [ ] Server key in an ignored env file and in the host's secrets. Serverless code flushes.\n- [ ] Door in dry run where the runtime allows it. Drain connected on Vercel.\n- [ ] Ignored routes cover the product's own API paths.\n- [ ] Privacy page text added.\n- [ ] `littlefriend verify --source browser` exits 0 on production, and batches show `\"dropped\": 0`.\n- [ ] No secret in your changes: `git diff <base branch> | grep -E 'lf[ser]_[A-Za-z0-9]'` prints nothing.\n- [ ] Report sent.\n\n## 17. Report back\n\nEnd with this report, filled in. Use \"skipped\" with a reason where a step did not apply. Never include a secret.\n\n```markdown\n## Little Friend setup: <product>\n\n- Repo and branch:\n- Domain and hosts:\n- Workspace:\n- Project id and site key (lf_):\n- Mode and consent:\n- Allowed origins:\n- Snippet: --framework <name>, in <file>\n- CSP: <files changed, or none needed>\n- Journey plan: <the table from step 3: journey, outcome goal, steps and how each is seen>\n- Named events: <name, where it fires>\n- Goals: <id, name, match>\n- Funnels: <id, name, steps>\n- Identify: <where it is called, what id it sends, or skipped>\n- Replay: <on or off, rate, shown, hidden, excluded routes>\n- Server events: <events, files, env var names, host secrets set or not>\n- Agents: <drain id and status, door runtime, preset and mode, or skipped>\n- Ignored routes: <the list, and any change from /api>\n- Hybrid or native: <origins added, backend events, or none>\n- Privacy page: <updated, or no privacy page>\n- Verify: <time, route and source that verify printed, local and production>\n- Skipped, and why:\n- Needs a person: <drains to connect, secrets to set, questions>\n- Guide or CLI problems found:\n```\n", bundled: true } : { text: GUIDE_PLACEHOLDER, bundled: false };
3622
3628
  }
3623
3629
  var guide = {
3624
3630
  path: ["guide"],
@@ -3717,11 +3723,14 @@ var keysList = {
3717
3723
  var keysCreate = {
3718
3724
  path: ["keys", "create"],
3719
3725
  summary: "Create a secret key, shown once or written straight to an env file",
3720
- usage: "--kind server|edge|read [--label <label>] [--env-file <path> [--env-name <NAME>]]",
3721
- example: "littlefriend keys create --kind server --env-file .env.local --env-name LF_SERVER_KEY",
3726
+ usage: "--kind server|edge|read [--label <label>] [--write-env <path> [--env-name <NAME>]]",
3727
+ example: "littlefriend keys create --kind server --write-env .env.local --env-name LF_SERVER_KEY",
3722
3728
  options: {
3723
3729
  kind: { type: "string" },
3724
3730
  label: { type: "string" },
3731
+ "write-env": { type: "string" },
3732
+ // Older name. Node itself reads --env-file anywhere in argv and exits when the file is
3733
+ // missing, so it only works for a file that already exists.
3725
3734
  "env-file": { type: "string" },
3726
3735
  "env-name": { type: "string" }
3727
3736
  },
@@ -3729,10 +3738,10 @@ var keysCreate = {
3729
3738
  const kind = oneOf(ctx, "kind", KINDS);
3730
3739
  if (!kind) throw usageError("--kind is required: server, edge or read.");
3731
3740
  const label = str(ctx, "label");
3732
- const envFile = str(ctx, "env-file");
3741
+ const envFile = str(ctx, "write-env") ?? str(ctx, "env-file");
3733
3742
  const envNameFlag = str(ctx, "env-name");
3734
3743
  if (envNameFlag !== void 0 && envFile === void 0) {
3735
- throw usageError("--env-name goes with --env-file, which names the file to write.");
3744
+ throw usageError("--env-name goes with --write-env, which names the file to write.");
3736
3745
  }
3737
3746
  const envName = envNameFlag ?? DEFAULT_ENV_NAMES[kind];
3738
3747
  if (!validEnvName(envName)) {
@@ -3754,7 +3763,7 @@ var keysCreate = {
3754
3763
  ` ${created2.secret}`,
3755
3764
  "",
3756
3765
  created2.notice,
3757
- `Keep it on the server, in an environment variable such as ${DEFAULT_ENV_NAMES[kind]}. To write it to a file without printing it, use --env-file.`
3766
+ `Keep it on the server, in an environment variable such as ${DEFAULT_ENV_NAMES[kind]}. To write it to a file without printing it, use --write-env.`
3758
3767
  )
3759
3768
  };
3760
3769
  }
@@ -3831,6 +3840,10 @@ function normalizeDomain(raw) {
3831
3840
  }
3832
3841
  return host.replace(/\/.*$/, "").replace(/^www\./, "");
3833
3842
  }
3843
+ function trimRoute(route) {
3844
+ const r = route.trim();
3845
+ return r.length > 1 ? r.replace(/\/+$/, "") : r;
3846
+ }
3834
3847
  function defaultOrigins(domain) {
3835
3848
  return [`https://${domain}`, `https://www.${domain}`];
3836
3849
  }
@@ -3847,6 +3860,7 @@ function projectText(p, role, origin) {
3847
3860
  ["Mode", p.privacyMode],
3848
3861
  ["Timezone", p.timezone],
3849
3862
  ["Allowed origins", p.allowedOrigins.length > 0 ? p.allowedOrigins.join(", ") : "any origin"],
3863
+ ["Ignored routes", (p.ignoredRoutes ?? []).length > 0 ? p.ignoredRoutes.join(", ") : "none"],
3850
3864
  ["Raw events kept", `${p.retentionEventsDays} days`],
3851
3865
  ["Totals kept", `${p.retentionAggregatesDays} days`],
3852
3866
  ["utm_term kept", p.campaignAllowTerm],
@@ -3971,13 +3985,17 @@ var projectsGet = {
3971
3985
  var projectsUpdate = {
3972
3986
  path: ["projects", "update"],
3973
3987
  summary: "Change a project's settings",
3974
- usage: "[--name <n>] [--domain <host>] [--mode aggregate|journey] [--origin <o>]... [--add-origin <o>]... [--remove-origin <o>]... [--any-origin] [--timezone <tz>] [--events-days <1-7>] [--aggregates-days <30-395>] [--campaign-term | --no-campaign-term]",
3988
+ usage: "[--name <n>] [--domain <host>] [--mode aggregate|journey] [--origin <o>]... [--add-origin <o>]... [--remove-origin <o>]... [--any-origin] [--ignored-route <path>]... [--add-ignored-route <path>]... [--remove-ignored-route <path>]... [--no-ignored-routes] [--timezone <tz>] [--events-days <1-7>] [--aggregates-days <30-395>] [--campaign-term | --no-campaign-term]",
3975
3989
  example: "littlefriend projects update --mode journey --add-origin capacitor://localhost",
3976
3990
  options: {
3977
3991
  ...SETTING_OPTIONS,
3978
3992
  "add-origin": { type: "string", multiple: true },
3979
3993
  "remove-origin": { type: "string", multiple: true },
3980
3994
  "any-origin": { type: "boolean" },
3995
+ "ignored-route": { type: "string", multiple: true },
3996
+ "add-ignored-route": { type: "string", multiple: true },
3997
+ "remove-ignored-route": { type: "string", multiple: true },
3998
+ "no-ignored-routes": { type: "boolean" },
3981
3999
  "events-days": { type: "string" },
3982
4000
  "aggregates-days": { type: "string" },
3983
4001
  "campaign-term": { type: "boolean" },
@@ -4013,6 +4031,17 @@ var projectsUpdate = {
4013
4031
  if (has(ctx, "origin") && flag(ctx, "any-origin")) {
4014
4032
  throw usageError("Pass --origin or --any-origin, not both.");
4015
4033
  }
4034
+ const replaceRoutes = has(ctx, "ignored-route") || flag(ctx, "no-ignored-routes");
4035
+ const addRoutes = many(ctx, "add-ignored-route").map(trimRoute);
4036
+ const removeRoutes = many(ctx, "remove-ignored-route").map(trimRoute);
4037
+ if (replaceRoutes && (addRoutes.length > 0 || removeRoutes.length > 0)) {
4038
+ throw usageError(
4039
+ "Use --ignored-route or --no-ignored-routes to replace the list, or --add-ignored-route and --remove-ignored-route to edit it."
4040
+ );
4041
+ }
4042
+ if (has(ctx, "ignored-route") && flag(ctx, "no-ignored-routes")) {
4043
+ throw usageError("Pass --ignored-route or --no-ignored-routes, not both.");
4044
+ }
4016
4045
  const resolved = await resolveProject(ctx);
4017
4046
  if (replace) body.allowedOrigins = flag(ctx, "any-origin") ? [] : many(ctx, "origin");
4018
4047
  if (add.length > 0 || remove.length > 0) {
@@ -4020,6 +4049,12 @@ var projectsUpdate = {
4020
4049
  for (const o of add) if (!next.includes(o)) next.push(o);
4021
4050
  body.allowedOrigins = next;
4022
4051
  }
4052
+ if (replaceRoutes) body.ignoredRoutes = flag(ctx, "no-ignored-routes") ? [] : many(ctx, "ignored-route");
4053
+ if (addRoutes.length > 0 || removeRoutes.length > 0) {
4054
+ const next = (resolved.ignoredRoutes ?? []).filter((r) => !removeRoutes.includes(r));
4055
+ for (const r of addRoutes) if (!next.includes(r)) next.push(r);
4056
+ body.ignoredRoutes = next;
4057
+ }
4023
4058
  if (Object.keys(body).length === 0) {
4024
4059
  throw usageError(
4025
4060
  "Nothing to change. Pass a setting, such as --mode journey. See littlefriend projects update --help."
@@ -4740,6 +4775,7 @@ var snippetCommands = [snippet];
4740
4775
  // src/commands/verify.ts
4741
4776
  var DEFAULT_WAIT_S = 60;
4742
4777
  var DEFAULT_SINCE_MIN = 60;
4778
+ var SOURCES2 = ["browser", "server", "edge"];
4743
4779
  var REJECT_HINTS = {
4744
4780
  origin_not_allowed: "a page on an origin the project does not allow sent it. Add the site's origin: littlefriend projects update --add-origin https://<host>",
4745
4781
  session_in_aggregate_mode: "the tag runs in journey mode but the project is in aggregate mode. Make them match: littlefriend projects update --mode journey",
@@ -4750,7 +4786,9 @@ var REJECT_HINTS = {
4750
4786
  rate_limited: "the project sent more requests at once than its rate limit allows",
4751
4787
  quota_exceeded: "the project reached its daily event quota",
4752
4788
  missing_signature: "a log drain delivery carried no signature: paste the signing secret into Vercel",
4753
- bad_signature: "a log drain delivery's signature did not match: paste the current signing secret into Vercel"
4789
+ bad_signature: "a log drain delivery's signature did not match: paste the current signing secret into Vercel",
4790
+ foreign_host: "log drain lines for another host on the same Vercel project, such as its .vercel.app address, were left out. That is expected",
4791
+ duplicate: "a request the log drain delivered more than once was counted once. That is expected"
4754
4792
  };
4755
4793
  function readLive(body) {
4756
4794
  if (Array.isArray(body)) return body;
@@ -4773,6 +4811,8 @@ function healthText(h) {
4773
4811
  ["Install verified", h.installVerified],
4774
4812
  ["Last received", h.lastReceivedAt ? when(h.lastReceivedAt) : "never"],
4775
4813
  ["Sources this week", sources.length > 0 ? sources.join(", ") : "none yet"],
4814
+ ...(h.ignored24h ?? 0) > 0 ? [["Ignored in 24 hours", `${h.ignored24h} requests to ignored routes, counted and not kept`]] : [],
4815
+ // Last, so the refusal lines below sit under their heading.
4776
4816
  ["Refused in 24 hours", h.rejected24h.length > 0 ? "" : "none"]
4777
4817
  ]),
4778
4818
  ...refused,
@@ -4787,14 +4827,17 @@ async function checkInstall(ctx, project, opts) {
4787
4827
  const refused = /* @__PURE__ */ new Map();
4788
4828
  let first;
4789
4829
  let accepted = 0;
4830
+ const from = opts.source ? ` from the ${opts.source}` : "";
4790
4831
  if (opts.waitS > 0) {
4791
4832
  ctx.note(
4792
- `Waiting up to ${opts.waitS} s for an event on ${project.name} (${project.publicKey}). Visit the site to send one.`
4833
+ `Waiting up to ${opts.waitS} s for an event${from} on ${project.name} (${project.publicKey}). Visit the site to send one.`
4793
4834
  );
4794
4835
  }
4795
4836
  for (; ; ) {
4796
4837
  const events = readLive(await ctx.api().get(projectPath(project, "/live")));
4797
- const recent = events.filter((e) => Date.parse(e.at) >= cutoff);
4838
+ const recent = events.filter(
4839
+ (e) => Date.parse(e.at) >= cutoff && (opts.source === void 0 || e.source === opts.source)
4840
+ );
4798
4841
  const ok = recent.filter((e) => e.accepted).sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
4799
4842
  refused.clear();
4800
4843
  for (const e of recent) {
@@ -4835,7 +4878,7 @@ async function checkInstall(ctx, project, opts) {
4835
4878
  )
4836
4879
  };
4837
4880
  }
4838
- const message = `No events arrived for ${project.name} in the last ${plural(opts.sinceMin, "minute")}${opts.waitS > 0 ? ` (waited ${waited} s)` : ""}.`;
4881
+ const message = `No events${from} arrived for ${project.name} in the last ${plural(opts.sinceMin, "minute")}${opts.waitS > 0 ? ` (waited ${waited} s)` : ""}.`;
4839
4882
  return {
4840
4883
  exitCode: EXIT.failure,
4841
4884
  data: {
@@ -4862,32 +4905,35 @@ async function checkInstall(ctx, project, opts) {
4862
4905
  )
4863
4906
  };
4864
4907
  }
4865
- var OPTIONS = { wait: { type: "string" }, since: { type: "string" } };
4908
+ var OPTIONS = { wait: { type: "string" }, since: { type: "string" }, source: { type: "string" } };
4866
4909
  var verify = {
4867
4910
  path: ["verify"],
4868
4911
  summary: "Wait for the first event, say what it was, and show refusals",
4869
- usage: "[--wait <seconds>] [--since <minutes>]",
4870
- example: "littlefriend verify --wait 120",
4912
+ usage: "[--wait <seconds>] [--since <minutes>] [--source browser|server|edge]",
4913
+ example: "littlefriend verify --source browser --wait 120",
4871
4914
  options: OPTIONS,
4872
4915
  async run(ctx) {
4873
4916
  const waitS = intOpt(ctx, "wait", 0, 3600) ?? DEFAULT_WAIT_S;
4874
4917
  const sinceMin = intOpt(ctx, "since", 1, 7 * 24 * 60) ?? DEFAULT_SINCE_MIN;
4875
- return checkInstall(ctx, await resolveProject(ctx), { waitS, sinceMin });
4918
+ const source = oneOf(ctx, "source", SOURCES2);
4919
+ return checkInstall(ctx, await resolveProject(ctx), { waitS, sinceMin, source });
4876
4920
  }
4877
4921
  };
4878
4922
  var projectsCheck = {
4879
4923
  path: ["projects", "check"],
4880
4924
  summary: "The project, whether events arrive, and why any were refused (verify without waiting)",
4881
- usage: "[--wait <seconds>] [--since <minutes>]",
4925
+ usage: "[--wait <seconds>] [--since <minutes>] [--source browser|server|edge]",
4882
4926
  example: "littlefriend projects check --project acme.com",
4883
4927
  options: OPTIONS,
4884
4928
  async run(ctx) {
4885
4929
  const waitS = intOpt(ctx, "wait", 0, 3600) ?? 0;
4886
4930
  const sinceMin = intOpt(ctx, "since", 1, 7 * 24 * 60) ?? DEFAULT_SINCE_MIN;
4931
+ const source = oneOf(ctx, "source", SOURCES2);
4887
4932
  const project = await resolveProject(ctx);
4888
4933
  const result = await checkInstall(ctx, project, {
4889
4934
  waitS,
4890
4935
  sinceMin,
4936
+ source,
4891
4937
  heading: projectText(project, void 0, ctx.origin())
4892
4938
  });
4893
4939
  return { ...result, data: { ...result.data, project } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlefriend/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Set up Little Friend analytics from the terminal: projects, install snippets, keys, goals, funnels, session replay, the agent door, log drains and install checks.",
6
6
  "keywords": [
@@ -25,8 +25,8 @@
25
25
  ],
26
26
  "devDependencies": {
27
27
  "esbuild": "^0.25.0",
28
- "@littlefriend/classify": "0.1.0",
29
- "@littlefriend/contract": "0.1.0"
28
+ "@littlefriend/contract": "0.1.0",
29
+ "@littlefriend/classify": "0.1.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"