@c15t/scripts 3.0.0-alpha.2 → 3.0.0-alpha.4
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/AGENTS.md +129 -63
- package/README.md +8 -29
- package/SKILL.md +33 -0
- package/dist/adobe-analytics.js +2 -0
- package/dist/ahrefs-analytics.js +2 -0
- package/dist/amplitude.js +2 -0
- package/dist/clearbit.js +2 -0
- package/dist/cloudflare-web-analytics.js +2 -0
- package/dist/cloudflare-zaraz.js +2 -0
- package/dist/crisp.js +2 -0
- package/dist/databuddy.js +2 -0
- package/dist/e2e-test-utils.js +2 -139
- package/dist/engine/compile.js +2 -89
- package/dist/engine/runtime.js +2 -448
- package/dist/events.js +2 -218
- package/dist/fathom-analytics.js +2 -0
- package/dist/front-chat.js +2 -0
- package/dist/google-tag-manager.js +2 -0
- package/dist/google-tag.js +2 -0
- package/dist/heap.js +2 -0
- package/dist/hightouch.js +2 -0
- package/dist/hotjar.js +2 -0
- package/dist/intercom.js +2 -0
- package/dist/klaviyo.js +2 -0
- package/dist/linkedin-insights.js +2 -0
- package/dist/logrocket.js +2 -0
- package/dist/matomo-analytics.js +2 -0
- package/dist/meta-pixel.js +2 -0
- package/dist/microsoft-clarity.js +2 -0
- package/dist/microsoft-uet.js +2 -0
- package/dist/mixpanel-analytics.js +2 -0
- package/dist/one-dollar-stats.js +2 -0
- package/dist/openai-pixel.js +2 -0
- package/dist/pinterest-tag.js +2 -0
- package/dist/pirsch.js +2 -0
- package/dist/plausible-analytics.js +2 -0
- package/dist/posthog.js +2 -0
- package/dist/promptwatch.js +2 -0
- package/dist/reddit-pixel.js +2 -0
- package/dist/registry.js +2 -422
- package/dist/resolve.js +2 -33
- package/dist/rudderstack.js +2 -0
- package/dist/rybbit-analytics.js +2 -0
- package/dist/segment.js +2 -0
- package/dist/snapchat-pixel.js +2 -0
- package/dist/tiktok-pixel.js +2 -0
- package/dist/types.js +2 -16
- package/dist/umami-analytics.js +2 -0
- package/dist/vendors/_shared/attributes.js +2 -14
- package/dist/vendors/_shared/google-consent.js +2 -27
- package/dist/vendors/_shared/install-builders.js +2 -21
- package/dist/vendors/_shared/required-id.js +2 -0
- package/dist/vendors/_shared/script-url.js +2 -28
- package/dist/vendors/ads-and-pixels/linkedin-insights.js +2 -48
- package/dist/vendors/ads-and-pixels/meta-pixel.js +2 -153
- package/dist/vendors/ads-and-pixels/microsoft-uet.js +2 -110
- package/dist/vendors/ads-and-pixels/openai-pixel.js +2 -88
- package/dist/vendors/ads-and-pixels/pinterest-tag.js +2 -123
- package/dist/vendors/ads-and-pixels/reddit-pixel.js +2 -107
- package/dist/vendors/ads-and-pixels/snapchat-pixel.js +2 -87
- package/dist/vendors/ads-and-pixels/tiktok-pixel.js +2 -89
- package/dist/vendors/ads-and-pixels/x-pixel.js +2 -48
- package/dist/vendors/analytics/adobe-analytics.js +2 -49
- package/dist/vendors/analytics/ahrefs-analytics.js +2 -27
- package/dist/vendors/analytics/amplitude.js +2 -134
- package/dist/vendors/analytics/clearbit.js +2 -28
- package/dist/vendors/analytics/cloudflare-web-analytics.js +2 -32
- package/dist/vendors/analytics/databuddy.js +2 -103
- package/dist/vendors/analytics/fathom-analytics.js +2 -35
- package/dist/vendors/analytics/google-tag.js +2 -78
- package/dist/vendors/analytics/heap.js +2 -134
- package/dist/vendors/analytics/hightouch.js +2 -109
- package/dist/vendors/analytics/hotjar.js +2 -44
- package/dist/vendors/analytics/logrocket.js +2 -58
- package/dist/vendors/analytics/matomo-analytics.js +2 -191
- package/dist/vendors/analytics/microsoft-clarity.js +2 -100
- package/dist/vendors/analytics/mixpanel-analytics.js +2 -93
- package/dist/vendors/analytics/one-dollar-stats.js +2 -30
- package/dist/vendors/analytics/pirsch.js +2 -67
- package/dist/vendors/analytics/plausible-analytics.js +2 -81
- package/dist/vendors/analytics/posthog.js +2 -200
- package/dist/vendors/analytics/promptwatch.js +2 -29
- package/dist/vendors/analytics/rudderstack.js +2 -183
- package/dist/vendors/analytics/rybbit-analytics.js +2 -63
- package/dist/vendors/analytics/segment.js +2 -65
- package/dist/vendors/analytics/umami-analytics.js +2 -39
- package/dist/vendors/analytics/vercel-analytics.js +2 -53
- package/dist/vendors/email-and-sms/klaviyo.js +2 -0
- package/dist/vendors/functional/crisp.js +2 -100
- package/dist/vendors/functional/front-chat.js +2 -64
- package/dist/vendors/functional/intercom.js +2 -45
- package/dist/vendors/tag-managers/cloudflare-zaraz.js +2 -98
- package/dist/vendors/tag-managers/google-tag-manager.js +2 -73
- package/dist/vercel-analytics.js +2 -0
- package/dist/x-pixel.js +2 -0
- package/dist-types/adobe-analytics.d.ts +2 -0
- package/dist-types/ahrefs-analytics.d.ts +2 -0
- package/dist-types/amplitude.d.ts +2 -0
- package/dist-types/clearbit.d.ts +2 -0
- package/dist-types/cloudflare-web-analytics.d.ts +2 -0
- package/dist-types/cloudflare-zaraz.d.ts +2 -0
- package/dist-types/crisp.d.ts +2 -0
- package/dist-types/databuddy.d.ts +2 -0
- package/dist-types/e2e-test-utils.d.ts +2 -0
- package/dist-types/engine/compile.d.ts +2 -3
- package/dist-types/engine/runtime.d.ts +2 -3
- package/dist-types/events.d.ts +2 -46
- package/dist-types/fathom-analytics.d.ts +2 -0
- package/dist-types/front-chat.d.ts +2 -0
- package/dist-types/google-tag-manager.d.ts +2 -0
- package/dist-types/google-tag.d.ts +2 -0
- package/dist-types/heap.d.ts +2 -0
- package/dist-types/hightouch.d.ts +2 -0
- package/dist-types/hotjar.d.ts +2 -0
- package/dist-types/intercom.d.ts +2 -0
- package/dist-types/klaviyo.d.ts +2 -0
- package/dist-types/linkedin-insights.d.ts +2 -0
- package/dist-types/logrocket.d.ts +2 -0
- package/dist-types/matomo-analytics.d.ts +2 -0
- package/dist-types/meta-pixel.d.ts +2 -0
- package/dist-types/microsoft-clarity.d.ts +2 -0
- package/dist-types/microsoft-uet.d.ts +2 -0
- package/dist-types/mixpanel-analytics.d.ts +2 -0
- package/dist-types/one-dollar-stats.d.ts +2 -0
- package/dist-types/openai-pixel.d.ts +2 -0
- package/dist-types/pinterest-tag.d.ts +2 -0
- package/dist-types/pirsch.d.ts +2 -0
- package/dist-types/plausible-analytics.d.ts +2 -0
- package/dist-types/posthog.d.ts +2 -0
- package/dist-types/promptwatch.d.ts +2 -0
- package/dist-types/reddit-pixel.d.ts +2 -0
- package/dist-types/registry.d.ts +2 -485
- package/dist-types/resolve.d.ts +2 -9
- package/dist-types/rudderstack.d.ts +2 -0
- package/dist-types/rybbit-analytics.d.ts +2 -0
- package/dist-types/segment.d.ts +2 -0
- package/dist-types/snapchat-pixel.d.ts +2 -0
- package/dist-types/tiktok-pixel.d.ts +2 -0
- package/dist-types/types.d.ts +2 -314
- package/dist-types/umami-analytics.d.ts +2 -0
- package/dist-types/vendors/_shared/attributes.d.ts +2 -35
- package/dist-types/vendors/_shared/google-consent.d.ts +2 -47
- package/dist-types/vendors/_shared/install-builders.d.ts +2 -30
- package/dist-types/vendors/_shared/required-id.d.ts +2 -0
- package/dist-types/vendors/_shared/script-url.d.ts +2 -75
- package/dist-types/vendors/ads-and-pixels/linkedin-insights.d.ts +2 -92
- package/dist-types/vendors/ads-and-pixels/meta-pixel.d.ts +2 -289
- package/dist-types/vendors/ads-and-pixels/microsoft-uet.d.ts +2 -105
- package/dist-types/vendors/ads-and-pixels/openai-pixel.d.ts +2 -211
- package/dist-types/vendors/ads-and-pixels/pinterest-tag.d.ts +2 -295
- package/dist-types/vendors/ads-and-pixels/reddit-pixel.d.ts +2 -210
- package/dist-types/vendors/ads-and-pixels/snapchat-pixel.d.ts +2 -171
- package/dist-types/vendors/ads-and-pixels/tiktok-pixel.d.ts +2 -106
- package/dist-types/vendors/ads-and-pixels/x-pixel.d.ts +2 -183
- package/dist-types/vendors/analytics/adobe-analytics.d.ts +2 -75
- package/dist-types/vendors/analytics/ahrefs-analytics.d.ts +2 -62
- package/dist-types/vendors/analytics/amplitude.d.ts +2 -234
- package/dist-types/vendors/analytics/clearbit.d.ts +2 -60
- package/dist-types/vendors/analytics/cloudflare-web-analytics.d.ts +2 -67
- package/dist-types/vendors/analytics/databuddy.d.ts +2 -147
- package/dist-types/vendors/analytics/fathom-analytics.d.ts +2 -90
- package/dist-types/vendors/analytics/google-tag.d.ts +2 -95
- package/dist-types/vendors/analytics/heap.d.ts +2 -316
- package/dist-types/vendors/analytics/hightouch.d.ts +2 -285
- package/dist-types/vendors/analytics/hotjar.d.ts +2 -73
- package/dist-types/vendors/analytics/logrocket.d.ts +2 -101
- package/dist-types/vendors/analytics/matomo-analytics.d.ts +2 -41
- package/dist-types/vendors/analytics/microsoft-clarity.d.ts +2 -97
- package/dist-types/vendors/analytics/mixpanel-analytics.d.ts +2 -113
- package/dist-types/vendors/analytics/one-dollar-stats.d.ts +2 -39
- package/dist-types/vendors/analytics/pirsch.d.ts +2 -96
- package/dist-types/vendors/analytics/plausible-analytics.d.ts +2 -122
- package/dist-types/vendors/analytics/posthog.d.ts +2 -175
- package/dist-types/vendors/analytics/promptwatch.d.ts +2 -36
- package/dist-types/vendors/analytics/rudderstack.d.ts +2 -330
- package/dist-types/vendors/analytics/rybbit-analytics.d.ts +2 -82
- package/dist-types/vendors/analytics/segment.d.ts +2 -164
- package/dist-types/vendors/analytics/umami-analytics.d.ts +2 -93
- package/dist-types/vendors/analytics/vercel-analytics.d.ts +2 -66
- package/dist-types/vendors/email-and-sms/klaviyo.d.ts +2 -0
- package/dist-types/vendors/functional/crisp.d.ts +2 -78
- package/dist-types/vendors/functional/front-chat.d.ts +2 -62
- package/dist-types/vendors/functional/intercom.d.ts +2 -135
- package/dist-types/vendors/tag-managers/cloudflare-zaraz.d.ts +2 -39
- package/dist-types/vendors/tag-managers/google-tag-manager.d.ts +2 -96
- package/dist-types/vercel-analytics.d.ts +2 -0
- package/dist-types/x-pixel.d.ts +2 -0
- package/docs/README.md +129 -63
- package/docs/assets/v3/bottom-bar.png +0 -0
- package/docs/assets/v3/brand-card.png +0 -0
- package/docs/assets/v3/brand-preferences.png +0 -0
- package/docs/assets/v3/choice-wall.png +0 -0
- package/docs/assets/v3/headless-bar-html.png +0 -0
- package/docs/assets/v3/headless-bar-mobile.png +0 -0
- package/docs/assets/v3/headless-bar.png +0 -0
- package/docs/assets/v3/slim-bar.png +0 -0
- package/docs/concepts/choose-your-setup.md +87 -0
- package/docs/concepts/consent-categories.md +84 -0
- package/docs/{guides → concepts}/consent-state.md +89 -105
- package/docs/{guides → concepts}/data-fetching.md +31 -27
- package/docs/concepts/how-consent-works.md +123 -0
- package/docs/concepts/policies.md +71 -0
- package/docs/customization/class-names.md +202 -0
- package/docs/customization/dark-mode.md +157 -0
- package/docs/customization/motion.md +119 -0
- package/docs/customization/overview.md +67 -34
- package/docs/customization/recipes.md +839 -49
- package/docs/customization/slots.md +216 -35
- package/docs/customization/stylesheets.md +147 -0
- package/docs/customization/tailwind.md +842 -0
- package/docs/customization/tokens.md +163 -96
- package/docs/customization/translations.md +60 -3
- package/docs/frameworks/astro/embeds.md +160 -0
- package/docs/frameworks/astro/network-blocker.md +86 -0
- package/docs/frameworks/astro/scripts.md +155 -0
- package/docs/frameworks/html/embeds.md +142 -0
- package/docs/frameworks/html/network-blocker.md +105 -0
- package/docs/frameworks/html/scripts.md +164 -0
- package/docs/frameworks/javascript/scripts.md +137 -0
- package/docs/frameworks/next/embeds.md +90 -0
- package/docs/frameworks/next/network-blocker.md +153 -0
- package/docs/frameworks/next/scripts.md +196 -0
- package/docs/frameworks/nuxt/embeds.md +81 -0
- package/docs/frameworks/nuxt/network-blocker.md +97 -0
- package/docs/frameworks/nuxt/scripts.md +89 -0
- package/docs/frameworks/react/embeds.md +89 -0
- package/docs/frameworks/react/network-blocker.md +140 -0
- package/docs/frameworks/react/scripts.md +115 -0
- package/docs/frameworks/svelte/embeds.md +96 -0
- package/docs/frameworks/svelte/network-blocker.md +141 -0
- package/docs/frameworks/svelte/scripts.md +144 -0
- package/docs/frameworks/sveltekit/embeds.md +103 -0
- package/docs/frameworks/sveltekit/network-blocker.md +159 -0
- package/docs/frameworks/sveltekit/scripts.md +172 -0
- package/docs/frameworks/tanstack-start/embeds.md +96 -0
- package/docs/frameworks/tanstack-start/network-blocker.md +145 -0
- package/docs/frameworks/tanstack-start/scripts.md +103 -0
- package/docs/frameworks/vue/embeds.md +84 -0
- package/docs/frameworks/vue/network-blocker.md +99 -0
- package/docs/frameworks/vue/scripts.md +93 -0
- package/docs/guides/banner-experiments.md +654 -0
- package/docs/guides/troubleshooting.md +120 -47
- package/docs/guides/verify-consent.md +81 -49
- package/docs/integrations/adobe-analytics.md +167 -159
- package/docs/integrations/ahrefs-analytics.md +152 -154
- package/docs/integrations/amplitude.md +162 -156
- package/docs/integrations/building-integrations.md +136 -37
- package/docs/integrations/clearbit.md +154 -154
- package/docs/integrations/cloudflare-web-analytics.md +156 -156
- package/docs/integrations/cloudflare-zaraz.md +209 -261
- package/docs/integrations/crisp.md +164 -158
- package/docs/integrations/databuddy.md +157 -173
- package/docs/integrations/fathom-analytics.md +158 -156
- package/docs/integrations/front-chat.md +167 -167
- package/docs/integrations/google-maps.md +118 -83
- package/docs/integrations/google-tag-manager.md +178 -163
- package/docs/integrations/google-tag.md +163 -160
- package/docs/integrations/heap.md +163 -155
- package/docs/integrations/hightouch.md +161 -157
- package/docs/integrations/hotjar.md +159 -155
- package/docs/integrations/intercom.md +183 -153
- package/docs/integrations/klaviyo.md +486 -0
- package/docs/integrations/linkedin-insights.md +174 -150
- package/docs/integrations/logrocket.md +160 -156
- package/docs/integrations/matomo-analytics.md +188 -178
- package/docs/integrations/meta-pixel.md +188 -150
- package/docs/integrations/microsoft-clarity.md +163 -155
- package/docs/integrations/microsoft-uet.md +148 -154
- package/docs/integrations/mixpanel-analytics.md +155 -160
- package/docs/integrations/one-dollar-stats.md +166 -165
- package/docs/integrations/openai-pixel.md +204 -301
- package/docs/integrations/overview.md +137 -80
- package/docs/integrations/pinterest-tag.md +191 -183
- package/docs/integrations/pirsch.md +169 -159
- package/docs/integrations/plausible-analytics.md +172 -158
- package/docs/integrations/posthog.md +313 -240
- package/docs/integrations/promptwatch.md +154 -154
- package/docs/integrations/reddit-pixel.md +185 -157
- package/docs/integrations/rudderstack.md +201 -186
- package/docs/integrations/rybbit-analytics.md +171 -160
- package/docs/integrations/segment.md +182 -154
- package/docs/integrations/snapchat-pixel.md +186 -156
- package/docs/integrations/tiktok-pixel.md +171 -150
- package/docs/integrations/umami-analytics.md +163 -158
- package/docs/integrations/vercel-analytics.md +166 -157
- package/docs/integrations/x-pixel.md +176 -150
- package/docs/integrations/youtube.md +121 -86
- package/docs/upgrade-v3.md +496 -467
- package/package.json +10 -257
- package/dist-types/__tests__/helpers.d.ts +0 -141
- package/docs/assets/v3/brand-bar.png +0 -0
- package/docs/assets/v3/mobile-card.png +0 -0
- package/docs/assets/v3/preferences.png +0 -0
- package/docs/frameworks/javascript/script-loader.md +0 -100
- package/docs/frameworks/next/script-loader.md +0 -216
- package/docs/frameworks/react/script-loader.md +0 -69
- package/docs/guides/deployment-modes.md +0 -75
- package/docs/guides/shared-consent-controls.md +0 -158
- package/docs/integrations/clear-on-revocation.md +0 -167
- package/docs/integrations/granular-consent.md +0 -210
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Banner experiments
|
|
3
|
+
description: Run A/B tests on consent banner presentation with any feature-flag
|
|
4
|
+
provider or built-in weighted assignment, and attribute every impression and
|
|
5
|
+
choice to its arm.
|
|
6
|
+
group: guides
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Vary presentation, not policy
|
|
10
|
+
|
|
11
|
+
An experiment changes only how the prompt and the preference center look:
|
|
12
|
+
variant, position, layout, primary actions, blocking. The policy rule, its
|
|
13
|
+
categories and its copy stay the same for every arm, so every arm records a
|
|
14
|
+
choice under the same policy fingerprint.
|
|
15
|
+
|
|
16
|
+
Your normal `presentation` is the `control` arm; without one, `control` is
|
|
17
|
+
the stock banner. Every other arm lists only what it changes. Add the
|
|
18
|
+
experiment to the provider you already have and pass the arm your feature flag
|
|
19
|
+
resolved. In the React example, the `Consent` wrapper takes the arm as a prop:
|
|
20
|
+
|
|
21
|
+
```tsx title="src/consent.tsx"
|
|
22
|
+
import { defineExperiment } from 'c15t';
|
|
23
|
+
import {
|
|
24
|
+
ConsentBanner,
|
|
25
|
+
ConsentDialog,
|
|
26
|
+
ConsentDialogLink,
|
|
27
|
+
ConsentProvider,
|
|
28
|
+
hosted,
|
|
29
|
+
} from 'c15t/react';
|
|
30
|
+
import type { ConsentProviderCallbacks } from 'c15t/react';
|
|
31
|
+
import type { ReactNode } from 'react';
|
|
32
|
+
|
|
33
|
+
import { scripts } from './scripts';
|
|
34
|
+
|
|
35
|
+
import 'c15t/react/styles.css';
|
|
36
|
+
|
|
37
|
+
const mode = hosted({ url: 'https://your-project.inth.app' });
|
|
38
|
+
|
|
39
|
+
// `control` is the stock banner. `wall` blocks the page until the visitor
|
|
40
|
+
// chooses.
|
|
41
|
+
const bannerExperiment = defineExperiment({
|
|
42
|
+
arms: { wall: { prompt: { variant: 'wall' } } },
|
|
43
|
+
id: 'banner-shape',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const pushToDataLayer = (event: Record<string, unknown>) => {
|
|
47
|
+
const page = window as Window & { dataLayer?: unknown[] };
|
|
48
|
+
page.dataLayer ??= [];
|
|
49
|
+
page.dataLayer.push(event);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Forward each impression and choice made under an arm to your analytics.
|
|
53
|
+
const callbacks = {
|
|
54
|
+
onChoiceRecorded: ({ consentAction, experiment }) => {
|
|
55
|
+
if (experiment) {
|
|
56
|
+
pushToDataLayer({
|
|
57
|
+
arm: experiment.arm,
|
|
58
|
+
consent_action: consentAction,
|
|
59
|
+
event: 'c15t_choice_recorded',
|
|
60
|
+
experiment_id: experiment.id,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
onSurfaceShown: ({ experiment, surface }) => {
|
|
65
|
+
if (experiment) {
|
|
66
|
+
pushToDataLayer({
|
|
67
|
+
arm: experiment.arm,
|
|
68
|
+
event: 'c15t_surface_shown',
|
|
69
|
+
experiment_id: experiment.id,
|
|
70
|
+
surface,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
} satisfies ConsentProviderCallbacks;
|
|
75
|
+
|
|
76
|
+
export const Consent = ({
|
|
77
|
+
arm,
|
|
78
|
+
children,
|
|
79
|
+
}: {
|
|
80
|
+
/**
|
|
81
|
+
* The arm your flag provider resolved. Omit it to let c15t pick, or pass
|
|
82
|
+
* `off` to leave this visitor out of the experiment.
|
|
83
|
+
*/
|
|
84
|
+
arm?: 'control' | 'wall' | 'off';
|
|
85
|
+
children: ReactNode;
|
|
86
|
+
}) => (
|
|
87
|
+
<ConsentProvider
|
|
88
|
+
options={{
|
|
89
|
+
callbacks,
|
|
90
|
+
experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
|
|
91
|
+
mode,
|
|
92
|
+
scripts,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
<ConsentBanner />
|
|
97
|
+
<ConsentDialog />
|
|
98
|
+
<footer>
|
|
99
|
+
<ConsentDialogLink>Privacy settings</ConsentDialogLink>
|
|
100
|
+
</footer>
|
|
101
|
+
</ConsentProvider>
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`defineExperiment()` from `c15t` returns its argument and has TypeScript check
|
|
106
|
+
the arm names in `arm` and `split`. The callbacks forward each impression and
|
|
107
|
+
choice to `window.dataLayer`; see
|
|
108
|
+
[send the events to your own analytics](#send-the-events-to-your-own-analytics-too).
|
|
109
|
+
|
|
110
|
+
Omit `arm` to let c15t pick, and set `split` to weight the arms:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
experiment: { ...bannerExperiment, split: { control: 60, wall: 40 } }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Field | Purpose |
|
|
117
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| `id` | Stable experiment name, recorded with every choice |
|
|
119
|
+
| `arms` | What each arm changes, merged over `presentation`. `control` is your `presentation` and is not listed |
|
|
120
|
+
| `arm` | The arm your flag resolved: `control` or a key of `arms` |
|
|
121
|
+
| `split` | Relative weights when c15t picks the arm; default equal. Keys are `control` and the arms |
|
|
122
|
+
| `acknowledgeDiagnostics` | Run an arm that trips a presentation diagnostic and record that you reviewed it |
|
|
123
|
+
|
|
124
|
+
c15t merges the arm over `presentation`, exposes it as `snapshot.experiment`,
|
|
125
|
+
sends it with `/init`, and records it on the choices of visitors the banner
|
|
126
|
+
showed it to, so you can compare opt-in rate and time to decision per arm.
|
|
127
|
+
|
|
128
|
+
The same option exists in `c15t/next` (`ConsentRoot` `options`), `c15t/vue`,
|
|
129
|
+
`@c15t/svelte`, the `c15t()` Astro integration from `c15t/astro`, and
|
|
130
|
+
`@c15t/browser`. On a server-rendered page in Next.js, TanStack Start or
|
|
131
|
+
SvelteKit, pass it to `resolveConsent` instead, which counts the arm and hands
|
|
132
|
+
it to the client; see [Vercel Flags SDK](#vercel-flags-sdk).
|
|
133
|
+
|
|
134
|
+
`experiment` is read once, when the provider mounts. Changing it later has
|
|
135
|
+
no effect; remount the provider (a `key` in React) to switch experiments.
|
|
136
|
+
|
|
137
|
+
Assignment and arm validation load as a separate chunk, only on pages that
|
|
138
|
+
set `experiment`. A site without an experiment does not download them.
|
|
139
|
+
|
|
140
|
+
### Astro
|
|
141
|
+
|
|
142
|
+
The Astro banner is server-rendered HTML that the browser only shows or
|
|
143
|
+
hides, so the arm is resolved on the server. To pick it per request, set
|
|
144
|
+
`middleware: false` in `c15t()` and compose the consent middleware yourself
|
|
145
|
+
with `experimentArm`, where `bannerExperimentFlag` stands for your flag lookup:
|
|
146
|
+
|
|
147
|
+
```ts title="src/middleware.ts"
|
|
148
|
+
import { consentMiddleware } from 'c15t/astro/middleware';
|
|
149
|
+
|
|
150
|
+
export const onRequest = consentMiddleware({
|
|
151
|
+
experimentArm: async (context) => await bannerExperimentFlag(context),
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Return `undefined` to run no experiment for that request. A fixed
|
|
156
|
+
`experiment.arm` in `c15t()` puts every visitor in one arm, which only
|
|
157
|
+
suits a staged rollout. Prerendered routes render once for every visitor, so
|
|
158
|
+
they get no per-request arm. `c15t()` throws at config time when
|
|
159
|
+
`experiment` has neither.
|
|
160
|
+
|
|
161
|
+
### Vary the theme
|
|
162
|
+
|
|
163
|
+
An arm can carry `theme` overrides next to its presentation fragment. They
|
|
164
|
+
merge over the host `theme` one token group deep, so an arm can change one
|
|
165
|
+
colour or radius and keep the rest of your palette. Arrays and scalars are
|
|
166
|
+
replaced.
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
experiment: {
|
|
170
|
+
id: 'button-style',
|
|
171
|
+
arms: {
|
|
172
|
+
bold: {
|
|
173
|
+
theme: {
|
|
174
|
+
colors: { primary: '#0a0a0a' },
|
|
175
|
+
radius: { lg: '4px' },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Read the merged theme with `useResolvedTheme()` in React,
|
|
183
|
+
`useResolvedTheme(theme)` in Vue, `getConsentManager().theme` in Svelte and
|
|
184
|
+
`client.theme` in `@c15t/browser`. Astro renders the arm's tokens with the
|
|
185
|
+
banner. In React, `consentActions` and slot overrides apply through the
|
|
186
|
+
provider, but colour, radius and other tokens reach the page through
|
|
187
|
+
`ConsentTheme`. Render it from the resolved theme in a client component inside
|
|
188
|
+
the provider, with both imported from `c15t/react`:
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
<ConsentTheme theme={useResolvedTheme()} />;
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Rendered from a client component, `ConsentTheme` ships the theme generator.
|
|
195
|
+
With an arm from your flag, you can instead render
|
|
196
|
+
`<ConsentTheme theme={resolveExperimentTheme(theme, experiment, { arm })} />`
|
|
197
|
+
from a Server Component. Per-action styling through
|
|
198
|
+
`theme.consentActions` runs through the same prominence check as
|
|
199
|
+
presentation: an arm that fills accept and outlines reject trips
|
|
200
|
+
`equivalent-prominence-overridden` and needs `acknowledgeDiagnostics: true`.
|
|
201
|
+
|
|
202
|
+
## Resolve the arm with a flag provider
|
|
203
|
+
|
|
204
|
+
Resolve the arm wherever your flags live and pass its name as `arm`. c15t
|
|
205
|
+
records `assignedBy: 'host'` and never re-assigns a visitor you assigned.
|
|
206
|
+
|
|
207
|
+
### Vercel Flags SDK
|
|
208
|
+
|
|
209
|
+
Give the flag three values. `off` keeps a visitor out of the test; `control`
|
|
210
|
+
and `wall` split the rest. Set the weights in the Vercel dashboard, so
|
|
211
|
+
you can roll out and widen the test without a deploy: start at 90% `off`,
|
|
212
|
+
check that both arms arrive, then move to 0% `off` for the real run.
|
|
213
|
+
|
|
214
|
+
```ts title="src/flags.ts"
|
|
215
|
+
import { vercelAdapter } from '@flags-sdk/vercel';
|
|
216
|
+
import { flag } from 'flags/next';
|
|
217
|
+
|
|
218
|
+
export const bannerExperimentFlag = flag<'off' | 'control' | 'wall'>({
|
|
219
|
+
key: 'banner-shape',
|
|
220
|
+
adapter: vercelAdapter(),
|
|
221
|
+
identify, // your existing identify
|
|
222
|
+
defaultValue: 'off',
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Keep `identify` returning a stable id. Vercel splits on it, so a visitor
|
|
227
|
+
keeps their arm when you change the weights.
|
|
228
|
+
|
|
229
|
+
Resolve the flag where consent resolves and pass the experiment to
|
|
230
|
+
`resolveConsent`. The server reports the arm with `/init`, and the returned
|
|
231
|
+
state carries the experiment to `ConsentRoot`, so the client needs no
|
|
232
|
+
`experiment` option of its own. The Next.js example does this in the root
|
|
233
|
+
layout of its `/experiment` route:
|
|
234
|
+
|
|
235
|
+
```tsx title="app/layout.tsx"
|
|
236
|
+
import { resolveConsent } from 'c15t/next/server';
|
|
237
|
+
import { Suspense } from 'react';
|
|
238
|
+
import type { ReactNode } from 'react';
|
|
239
|
+
|
|
240
|
+
import { consentConfig } from '@/c15t.config';
|
|
241
|
+
import { ExperimentConsent } from '@/components/experiment-consent';
|
|
242
|
+
import { bannerExperiment } from '@/lib/experiment';
|
|
243
|
+
import { bannerExperimentFlag } from '@/lib/flags';
|
|
244
|
+
|
|
245
|
+
import '@/styles/globals.css';
|
|
246
|
+
|
|
247
|
+
const ResolvedConsent = async ({ children }: { children: ReactNode }) => {
|
|
248
|
+
const arm = await bannerExperimentFlag();
|
|
249
|
+
const state = await resolveConsent({
|
|
250
|
+
config: consentConfig,
|
|
251
|
+
// `off` keeps this visitor out of the experiment.
|
|
252
|
+
experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return <ExperimentConsent state={state}>{children}</ExperimentConsent>;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const RootLayout = ({ children }: { children: ReactNode }) => (
|
|
259
|
+
<html lang="en">
|
|
260
|
+
<body>
|
|
261
|
+
<Suspense fallback={null}>
|
|
262
|
+
<ResolvedConsent>{children}</ResolvedConsent>
|
|
263
|
+
</Suspense>
|
|
264
|
+
</body>
|
|
265
|
+
</html>
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
export default RootLayout;
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
In the example, `lib/flags.ts` stands in for the flag above and reads the arm
|
|
272
|
+
from the URL. `ExperimentConsent` is the `Consent` wrapper from the
|
|
273
|
+
[App Router guide](https://c15t.com/docs/frameworks/next/app-router) with the event callbacks
|
|
274
|
+
from [send the events to your own analytics](#send-the-events-to-your-own-analytics-too)
|
|
275
|
+
in `options`. The layout awaits consent inside `<Suspense>`, as in
|
|
276
|
+
[render the banner in the server HTML](https://c15t.com/docs/frameworks/next/app-router#render-the-banner-in-the-server-html),
|
|
277
|
+
so the banner is in the server HTML already showing the visitor's arm.
|
|
278
|
+
`resolveConsent` in `c15t/tanstack-start/server` and `@c15t/svelte/server`
|
|
279
|
+
takes the same `experiment`.
|
|
280
|
+
|
|
281
|
+
If you pass `resolveConsent()` to `ConsentRoot` without awaiting it (the
|
|
282
|
+
streaming layout), the client mounts before the state arrives. Pass the
|
|
283
|
+
same experiment to the client options as well; c15t warns in development
|
|
284
|
+
when the streamed state carries an experiment the client did not get.
|
|
285
|
+
|
|
286
|
+
### PostHog
|
|
287
|
+
|
|
288
|
+
PostHog resolves flags asynchronously in the browser. Because `experiment`
|
|
289
|
+
is read once at mount, wait for the flags, then mount the provider with the
|
|
290
|
+
resolved arm. Do not wait forever: a blocked or slow flags request would
|
|
291
|
+
leave the visitor with no banner and you with no consent. After a second,
|
|
292
|
+
mount without the experiment. Those visitors see `presentation` and are not
|
|
293
|
+
counted in either arm. This component wraps the `Consent` component from
|
|
294
|
+
[the first example](#vary-presentation-not-policy):
|
|
295
|
+
|
|
296
|
+
```tsx title="src/flagged-consent.tsx"
|
|
297
|
+
import posthog from 'posthog-js';
|
|
298
|
+
import { useEffect, useState } from 'react';
|
|
299
|
+
import type { ReactNode } from 'react';
|
|
300
|
+
|
|
301
|
+
import { Consent } from './consent';
|
|
302
|
+
|
|
303
|
+
export const FlaggedConsent = ({ children }: { children: ReactNode }) => {
|
|
304
|
+
const [arm, setArm] = useState<'control' | 'wall' | 'off' | null>(null);
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
// The first answer wins: the provider reads the arm once.
|
|
307
|
+
const fallback = setTimeout(
|
|
308
|
+
() => setArm((current) => current ?? 'off'),
|
|
309
|
+
1000
|
|
310
|
+
);
|
|
311
|
+
const unsubscribe = posthog.onFeatureFlags(() => {
|
|
312
|
+
const flag = posthog.getFeatureFlag('banner-shape');
|
|
313
|
+
setArm((current) => current ?? (flag === 'wall' ? 'wall' : 'control'));
|
|
314
|
+
});
|
|
315
|
+
return () => {
|
|
316
|
+
clearTimeout(fallback);
|
|
317
|
+
unsubscribe();
|
|
318
|
+
};
|
|
319
|
+
}, []);
|
|
320
|
+
if (arm === null) {
|
|
321
|
+
// Flags not loaded yet: no provider, no banner.
|
|
322
|
+
return children;
|
|
323
|
+
}
|
|
324
|
+
return <Consent arm={arm}>{children}</Consent>;
|
|
325
|
+
};
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### LaunchDarkly, GrowthBook, Statsig
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
const arm = ldClient.stringVariation('banner-shape', 'control');
|
|
332
|
+
const arm = growthbook.getFeatureValue('banner-shape', 'control');
|
|
333
|
+
const arm = StatsigClient.instance()
|
|
334
|
+
.getExperiment('banner-shape')
|
|
335
|
+
.get('arm', 'control');
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
An `arm` that is not `control` or a key of `arms` logs an error and runs no
|
|
339
|
+
experiment for that visitor; the page still renders. Treat that as a flag
|
|
340
|
+
misconfiguration.
|
|
341
|
+
|
|
342
|
+
## Let c15t assign the arm
|
|
343
|
+
|
|
344
|
+
Omit `arm` and c15t picks one by `split` (equal by default) when the page
|
|
345
|
+
starts, before `/init`, and records `assignedBy: 'c15t'`. A visitor who
|
|
346
|
+
already saw an arm keeps it.
|
|
347
|
+
|
|
348
|
+
The banner waits until the arm is picked, so the visitor never sees the base
|
|
349
|
+
banner swap for their arm. On a server-rendered page that means the banner is
|
|
350
|
+
not in the server HTML; it appears once the browser has loaded the
|
|
351
|
+
assignment chunk. If the chunk fails to load, the base banner shows and no
|
|
352
|
+
experiment runs. To keep the banner in the server HTML, resolve the arm on
|
|
353
|
+
the server and pass it as `arm`.
|
|
354
|
+
|
|
355
|
+
Once the banner has shown the arm, c15t stores `{ id, arm }` under
|
|
356
|
+
`c15t-experiment-v1` in localStorage (a cookie when localStorage is
|
|
357
|
+
unavailable), so the visitor keeps seeing the banner they saw. Nothing is
|
|
358
|
+
stored for a visitor who is never prompted, and nothing is stored for a
|
|
359
|
+
host-resolved arm: your flag provider decides that one on every visit. The
|
|
360
|
+
record holds no identifier. It exists only to keep the consent banner
|
|
361
|
+
consistent, so treat it like the consent record itself when you describe
|
|
362
|
+
your storage.
|
|
363
|
+
|
|
364
|
+
A `split` that gives no arm a positive weight, or that names an arm that does
|
|
365
|
+
not exist, logs an error and runs no experiment. An arm missing from the
|
|
366
|
+
split gets no visitors.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
experiment: {
|
|
370
|
+
id: 'banner-shape',
|
|
371
|
+
arms: { wall: { prompt: { variant: 'wall' } } },
|
|
372
|
+
split: { control: 60, wall: 40 },
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Changing `id` starts a new experiment and re-assigns everyone. Removing an
|
|
377
|
+
arm re-assigns only the visitors who were in it. For a clean analysis, change
|
|
378
|
+
`id` rather than editing the arms of a running experiment.
|
|
379
|
+
|
|
380
|
+
Built-in assignment is not available in Astro; see
|
|
381
|
+
[Astro](#astro).
|
|
382
|
+
|
|
383
|
+
## Acknowledge presentation diagnostics
|
|
384
|
+
|
|
385
|
+
Each arm is resolved under the visitor's policy the same way `presentation`
|
|
386
|
+
is. An arm that trips a diagnostic, for example
|
|
387
|
+
`equivalent-prominence-overridden` because it makes accept primary while
|
|
388
|
+
reject stays neutral, is not shown under that policy: those visitors see the
|
|
389
|
+
base presentation, are not counted in the experiment, and c15t logs the
|
|
390
|
+
diagnostics. Set `acknowledgeDiagnostics: true` to run the arm anyway. c15t
|
|
391
|
+
then logs the diagnostics as a warning once per policy and records
|
|
392
|
+
`acknowledgedDiagnostics: true` with the arm on every choice. You own the
|
|
393
|
+
legal review of that arm; c15t records that you made it.
|
|
394
|
+
|
|
395
|
+
The check runs in the browser once the policy is known, so catch a rejected
|
|
396
|
+
arm before you deploy with `validateExperiment` in a test. `euPolicy` and
|
|
397
|
+
`presentation` stand for the policy and presentation your site uses:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
import { validateExperiment } from 'c15t/experiment';
|
|
401
|
+
|
|
402
|
+
test('banner-shape arms pass under the EU policy', () => {
|
|
403
|
+
validateExperiment(bannerExperiment, euPolicy, { presentation });
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
It throws for an invalid definition and for arms with unacknowledged
|
|
408
|
+
diagnostics.
|
|
409
|
+
|
|
410
|
+
## Read the assignment
|
|
411
|
+
|
|
412
|
+
```tsx
|
|
413
|
+
import {
|
|
414
|
+
useExperiment,
|
|
415
|
+
useResolvedPresentation,
|
|
416
|
+
useResolvedTheme,
|
|
417
|
+
} from 'c15t/react';
|
|
418
|
+
|
|
419
|
+
const { id, arm, assignedBy } = useExperiment() ?? {};
|
|
420
|
+
const presentation = useResolvedPresentation();
|
|
421
|
+
const theme = useResolvedTheme();
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
React exposes three hooks:
|
|
425
|
+
|
|
426
|
+
* `useExperiment()` returns the assigned arm (`id`, `arm`, `assignedBy`,
|
|
427
|
+
`acknowledgedDiagnostics`), or `null` while no experiment is configured
|
|
428
|
+
or no arm is assigned yet.
|
|
429
|
+
* `useResolvedPresentation()` returns `presentation` with the assigned
|
|
430
|
+
arm merged over it per surface. While no arm is assigned it returns
|
|
431
|
+
`presentation` itself. The stock banner, dialog and widget render from it,
|
|
432
|
+
as do `usePromptPresentation()` and `usePreferencesPresentation()`.
|
|
433
|
+
* `useResolvedTheme()` returns `theme` with the arm's `theme` merged one
|
|
434
|
+
token group deep over it. While no arm is assigned, or the arm has no
|
|
435
|
+
`theme`, it returns `theme` itself. The provider injects this merged theme.
|
|
436
|
+
|
|
437
|
+
Built-in assignment lands after mount, so `useExperiment()` is `null` on the
|
|
438
|
+
server render and during hydration and the resolved hooks return the base
|
|
439
|
+
values. The banner is held until then, and a clean preferences draft reseeds
|
|
440
|
+
from the arm's `preferences.defaults` when it lands. An
|
|
441
|
+
arm from your flag is known from the first render, on the server too.
|
|
442
|
+
|
|
443
|
+
Vue exposes `useExperiment()`, `useResolvedPresentation()` and
|
|
444
|
+
`useResolvedTheme(theme)`, Svelte `getConsentManager().experiment`,
|
|
445
|
+
`.presentation` and `.theme`, and `@c15t/browser` `client.presentation` and
|
|
446
|
+
`client.theme`. Every adapter also reports the arm on `snapshot.experiment`.
|
|
447
|
+
|
|
448
|
+
## What is recorded
|
|
449
|
+
|
|
450
|
+
An impression or a choice carries the arm only once the banner has shown it
|
|
451
|
+
in the current page. A returning visitor who reopens the preference center
|
|
452
|
+
from a footer link, or saves from an inline widget, never saw the arm's
|
|
453
|
+
banner, so their choice is recorded without it and does not count toward any
|
|
454
|
+
arm.
|
|
455
|
+
|
|
456
|
+
Each choice saved through `POST /subjects` carries in `metadata`:
|
|
457
|
+
|
|
458
|
+
| Key | Value |
|
|
459
|
+
| ------------------ | -------------------------------------------------------------- |
|
|
460
|
+
| `experiment` | `{ id, arm, assignedBy, acknowledgedDiagnostics }` |
|
|
461
|
+
| `timeToDecisionMs` | Milliseconds from the surface's first impression to the action |
|
|
462
|
+
|
|
463
|
+
`uiSource` on the same record names the surface (`banner`, `dialog`,
|
|
464
|
+
`widget`). The `surface:shown` and `choice:recorded` kernel events and the
|
|
465
|
+
`onSurfaceShown` and `onChoiceRecorded` callbacks carry the same
|
|
466
|
+
`experiment` object, so impressions and decisions can be joined per arm in
|
|
467
|
+
your analytics without a backend query.
|
|
468
|
+
|
|
469
|
+
## Measure the results
|
|
470
|
+
|
|
471
|
+
An opt-in rate needs two counts per arm: how many visitors the banner was
|
|
472
|
+
owed to, and how many of them accepted. c15t sends both to the backend on
|
|
473
|
+
its own; you do not wire anything up.
|
|
474
|
+
|
|
475
|
+
* **Visitors owed the banner.** While a visitor has no stored choice, every
|
|
476
|
+
`/init` carries their arm in an `x-c15t-experiment: <id>=<arm>` header,
|
|
477
|
+
and the backend puts it on that request's session report as
|
|
478
|
+
`experiment: { id, arm }`. In manifest mode, the server render or the
|
|
479
|
+
init route puts it on the report it sends to `POST /sessions`. A visitor
|
|
480
|
+
who already chose is not shown the banner, so is not counted.
|
|
481
|
+
* **Choices.** Every choice saved through `POST /subjects` carries the arm
|
|
482
|
+
in `metadata.experiment`, as above.
|
|
483
|
+
|
|
484
|
+
On a server-rendered page the server calls `/init`, not the browser, so the
|
|
485
|
+
server has to know the arm. Pass the experiment to `resolveConsent`, as in
|
|
486
|
+
[Vercel Flags SDK](#vercel-flags-sdk): it sends only `{ id, arm }` to the
|
|
487
|
+
backend and hands the full experiment to the client in its state.
|
|
488
|
+
`resolveConsent` takes `experiment` in `c15t/next/server`,
|
|
489
|
+
`c15t/tanstack-start/server` and `@c15t/svelte/server`; Astro and Nuxt pass
|
|
490
|
+
the arm they rendered on their own. When c15t picks the arm in the browser,
|
|
491
|
+
the browser's own `/init` carries it, so a server-rendered page that skips
|
|
492
|
+
the client `/init` has no count for built-in assignment. Resolve the arm with
|
|
493
|
+
a flag on those pages.
|
|
494
|
+
|
|
495
|
+
### Where the counts go
|
|
496
|
+
|
|
497
|
+
On Inth, the dashboard reads both counts for you. A self-hosted backend
|
|
498
|
+
hands each session report to `sessions.onReport`, which is where you log or
|
|
499
|
+
count it; `experiment` is on the report. Choices are in the `consent` table,
|
|
500
|
+
and [`GET /experiments/:id/summary`](#read-the-summary-from-your-backend)
|
|
501
|
+
groups them per arm.
|
|
502
|
+
|
|
503
|
+
The opt-in rate of an arm is visitors with an `accept_all` choice under that
|
|
504
|
+
arm, divided by visitors whose session reports carry the arm. Count
|
|
505
|
+
visitors, not requests: an undecided visitor sends a report on every page
|
|
506
|
+
until they choose. Deduplicate the reports per visitor the way you count
|
|
507
|
+
sessions.
|
|
508
|
+
|
|
509
|
+
### Send the events to your own analytics too
|
|
510
|
+
|
|
511
|
+
The `onSurfaceShown` and `onChoiceRecorded` callbacks carry the same
|
|
512
|
+
`experiment` object, so a few lines forward them to any tool. The
|
|
513
|
+
[React example](#vary-presentation-not-policy) pushes both to
|
|
514
|
+
`window.dataLayer` for Google Tag Manager, as `c15t_surface_shown` with the
|
|
515
|
+
surface and `c15t_choice_recorded` with the consent action. To send them to
|
|
516
|
+
PostHog instead, call `posthog.capture()` with the same fields in the
|
|
517
|
+
callbacks. The callbacks run for every impression and choice; check
|
|
518
|
+
`experiment` first, because it is `undefined` for visitors outside the test.
|
|
519
|
+
|
|
520
|
+
An impression fires before any consent exists. If your analytics tool loads
|
|
521
|
+
only after consent, it never sees a decliner's impression, and its opt-in
|
|
522
|
+
rate trends towards 100%. The backend counts above do not have that gap.
|
|
523
|
+
|
|
524
|
+
### Read the results
|
|
525
|
+
|
|
526
|
+
Consent rates move by a few points, not by half. At a 30% base rate you need
|
|
527
|
+
roughly 2,500 visitors per arm to detect a 5-point change with 80% power,
|
|
528
|
+
and about 10,000 per arm to detect 2 points. Run a sample-size calculator
|
|
529
|
+
against your own base rate before you start, decide the stop date up front,
|
|
530
|
+
and do not stop early on a good-looking day.
|
|
531
|
+
|
|
532
|
+
Under an `opt-out` policy with `prompt: 'notice'`, dismissing the notice
|
|
533
|
+
records no choice, so compare arms on opt-outs instead: `opt_out` choices
|
|
534
|
+
per visitor owed the banner.
|
|
535
|
+
|
|
536
|
+
Time to decision is the median `timeToDecisionMs` per arm over the choices.
|
|
537
|
+
Use the median, not the mean: a visitor who leaves the tab open skews the
|
|
538
|
+
mean without saying anything about the arm.
|
|
539
|
+
|
|
540
|
+
## Read the summary from your backend
|
|
541
|
+
|
|
542
|
+
The self-hosted backend copies `experiment.id`, `experiment.arm` and
|
|
543
|
+
`timeToDecisionMs` out of `metadata` onto their own columns (migration
|
|
544
|
+
`6-experiment-attribution`), so `GET /experiments/:id/summary` can group
|
|
545
|
+
choices per arm without a JSON query. It needs an API key.
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
curl 'https://app.example.com/api/c15t/experiments/banner-shape/summary?from=2026-09-01&to=2026-09-30' \
|
|
549
|
+
-H "Authorization: Bearer $C15T_API_KEY"
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
```json
|
|
553
|
+
{
|
|
554
|
+
"experimentId": "banner-shape",
|
|
555
|
+
"from": "2026-09-01T00:00:00.000Z",
|
|
556
|
+
"to": "2026-09-30T23:59:59.999Z",
|
|
557
|
+
"arms": [
|
|
558
|
+
{
|
|
559
|
+
"arm": "wall",
|
|
560
|
+
"choices": 120,
|
|
561
|
+
"byAction": {
|
|
562
|
+
"accept_all": 80,
|
|
563
|
+
"custom": 10,
|
|
564
|
+
"opt_out": 0,
|
|
565
|
+
"reject_all": 30,
|
|
566
|
+
"unknown": 0
|
|
567
|
+
},
|
|
568
|
+
"bySurface": { "banner": 100, "dialog": 20 },
|
|
569
|
+
"medianTimeToDecisionMs": 4200
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
"arm": "control",
|
|
573
|
+
"choices": 95,
|
|
574
|
+
"byAction": {
|
|
575
|
+
"accept_all": 50,
|
|
576
|
+
"custom": 5,
|
|
577
|
+
"opt_out": 0,
|
|
578
|
+
"reject_all": 40,
|
|
579
|
+
"unknown": 0
|
|
580
|
+
},
|
|
581
|
+
"bySurface": { "banner": 90, "dialog": 5 },
|
|
582
|
+
"medianTimeToDecisionMs": 5100
|
|
583
|
+
}
|
|
584
|
+
]
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
`byAction` always has all five keys. They are the action the backend
|
|
589
|
+
stored, not the client's `consent_action`: `accept_all` for accept,
|
|
590
|
+
`reject_all` for reject, `opt_out` for a reject under an opt-out policy,
|
|
591
|
+
`custom` for a saved selection, and `unknown` for a record without one.
|
|
592
|
+
`choices` counts consent records, so a visitor who changes their mind under
|
|
593
|
+
the same arm counts twice.
|
|
594
|
+
|
|
595
|
+
`from` and `to` accept an ISO 8601 date or timestamp and filter on the
|
|
596
|
+
consent's `givenAt`, both ends inclusive. A date without a time is the whole
|
|
597
|
+
of that day in UTC: `from=2026-09-01` starts at midnight and `to=2026-09-30`
|
|
598
|
+
runs to the end of the 30th, so the example above covers all of September. A
|
|
599
|
+
`from` later than `to` is a 400. `domain` narrows to one domain name. An
|
|
600
|
+
experiment id no consent carries returns `arms: []`.
|
|
601
|
+
|
|
602
|
+
From server code, the [Node.js SDK](https://c15t.com/docs/self-host/api/node-sdk#read-an-experiment-summary)
|
|
603
|
+
makes the same call. Create the client with an API key; `from` and `to` also
|
|
604
|
+
take a `Date`:
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
const c15t = createC15tClient({
|
|
608
|
+
baseUrl: 'https://app.example.com/api/c15t',
|
|
609
|
+
apiKey,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const result = await c15t.experiments.summary('banner-shape', {
|
|
613
|
+
from: '2026-09-01',
|
|
614
|
+
to: '2026-09-30',
|
|
615
|
+
});
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The summary counts choices. The other half of an opt-in rate, the visitors
|
|
619
|
+
each arm's banner was owed to, is on the session reports `/init` produces
|
|
620
|
+
(see [Measure the results](#measure-the-results)). Divide `accept_all` per
|
|
621
|
+
arm by the visitors whose reports carry that arm.
|
|
622
|
+
|
|
623
|
+
## Try it
|
|
624
|
+
|
|
625
|
+
Every example app under `examples/` runs this experiment outside the pages
|
|
626
|
+
the docs publish. Each page shows the assigned arm and the events the
|
|
627
|
+
callbacks sent. Adding `arm=wall` sets the arm the way a flag would, so the
|
|
628
|
+
page shows `assignedBy: host`. Run each command in the example's directory.
|
|
629
|
+
|
|
630
|
+
| Example | Start | Open |
|
|
631
|
+
| --------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
|
632
|
+
| Next.js | `bun run dev` | `/experiment`, with the arm from a stand-in flag: `?arm=wall`, `?arm=off` to leave the test, `control` otherwise |
|
|
633
|
+
| TanStack Start | `C15T_EXPERIMENT=1 bun run dev` | `/consent-example?experiment=1`, then add `&arm=wall` |
|
|
634
|
+
| React | `bun run dev` | `/experiment.html`, then add `?arm=wall` |
|
|
635
|
+
| Nuxt | `C15T_NUXT_EXPERIMENT=1 bun run dev`; add `C15T_NUXT_EXPERIMENT_ARM=wall` for the wall arm | `/consent-example` |
|
|
636
|
+
| Vue | `bun run dev` | `/?experiment=1`, then add `&arm=wall` |
|
|
637
|
+
| Astro | `C15T_EXPERIMENT=1 bun run dev` | `/consent-example?experiment=1` runs `control`, as Astro has no built-in assignment; add `&arm=wall` |
|
|
638
|
+
| Svelte | `bun run dev` | `/?experiment=1`, then add `&arm=wall` |
|
|
639
|
+
| SvelteKit | `bun run dev` | `/experiment-example?experiment=1`, then add `&arm=wall` |
|
|
640
|
+
| HTML script tag | `bun run dev` | `/?experiment=1`, then add `&arm=wall` |
|
|
641
|
+
| JavaScript | `bun run dev` | `/experiment/`, then add `?arm=wall` |
|
|
642
|
+
|
|
643
|
+
## End an experiment
|
|
644
|
+
|
|
645
|
+
Move the winning arm's fragment into `presentation` (and its `theme` into
|
|
646
|
+
`theme`), then remove `experiment`. Visitors keep their consent; the stored
|
|
647
|
+
arm is ignored once no experiment reads it.
|
|
648
|
+
|
|
649
|
+
## Copy is out of scope
|
|
650
|
+
|
|
651
|
+
Arms change presentation and theme tokens only. Copy is not a variant dimension because
|
|
652
|
+
`copyRevision` is hashed into the prompt fingerprint: a copy change re-prompts
|
|
653
|
+
every returning visitor, so a copy experiment would re-prompt them on each
|
|
654
|
+
arm switch. Vary layout, shape, position and action prominence instead.
|