@evinvest/kitstart 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.11.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.12.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -678,6 +678,13 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
678
678
  Every booking joins its lead by it (Cal.com's `metadata[ref]`, a link's
679
679
  `ref`, `booking.requested`'s `lead_ref`): **a brand must send this as the
680
680
  panel lead id** in `lead.created`, not an id it computes itself.
681
+ - **`ctx.analyticsId` — the visit, for PostHog.** The `distinct_id` of the
682
+ page's beacons (`AnalyticsBoundary` holds one per page, in memory), posted
683
+ by `LeadCapture`'s script as `analytics_id` and checked against
684
+ `[A-Za-z0-9._:-]{1,128}`. Write it as `lead.created`'s `analytics_id`
685
+ property so the panel's PostHog events of the lead join the visit — once
686
+ the panel accepts the property. Absent for a plain post, a page with no
687
+ analytics key, or an older page; never stored with the lead.
681
688
  - **Booking requests: `panelBooking`, off.** With `leadWebhook(…, {
682
689
  panelBooking: true, buildBookingBody })`, `bookingRoute` queues
683
690
  `booking.requested@1` through the same outbox, after the lead's
@@ -715,6 +722,54 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
715
722
  `WEBHOOK_TICK_MS` and a new process picks up what is due. Rows queued for a
716
723
  previous URL stay in place and are counted at open.
717
724
 
725
+ ## Experiments from the panel
726
+
727
+ The experiments live in code (`@evinvest/experiments`, one `as const` config);
728
+ the panel decides their weights and kill switch, and PostHog counts them.
729
+
730
+ **Overrides** (`createExperimentsSource`): `GET <base>/experiments`, the same
731
+ base as the place source, `{ "experiments": { "<key>": { enabled?, weights?,
732
+ holdout? } } }`. The proxy reads it on every request, so it answers from memory:
733
+ the first call waits for the panel (1.5 s timeout), later ones are served from
734
+ the cache, and past the 30 s TTL the stale answer is served while one refresh
735
+ runs behind it. An unreachable panel, a non-200 or a body of another shape is
736
+ logged and keeps the last good answer — a kill switch must not come back on
737
+ because the panel blinked — or `{}`, the config in code, when there was none
738
+ yet; either way the panel is not asked again before the TTL.
739
+
740
+ ```ts
741
+ // shared/config/env.ts
742
+ export const experimentOverrides = createExperimentsSource({ baseUrl: () => serverEnv().locationsApiUrl });
743
+ // proxy.ts — the same applied config wherever a variant is read
744
+ const live = applyOverrides(experiments, await experimentOverrides.overrides());
745
+ ```
746
+
747
+ Only types are checked here; whether a field fits the code (weights of the
748
+ declared length, holdout in `[0, 1)`) is `applyOverrides`' call.
749
+
750
+ **Declaration** (`declareExperiments`): at every start the landing tells the
751
+ panel which experiments this build runs — `experiments.declared@1`, one event
752
+ with every experiment's key, variants, weights, `enabled`, `holdout` and an
753
+ optional one-line `summary`, under a fresh UUIDv7. It goes through the lead
754
+ webhook's outbox, signed and retried like a lead (so it needs the same
755
+ `LEAD_WEBHOOK_URL` and sqlite lead store), and the panel keeps the latest by
756
+ `occurredAt`; an experiment missing from it is retired there.
757
+
758
+ ```ts
759
+ // instrumentation.ts
760
+ export async function register() {
761
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
762
+ declareExperiments(webhook, experiments, { summaries: { lead_layout: "Two steps beat one long form" } });
763
+ }
764
+ ```
765
+
766
+ It never throws and never holds the start: no webhook, one that cannot be
767
+ built, or a queue that refuses the row is logged and skipped — the panel keeps
768
+ the last declaration. An experiment the panel would refuse (key not
769
+ `[a-z0-9_]{1,64}`, fewer than two unique slug variants, weights not one per
770
+ variant, negative or summing to zero, holdout outside `[0, 1)`, summary over
771
+ 200 characters) is left out and logged, since the panel judges the event whole.
772
+
718
773
  ## The site
719
774
 
720
775
  ```ts
@@ -9,6 +9,12 @@ export declare const FORM_ID_FIELD = "form_id";
9
9
  /** The site's experiment assignment, posted so the submit counts in the right arm. */
10
10
  export declare const EXPERIMENT_FIELD = "experiment";
11
11
  export declare const VARIANT_FIELD = "variant";
12
+ /**
13
+ * The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
14
+ * webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
15
+ * stored with the lead and never a reason to refuse one.
16
+ */
17
+ export declare const ANALYTICS_ID_FIELD = "analytics_id";
12
18
  /**
13
19
  * The price the form showed, in cents — compared with the server's, never
14
20
  * stored as the price: a lead is taken only at the price the visitor saw.
@@ -73,6 +79,7 @@ export interface AcceptDeps {
73
79
  locale: string;
74
80
  formId: string;
75
81
  leadRef: string;
82
+ analyticsId?: string;
76
83
  }) => void;
77
84
  /**
78
85
  * The price list to price an `estimate` or `fixed` lead with
@@ -1,4 +1,4 @@
1
- import { experimentProps } from "./analytics.js";
1
+ import { ANALYTICS_ID, experimentProps } from "./analytics.js";
2
2
  import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
3
3
  import { channelOf, MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
4
4
  import { flowOf, readEstimateInputs } from "./pricing/flow.js";
@@ -10,6 +10,12 @@ export const FORM_ID_FIELD = "form_id";
10
10
  /** The site's experiment assignment, posted so the submit counts in the right arm. */
11
11
  export const EXPERIMENT_FIELD = "experiment";
12
12
  export const VARIANT_FIELD = "variant";
13
+ /**
14
+ * The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
15
+ * webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
16
+ * stored with the lead and never a reason to refuse one.
17
+ */
18
+ export const ANALYTICS_ID_FIELD = "analytics_id";
13
19
  /**
14
20
  * The price the form showed, in cents — compared with the server's, never
15
21
  * stored as the price: a lead is taken only at the price the visitor saw.
@@ -161,11 +167,13 @@ async function accept(site, form, clientKey, deps) {
161
167
  // lead (or one from a page cached before the stamp existed) is still sent
162
168
  // on, flagged. The honeypot and the rate limit are the server's own
163
169
  // evidence, and those leads wait in the table for a reviewer.
170
+ const rawAnalyticsId = form.get(ANALYTICS_ID_FIELD);
171
+ const analyticsId = typeof rawAnalyticsId === "string" && ANALYTICS_ID.test(rawAnalyticsId) ? rawAnalyticsId : undefined;
164
172
  const queue = () => {
165
173
  if (!deps.enqueue)
166
174
  return;
167
175
  try {
168
- deps.enqueue(lead, id, { locale, formId, leadRef: ref });
176
+ deps.enqueue(lead, id, { locale, formId, leadRef: ref, ...(analyticsId ? { analyticsId } : {}) });
169
177
  }
170
178
  catch (error) {
171
179
  deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
@@ -36,6 +36,12 @@ export type LeadStep = "need" | "contact";
36
36
  * the site's assignment, passed in; `field` is a field's role, never its value.
37
37
  */
38
38
  export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket", "provider"];
39
+ /**
40
+ * The beacon's `distinct_id` as a lead may carry it to the panel
41
+ * (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
42
+ * visit. The panel's own rule; anything else is dropped, never refused.
43
+ */
44
+ export declare const ANALYTICS_ID: RegExp;
39
45
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
40
46
  export declare const EXPERIMENT_SLUG: RegExp;
41
47
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
@@ -62,6 +68,8 @@ export interface AnalyticsTarget {
62
68
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
63
69
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
64
70
  * because the events that matter most — a tap on `tel:` or `wa.me` — are
65
- * followed by the page handing the visitor to another app.
71
+ * followed by the page handing the visitor to another app. `distinctId`:
72
+ * the visitor's id, held by the caller so a lead can name it; a random one
73
+ * per sink without it.
66
74
  */
67
- export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null): AnalyticsSink;
75
+ export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string): AnalyticsSink;
@@ -58,6 +58,12 @@ export const ALLOWED_PROPS = [
58
58
  // The booking's provider, `manual` | `link` | `cal_com` — never its URL.
59
59
  "provider",
60
60
  ];
61
+ /**
62
+ * The beacon's `distinct_id` as a lead may carry it to the panel
63
+ * (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
64
+ * visit. The panel's own rule; anything else is dropped, never refused.
65
+ */
66
+ export const ANALYTICS_ID = /^[A-Za-z0-9._:-]{1,128}$/;
61
67
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
62
68
  export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
63
69
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
@@ -75,11 +81,14 @@ export function countsAsPageView(pathname) {
75
81
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
76
82
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
77
83
  * because the events that matter most — a tap on `tel:` or `wa.me` — are
78
- * followed by the page handing the visitor to another app.
84
+ * followed by the page handing the visitor to another app. `distinctId`:
85
+ * the visitor's id, held by the caller so a lead can name it; a random one
86
+ * per sink without it.
79
87
  */
80
- export function analyticsSink(target, locationId) {
88
+ export function analyticsSink(target, locationId, distinctId) {
81
89
  return createBeaconSink({
82
90
  key: target.key ?? undefined,
91
+ ...(distinctId !== undefined ? { distinctId } : {}),
83
92
  host: target.host,
84
93
  allowedProps: ALLOWED_PROPS,
85
94
  globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
package/dist/index.d.ts CHANGED
@@ -13,10 +13,10 @@ export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptu
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
15
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
16
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
16
+ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
19
- export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
19
+ export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
20
20
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
21
21
  export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
22
22
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
package/dist/index.js CHANGED
@@ -13,10 +13,10 @@ export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
15
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
16
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
16
+ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
19
- export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
19
+ export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
20
20
  export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
21
21
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
22
22
  // A landing needs these beside the machinery; re-exported by name so a brand
@@ -2,9 +2,10 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { ContactLinkTracker } from "@evinvest/marketing/tracker";
4
4
  import { usePathname } from "next/navigation.js";
5
- import { useEffect, useMemo } from "react";
5
+ import { useEffect, useMemo, useState } from "react";
6
6
  import { analyticsSink, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
7
- import { AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
7
+ import { AnalyticsIdContext, AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
8
+ import { newSubmissionId } from "./use-lead-submit.js";
8
9
  /**
9
10
  * `data-intent` on a link or button marks a conversion intent that is not a
10
11
  * contact link — or one the tracker cannot classify (`sms:`).
@@ -35,7 +36,10 @@ function source() {
35
36
  export function AnalyticsBoundary({ target, placeSlug, children }) {
36
37
  // On the target's values: a server layout hands a fresh object every render.
37
38
  const { key, host, brandId } = target;
38
- const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug), [key, host, brandId, placeSlug]);
39
+ // One visitor for the boundary's life, in memory only: a sink rebuilt for
40
+ // another place keeps it, and a lead posted from the page can name it.
41
+ const [distinctId] = useState(newSubmissionId);
42
+ const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug, distinctId), [key, host, brandId, placeSlug, distinctId]);
39
43
  const pathname = usePathname();
40
44
  useEffect(() => {
41
45
  if (!countsAsPageView(pathname))
@@ -57,5 +61,5 @@ export function AnalyticsBoundary({ target, placeSlug, children }) {
57
61
  }, [sink]);
58
62
  // A link inside an experiment's island (`LeadCapture`) carries the
59
63
  // assignment on itself, which is what the tracker reads.
60
- return (_jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }));
64
+ return (_jsx(AnalyticsIdContext.Provider, { value: key ? distinctId : null, children: _jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }) }));
61
65
  }
@@ -7,5 +7,12 @@ import { experimentProps } from "../core/analytics.js";
7
7
  */
8
8
  export declare const AnalyticsSinkContext: import("react").Context<AnalyticsSink | null>;
9
9
  export declare const useAnalyticsSink: () => AnalyticsSink | null;
10
+ /**
11
+ * The `distinct_id` the boundary's sink sends, for a lead to carry to the
12
+ * panel. `null` outside a boundary or with no key: no event names it, so a
13
+ * lead has nothing to join.
14
+ */
15
+ export declare const AnalyticsIdContext: import("react").Context<string | null>;
16
+ export declare const useAnalyticsId: () => string | null;
10
17
  /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
11
18
  export declare function experimentOf(el: Element | null): ReturnType<typeof experimentProps>;
@@ -8,6 +8,13 @@ import { experimentProps } from "../core/analytics.js";
8
8
  */
9
9
  export const AnalyticsSinkContext = createContext(null);
10
10
  export const useAnalyticsSink = () => useContext(AnalyticsSinkContext);
11
+ /**
12
+ * The `distinct_id` the boundary's sink sends, for a lead to carry to the
13
+ * panel. `null` outside a boundary or with no key: no event names it, so a
14
+ * lead has nothing to join.
15
+ */
16
+ export const AnalyticsIdContext = createContext(null);
17
+ export const useAnalyticsId = () => useContext(AnalyticsIdContext);
11
18
  /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
12
19
  export function experimentOf(el) {
13
20
  const host = el?.closest("[data-experiment]");
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
  import { useRef, useState } from "react";
3
+ import { ANALYTICS_ID_FIELD } from "../core/accept.js";
3
4
  import { CHANNEL_FIELD, SUBMISSION_FIELD } from "../core/lead.js";
4
5
  import { THANKS } from "../core/routing.js";
6
+ import { useAnalyticsId } from "./analytics-context.js";
5
7
  /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
6
8
  export const navigation = { assign: (url) => window.location.assign(url) };
7
9
  /**
@@ -98,6 +100,7 @@ export function useLeadSubmit(done, fields, on) {
98
100
  const [failure, setFailure] = useState(null);
99
101
  const pending = useRef(false);
100
102
  const last = useRef(null);
103
+ const analyticsId = useAnalyticsId();
101
104
  const submit = (event) => {
102
105
  event.preventDefault();
103
106
  if (pending.current)
@@ -111,6 +114,10 @@ export function useLeadSubmit(done, fields, on) {
111
114
  for (const [key, value] of data)
112
115
  if (typeof value === "string")
113
116
  body.append(key, value);
117
+ // Not a field of the form: the submission id stamps what the visitor
118
+ // posted, and a plain post (no script) has no analytics to name anyway.
119
+ if (analyticsId)
120
+ body.set(ANALYTICS_ID_FIELD, analyticsId);
114
121
  const read = (field) => {
115
122
  const value = field === undefined ? null : data.get(field);
116
123
  return typeof value === "string" ? value.trim() : "";
@@ -0,0 +1,106 @@
1
+ import "server-only";
2
+ import type { LeadWebhook } from "./lead-webhook.js";
3
+ /**
4
+ * One experiment as the code declares it — the shape of an
5
+ * `@evinvest/experiments` spec, so the brand's `as const` config is passed
6
+ * as is. `variants[0]` is the control.
7
+ */
8
+ export interface DeclaredExperiment {
9
+ readonly variants: readonly string[];
10
+ readonly weights: readonly number[];
11
+ readonly enabled?: boolean;
12
+ readonly holdout?: number;
13
+ }
14
+ export type ExperimentsDeclaration = Readonly<Record<string, DeclaredExperiment>>;
15
+ /** `ExperimentDeclaration` of the panel's `experiments.declared@1`. */
16
+ export interface ExperimentDeclarationV1 {
17
+ key: string;
18
+ variants: string[];
19
+ weights: number[];
20
+ enabled: boolean;
21
+ holdout?: number;
22
+ summary?: string;
23
+ }
24
+ /** `experiments.declared@1` as protojson: `sa.v1.Event` with `ExperimentsDeclaredV1` for properties. */
25
+ export interface ExperimentsDeclaredEvent {
26
+ id: string;
27
+ schema: "sa.funnel.v1";
28
+ type: "experiments.declared";
29
+ typeVersion: 1;
30
+ occurredAt: string;
31
+ source: {
32
+ kind: "site";
33
+ id: string;
34
+ };
35
+ subject: {
36
+ brandId: string;
37
+ };
38
+ properties: {
39
+ experiments: ExperimentDeclarationV1[];
40
+ };
41
+ }
42
+ /**
43
+ * Why the panel would refuse this experiment, or `null`. The panel judges the
44
+ * event whole, so one bad experiment would lose the brand's declaration:
45
+ * it is left out instead, and said so at start.
46
+ */
47
+ export declare function declarationProblem(key: string, spec: DeclaredExperiment, summary: string | undefined): string | null;
48
+ /** RFC 9562 UUIDv7: the panel refuses any other version for an event id. */
49
+ export declare function uuidV7(nowMs: number): string;
50
+ export interface DeclarationContext {
51
+ brandId: string;
52
+ /** The key id the batch is signed with: the panel refuses any other `source.id`. */
53
+ sourceId: string;
54
+ at: Date;
55
+ /** One line of hypothesis per key, at most 200 characters. */
56
+ summaries?: Readonly<Record<string, string>>;
57
+ }
58
+ /**
59
+ * The ingest body declaring the brand's experiments, and what was left out of
60
+ * it with why. A fresh UUIDv7 each call: every start is a declaration of its
61
+ * own, and the panel keeps the latest by `occurredAt`.
62
+ */
63
+ export declare function experimentsDeclaredBody(experiments: ExperimentsDeclaration, ctx: DeclarationContext): {
64
+ body: {
65
+ events: [ExperimentsDeclaredEvent];
66
+ };
67
+ skipped: {
68
+ key: string;
69
+ why: string;
70
+ }[];
71
+ };
72
+ /** What `declareExperiments` did: queued (`row`), or why not. */
73
+ export type DeclareOutcome = {
74
+ kind: "queued";
75
+ row: number;
76
+ skipped: {
77
+ key: string;
78
+ why: string;
79
+ }[];
80
+ } | {
81
+ kind: "off";
82
+ } | {
83
+ kind: "failed";
84
+ };
85
+ export interface DeclareOptions {
86
+ summaries?: Readonly<Record<string, string>>;
87
+ log?: Pick<Console, "warn" | "error">;
88
+ }
89
+ /**
90
+ * Tells the panel which experiments this build runs — for `instrumentation.ts`
91
+ * `register()`, once per start:
92
+ *
93
+ * ```ts
94
+ * export async function register() {
95
+ * if (process.env.NEXT_RUNTIME !== "nodejs") return;
96
+ * declareExperiments(webhook, experiments, { summaries: { hero: "A shorter form converts better" } });
97
+ * }
98
+ * ```
99
+ *
100
+ * Queued in the lead webhook's outbox, signed and retried like a lead, and
101
+ * sent at once. It never throws and never holds the start: a webhook that is
102
+ * off (no `LEAD_WEBHOOK_URL`) or cannot be built is logged and skipped — the
103
+ * panel keeps the brand's last declaration. An experiment the panel would
104
+ * refuse is left out, logged.
105
+ */
106
+ export declare function declareExperiments(webhook: () => LeadWebhook | null, experiments: ExperimentsDeclaration, options?: DeclareOptions): DeclareOutcome;
@@ -0,0 +1,124 @@
1
+ import "server-only";
2
+ import { randomBytes } from "node:crypto";
3
+ import { EXPERIMENT_SLUG } from "../core/analytics.js";
4
+ const KEY = /^[a-z0-9_]{1,64}$/;
5
+ const MAX_SUMMARY = 200;
6
+ /**
7
+ * Why the panel would refuse this experiment, or `null`. The panel judges the
8
+ * event whole, so one bad experiment would lose the brand's declaration:
9
+ * it is left out instead, and said so at start.
10
+ */
11
+ export function declarationProblem(key, spec, summary) {
12
+ if (!KEY.test(key))
13
+ return "the key is not [a-z0-9_]{1,64}";
14
+ if (spec.variants.length < 2)
15
+ return "fewer than two variants";
16
+ if (new Set(spec.variants).size !== spec.variants.length)
17
+ return "a variant is declared twice";
18
+ if (!spec.variants.every(v => EXPERIMENT_SLUG.test(v)))
19
+ return "a variant is not a slug ([a-z0-9_-]{1,32})";
20
+ if (spec.weights.length !== spec.variants.length)
21
+ return "not one weight per variant";
22
+ if (!spec.weights.every(w => Number.isFinite(w) && w >= 0))
23
+ return "a weight is negative or not a number";
24
+ if (spec.weights.reduce((sum, w) => sum + w, 0) <= 0)
25
+ return "the weights sum to zero";
26
+ if (spec.holdout !== undefined && !(Number.isFinite(spec.holdout) && spec.holdout >= 0 && spec.holdout < 1))
27
+ return "the holdout is not in [0, 1)";
28
+ if (summary !== undefined && summary.length > MAX_SUMMARY)
29
+ return `the summary is over ${MAX_SUMMARY} characters`;
30
+ return null;
31
+ }
32
+ /** RFC 9562 UUIDv7: the panel refuses any other version for an event id. */
33
+ export function uuidV7(nowMs) {
34
+ const bytes = randomBytes(16);
35
+ let ms = nowMs;
36
+ for (let i = 5; i >= 0; i--) {
37
+ bytes[i] = ms % 256;
38
+ ms = Math.floor(ms / 256);
39
+ }
40
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x70;
41
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
42
+ const hex = bytes.toString("hex");
43
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
44
+ }
45
+ /**
46
+ * The ingest body declaring the brand's experiments, and what was left out of
47
+ * it with why. A fresh UUIDv7 each call: every start is a declaration of its
48
+ * own, and the panel keeps the latest by `occurredAt`.
49
+ */
50
+ export function experimentsDeclaredBody(experiments, ctx) {
51
+ const declared = [];
52
+ const skipped = [];
53
+ for (const [key, spec] of Object.entries(experiments)) {
54
+ const summary = ctx.summaries && Object.hasOwn(ctx.summaries, key) ? ctx.summaries[key] : undefined;
55
+ const why = declarationProblem(key, spec, summary);
56
+ if (why !== null) {
57
+ skipped.push({ key, why });
58
+ continue;
59
+ }
60
+ declared.push({
61
+ key,
62
+ variants: [...spec.variants],
63
+ weights: [...spec.weights],
64
+ enabled: spec.enabled ?? true,
65
+ ...(spec.holdout !== undefined ? { holdout: spec.holdout } : {}),
66
+ ...(summary !== undefined && summary !== "" ? { summary } : {}),
67
+ });
68
+ }
69
+ const event = {
70
+ id: uuidV7(ctx.at.getTime()),
71
+ schema: "sa.funnel.v1",
72
+ type: "experiments.declared",
73
+ typeVersion: 1,
74
+ occurredAt: ctx.at.toISOString(),
75
+ source: { kind: "site", id: ctx.sourceId },
76
+ subject: { brandId: ctx.brandId },
77
+ properties: { experiments: declared },
78
+ };
79
+ return { body: { events: [event] }, skipped };
80
+ }
81
+ /**
82
+ * Tells the panel which experiments this build runs — for `instrumentation.ts`
83
+ * `register()`, once per start:
84
+ *
85
+ * ```ts
86
+ * export async function register() {
87
+ * if (process.env.NEXT_RUNTIME !== "nodejs") return;
88
+ * declareExperiments(webhook, experiments, { summaries: { hero: "A shorter form converts better" } });
89
+ * }
90
+ * ```
91
+ *
92
+ * Queued in the lead webhook's outbox, signed and retried like a lead, and
93
+ * sent at once. It never throws and never holds the start: a webhook that is
94
+ * off (no `LEAD_WEBHOOK_URL`) or cannot be built is logged and skipped — the
95
+ * panel keeps the brand's last declaration. An experiment the panel would
96
+ * refuse is left out, logged.
97
+ */
98
+ export function declareExperiments(webhook, experiments, options = {}) {
99
+ const log = options.log ?? console;
100
+ let hook;
101
+ try {
102
+ hook = webhook();
103
+ }
104
+ catch (error) {
105
+ log.error("experiments: the lead webhook could not be built, nothing declared to the panel", error);
106
+ return { kind: "failed" };
107
+ }
108
+ if (!hook?.declareExperiments)
109
+ return { kind: "off" };
110
+ let outcome;
111
+ try {
112
+ outcome = hook.declareExperiments(experiments, options.summaries ?? {});
113
+ }
114
+ catch (error) {
115
+ log.error("experiments: the declaration could not be queued", error);
116
+ return { kind: "failed" };
117
+ }
118
+ if (outcome.kind === "queued") {
119
+ for (const { key, why } of outcome.skipped)
120
+ log.error(`experiments: ${key} is not declared to the panel: ${why}`);
121
+ hook.tick().catch((error) => log.error("experiments: sending the declaration failed; the outbox retries it", error));
122
+ }
123
+ return outcome;
124
+ }
@@ -0,0 +1,49 @@
1
+ import "server-only";
2
+ /**
3
+ * An operator's override of one experiment, as the panel serves it — the
4
+ * shape `applyOverrides` of `@evinvest/experiments` takes. Only the types are
5
+ * checked here; whether a field fits the experiment in code (weights of the
6
+ * right length, a holdout in `[0, 1)`) is `applyOverrides`' call, because only
7
+ * it knows the code's variants.
8
+ */
9
+ export interface ExperimentOverride {
10
+ readonly enabled?: boolean;
11
+ readonly weights?: readonly number[];
12
+ readonly holdout?: number;
13
+ }
14
+ export type ExperimentOverrides = Readonly<Record<string, ExperimentOverride>>;
15
+ /**
16
+ * The operator's overrides of the brand's experiments: `GET
17
+ * <base>/experiments`, the panel's `{ "experiments": { "<key>": {…} } }`.
18
+ *
19
+ * Read by the proxy on every request, so the answer comes from memory: the
20
+ * first call waits for the panel (bounded by `timeoutMs`), every later one is
21
+ * served from the cache — past the TTL too, while one refresh runs behind it
22
+ * (stale-while-revalidate). The source never throws and never makes a page
23
+ * wait on the panel twice. A failed refresh — an unreachable panel, a
24
+ * non-200, a body that is not the panel's shape — keeps the last good answer:
25
+ * a kill switch must not turn itself back on because the panel blinked. With
26
+ * no good answer yet (or no base URL) it is `{}`, the config in code.
27
+ */
28
+ export interface ExperimentsSourceOptions {
29
+ /** The brand's base on the panel (`LOCATIONS_API_URL`), read when asked; `null` → no overrides. */
30
+ baseUrl: () => string | null;
31
+ /** How long an answer is fresh; 30 s. */
32
+ ttlMs?: number;
33
+ /** How long the panel is given; 1.5 s. The proxy is waiting on the first call. */
34
+ timeoutMs?: number;
35
+ now?: () => number;
36
+ log?: Pick<Console, "error">;
37
+ }
38
+ export interface ExperimentsSource {
39
+ /** The overrides to apply now; `{}` when there are none or the panel cannot say. Never throws. */
40
+ overrides(): Promise<ExperimentOverrides>;
41
+ }
42
+ export declare const EXPERIMENTS_TTL_MS = 30000;
43
+ /**
44
+ * The panel's answer as overrides, or `null` when it is not the panel's shape.
45
+ * A field of the wrong type is dropped, an entry that is not an object is
46
+ * skipped; the rest stands.
47
+ */
48
+ export declare function parseExperimentOverrides(body: unknown): ExperimentOverrides | null;
49
+ export declare function createExperimentsSource(options: ExperimentsSourceOptions): ExperimentsSource;
@@ -0,0 +1,88 @@
1
+ import "server-only";
2
+ export const EXPERIMENTS_TTL_MS = 30_000;
3
+ const TIMEOUT_MS = 1_500;
4
+ const NONE = Object.freeze({});
5
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6
+ /**
7
+ * The panel's answer as overrides, or `null` when it is not the panel's shape.
8
+ * A field of the wrong type is dropped, an entry that is not an object is
9
+ * skipped; the rest stands.
10
+ */
11
+ export function parseExperimentOverrides(body) {
12
+ if (!isRecord(body) || !isRecord(body["experiments"]))
13
+ return null;
14
+ const out = {};
15
+ for (const [key, entry] of Object.entries(body["experiments"])) {
16
+ if (!isRecord(entry))
17
+ continue;
18
+ const { enabled, weights, holdout } = entry;
19
+ out[key] = {
20
+ ...(typeof enabled === "boolean" ? { enabled } : {}),
21
+ ...(Array.isArray(weights) && weights.every((w) => typeof w === "number") ? { weights } : {}),
22
+ ...(typeof holdout === "number" ? { holdout } : {}),
23
+ };
24
+ }
25
+ return out;
26
+ }
27
+ export function createExperimentsSource(options) {
28
+ const ttl = options.ttlMs ?? EXPERIMENTS_TTL_MS;
29
+ const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
30
+ const now = options.now ?? Date.now;
31
+ const log = options.log ?? console;
32
+ let cached = null;
33
+ let inflight = null;
34
+ /** The panel's overrides, or `null` when it could not say. */
35
+ async function load(base) {
36
+ let response;
37
+ try {
38
+ // `no-store`: the cache is this module's; Next's data cache would add a second TTL.
39
+ response = await fetch(`${base}/experiments`, { cache: "no-store", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
40
+ }
41
+ catch (cause) {
42
+ log.error("experiments: the panel is unreachable, keeping the last good overrides", cause);
43
+ return null;
44
+ }
45
+ if (response.status !== 200) {
46
+ log.error(`experiments: the panel answered ${response.status}, keeping the last good overrides`);
47
+ return null;
48
+ }
49
+ let body;
50
+ try {
51
+ body = await response.json();
52
+ }
53
+ catch (cause) {
54
+ log.error("experiments: the panel's body is not JSON, keeping the last good overrides", cause);
55
+ return null;
56
+ }
57
+ const parsed = parseExperimentOverrides(body);
58
+ if (parsed === null)
59
+ log.error("experiments: the panel's body is not { experiments: {…} }, keeping the last good overrides");
60
+ return parsed;
61
+ }
62
+ // One request at a time, however many visitors arrive while it runs.
63
+ function refresh(base) {
64
+ inflight ??= load(base)
65
+ .then(loaded => {
66
+ // A failure still restarts the clock, so a down panel is asked once per TTL.
67
+ const value = loaded ?? (cached?.base === base ? cached.value : NONE);
68
+ cached = { base, at: now(), value };
69
+ return value;
70
+ })
71
+ .finally(() => {
72
+ inflight = null;
73
+ });
74
+ return inflight;
75
+ }
76
+ return {
77
+ async overrides() {
78
+ const base = options.baseUrl();
79
+ if (!base)
80
+ return NONE;
81
+ if (cached?.base !== base)
82
+ return refresh(base);
83
+ if (now() - cached.at >= ttl)
84
+ void refresh(base);
85
+ return cached.value;
86
+ },
87
+ };
88
+ }
@@ -6,6 +6,8 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv, type EnvSource, type ServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError, type PlaceSource, type PlaceSourceOptions } from "./place-source.js";
9
+ export { createExperimentsSource, EXPERIMENTS_TTL_MS, parseExperimentOverrides, type ExperimentOverride, type ExperimentOverrides, type ExperimentsSource, type ExperimentsSourceOptions, } from "./experiments-source.js";
10
+ export { declarationProblem, declareExperiments, experimentsDeclaredBody, type DeclarationContext, type DeclaredExperiment, type DeclareOptions, type DeclareOutcome, type ExperimentDeclarationV1, type ExperimentsDeclaration, type ExperimentsDeclaredEvent, } from "./experiments-declared.js";
9
11
  export { createPricingSource, type PricingSource, type PricingSourceOptions } from "./pricing-source.js";
10
12
  export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
11
13
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
@@ -6,6 +6,8 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError } from "./place-source.js";
9
+ export { createExperimentsSource, EXPERIMENTS_TTL_MS, parseExperimentOverrides, } from "./experiments-source.js";
10
+ export { declarationProblem, declareExperiments, experimentsDeclaredBody, } from "./experiments-declared.js";
9
11
  export { createPricingSource } from "./pricing-source.js";
10
12
  export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
11
13
  export { openSqliteLeadStore } from "./lead-store-sqlite.js";
@@ -3,6 +3,7 @@ import type { BookingRequest } from "../core/booking/model.js";
3
3
  import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
4
4
  import type { LeadFlow } from "../core/pricing/flow.js";
5
5
  import type { ServerEnv } from "./env.js";
6
+ import { type DeclareOutcome, type ExperimentsDeclaration } from "./experiments-declared.js";
6
7
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
7
8
  import type { WebhookSigning } from "./webhook-signature.js";
8
9
  /** What the brand's body builder is told besides the lead. */
@@ -34,6 +35,14 @@ export interface LeadWebhookContext {
34
35
  * the properties. `panelFlowProperties` writes them as the panel reads them.
35
36
  */
36
37
  flow?: PanelFlow;
38
+ /**
39
+ * The visitor's analytics id as the page posted it (`ANALYTICS_ID_FIELD`),
40
+ * for `lead.created`'s `analytics_id` — so PostHog joins the lead to the
41
+ * visit. Absent from a page without the script, an older page, or one
42
+ * without analytics. Write it only once the panel's contract has the
43
+ * property: it refuses an unknown one, and the outbox would park the lead.
44
+ */
45
+ analyticsId?: string;
37
46
  }
38
47
  /** A lead's sale as the panel's `lead.created` may carry it. */
39
48
  export interface PanelFlow {
@@ -108,6 +117,7 @@ export interface LeadWebhook {
108
117
  locale: string;
109
118
  formId: string;
110
119
  leadRef?: string;
120
+ analyticsId?: string;
111
121
  }): number;
112
122
  tick(): Promise<TickReport>;
113
123
  start(intervalMs?: number): void;
@@ -129,6 +139,13 @@ export interface LeadWebhook {
129
139
  * suspect held back); `off` without `panelBooking` or a `buildBookingBody`.
130
140
  */
131
141
  requestBooking?(request: BookingRequest): BookingQueued;
142
+ /**
143
+ * Queues `experiments.declared@1` — the experiments this build runs — in
144
+ * the same outbox, signed as a lead is. Experiments the panel would refuse
145
+ * are left out and listed in `skipped`. `declareExperiments` is the safe
146
+ * wrapper for a start.
147
+ */
148
+ declareExperiments?(experiments: ExperimentsDeclaration, summaries?: Readonly<Record<string, string>>): DeclareOutcome;
132
149
  readonly outbox: WebhookOutbox;
133
150
  }
134
151
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
@@ -1,6 +1,7 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { suspectOf } from "../core/lead.js";
4
+ import { experimentsDeclaredBody } from "./experiments-declared.js";
4
5
  import { openWebhookOutbox } from "./webhook-outbox.js";
5
6
  /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
6
7
  export function panelFlowOf(lead) {
@@ -45,6 +46,7 @@ export function leadWebhook(site, env, options) {
45
46
  throw new Error("LEAD_WEBHOOK_URL: the webhook outbox needs the sqlite lead store");
46
47
  const outbox = openWebhookOutbox(env.leadsDb.path, { ...env.leadWebhook, signing }, outboxOptions);
47
48
  const now = options.now ?? Date.now;
49
+ const sourceId = env.leadWebhook.keyId;
48
50
  return {
49
51
  outbox,
50
52
  panelSuspect,
@@ -65,6 +67,11 @@ export function leadWebhook(site, env, options) {
65
67
  const row = outbox.enqueueOnce(body, `booking:${request.leadRef}`, lead);
66
68
  return row === null ? { kind: "duplicate" } : { kind: "queued", row };
67
69
  },
70
+ declareExperiments(experiments, summaries) {
71
+ const { body, skipped } = experimentsDeclaredBody(experiments, { brandId: site.brand.id, sourceId, at: new Date(now()), ...(summaries ? { summaries } : {}) });
72
+ const row = outbox.enqueue(JSON.stringify(body), `experiments:${body.events[0].id}`);
73
+ return { kind: "queued", row, skipped };
74
+ },
68
75
  enqueue(lead, id, meta) {
69
76
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
70
77
  const flow = panelFlow ? panelFlowOf(lead) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.11.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.12.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.11.0",
23
+ "@evinvest/kitstart": "^0.12.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",