Skip to content

Commit be5f0f9

Browse files
committed
Add privacy-conscious playground analytics
1 parent c9d3076 commit be5f0f9

8 files changed

Lines changed: 245 additions & 21 deletions

File tree

docs/product-analytics.md

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Product usage analytics
22

3-
Posecode keeps Vercel pageviews and product-usage events separate. Pageviews
4-
answer “which routes were visited?”; the events below answer “did someone use
5-
the product?”
3+
Posecode keeps Vercel pageviews and product-usage events separate. One initial
4+
pageview per loaded HTML entry point answers “which route was visited?”; the
5+
deduplicated events below answer whether someone completed a meaningful product
6+
step.
67

78
## Provider and production configuration
89

@@ -12,6 +13,13 @@ types, failure isolation, and session deduplication live in
1213
`playground/src/vercel-analytics.ts`.
1314

1415
Vercel Web Analytics pageviews remain enabled without extra configuration.
16+
The adapter disables soft-navigation tracking because the playground uses
17+
`history.replaceState()` while editing; those source-address updates are not
18+
new visits. Its `beforeSend` hook strips query strings and hashes, collapses
19+
`/play/:movement` to `/play/[movement]`, and normalizes `.html` aliases before
20+
the event leaves the browser. This prevents encoded movement source in a share
21+
hash from becoming analytics URL data.
22+
1523
Vercel's current plan table says custom events are **not available on Hobby**;
1624
they are available on Pro and Enterprise. Pro allows at most two properties per
1725
custom event. The schema below deliberately stays within that limit.
@@ -40,7 +48,9 @@ Sources:
4048
| `preset_opened` | A bundled movement is actually opened at initial load or selected in the library. | `source`: `library`, `direct_url`, `shared_link`, or `landing_cta`; `preset_id`: bundled stable ID |
4149
| `editor_changed` | The first real CodeMirror user edit in the page session. Programmatic preset loads do not count. | `document_kind`: `preset`, `shared`, or `custom` |
4250
| `render_succeeded` | `viewer.load()` successfully accepts a new meaningful document revision. Lazy boot and repeated recompiles of the same revision are deduplicated. The animation frame loop never emits this event. | `trigger`: `initial`, `preset_open`, `shared_link`, or `editor_change`; `document_kind` |
43-
| `share_created` | The generated preset/encoded URL has successfully been written to the clipboard. | `share_kind`: `preset` or `encoded` |
51+
| `prompt_copied` | The authoring guide is successfully copied on the landing page or playground. Repeated copies in one page session are deduplicated. | `location`: `landing` or `playground` |
52+
| `movement_attempted` | The first user-initiated editor revision in the page session parses without errors, loads in the viewer, and does not exactly equal a bundled preset. Initial preset/shared loads and invalid edits do not count. | none |
53+
| `share_created` | The generated preset/encoded URL has successfully been written to the clipboard. Repeated successful copies in one page session are deduplicated. | `share_kind`: `preset` or `encoded` |
4454
| `embed_docs_clicked` | The embed documentation CTA on `/for-products` is clicked. | `location`: `for_products` |
4555
| `install_command_copied` | An npm/npx command on `/for-products` is successfully written to the clipboard. | `command`: `embed`, `packages`, or `mcp`; `location`: `for_products` |
4656

@@ -51,20 +61,29 @@ Useful readings include:
5161

5262
- `preset_opened` grouped by `source` separates library discovery from direct,
5363
shared, and landing-page entry.
54-
- Compare `editor_changed` and `render_succeeded` counts to see whether editing
55-
reaches a valid renderer update. They are intentionally not a strict funnel:
56-
initial and preset renders also count.
57-
- `share_created` is a confirmed clipboard outcome, not a button-click count.
64+
- Compare route visitors with `prompt_copied`, `movement_attempted`, and
65+
`share_created` for the focused authoring funnel. These are aggregate counts,
66+
not joined user records.
67+
- `movement_attempted` excludes invalid edits and unchanged presets.
68+
- `prompt_copied` and `share_created` are confirmed clipboard outcomes, not
69+
button-click counts.
5870
- Group `install_command_copied` by `command` to compare integration intent.
5971

6072
Vercel reports aggregate events rather than a user-level funnel. Do not attempt
6173
to join individual visitors or reconstruct sessions from these payloads.
6274

6375
## Privacy and resilience
6476

65-
Events never contain Posecode source text, authoring prompts, personal data,
66-
full share tokens, query strings, referrers, or sensitive URLs. `preset_id` is a
67-
bounded public catalogue identifier; all other values are closed enums.
77+
Custom event properties never contain Posecode source text, authoring prompts,
78+
personal data, full share tokens, query strings, referrers, or sensitive URLs.
79+
`preset_id` is a bounded public catalogue identifier; all other values are
80+
closed enums.
81+
82+
Funnel deduplication is deliberately page-session-only and uses in-memory sets,
83+
not cookies, local storage, user IDs, or source hashes. Reloading the page starts
84+
a new page session. Vercel pageviews can still include Vercel's standard
85+
anonymous dimensions and an incoming referrer under its Web Analytics privacy
86+
model; the application does not add identifying fields.
6887

6988
Every analytics call is best-effort and catches provider failures. Ad blockers,
7089
network failures, a missing provider configuration, or plan limitations do not

playground/src/analytics.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const USAGE_EVENT_NAMES = {
99
presetOpened: "preset_opened",
1010
editorChanged: "editor_changed",
1111
renderSucceeded: "render_succeeded",
12+
promptCopied: "prompt_copied",
13+
movementAttempted: "movement_attempted",
1214
shareCreated: "share_created",
1315
embedDocsClicked: "embed_docs_clicked",
1416
installCommandCopied: "install_command_copied",
@@ -26,12 +28,15 @@ export type RenderTrigger =
2628
| "shared_link"
2729
| "editor_change";
2830
export type ShareKind = "preset" | "encoded";
31+
export type PromptLocation = "landing" | "playground";
2932
export type InstallCommand = "embed" | "packages" | "mcp";
3033

3134
export interface UsageEventMap {
3235
preset_opened: { source: PresetOpenSource; preset_id: string };
3336
editor_changed: { document_kind: DocumentKind };
3437
render_succeeded: { trigger: RenderTrigger; document_kind: DocumentKind };
38+
prompt_copied: { location: PromptLocation };
39+
movement_attempted: Record<string, never>;
3540
share_created: { share_kind: ShareKind };
3641
embed_docs_clicked: { location: "for_products" };
3742
install_command_copied: {
@@ -70,6 +75,35 @@ export function trackUsageEvent<Name extends UsageEventName>(
7075
export class UsageSession {
7176
private firstEditTracked = false;
7277
private renderedRevisions = new Set<number>();
78+
private funnelEvents = new Set<
79+
"prompt_copied" | "movement_attempted" | "share_created"
80+
>();
81+
82+
private trackFunnelEventOnce<Name extends
83+
| "prompt_copied"
84+
| "movement_attempted"
85+
| "share_created">(
86+
name: Name,
87+
properties: UsageEventMap[Name],
88+
): void {
89+
if (this.funnelEvents.has(name)) return;
90+
this.funnelEvents.add(name);
91+
trackUsageEvent(name, properties);
92+
}
93+
94+
trackPromptCopied(location: PromptLocation): void {
95+
this.trackFunnelEventOnce(USAGE_EVENT_NAMES.promptCopied, { location });
96+
}
97+
98+
trackFirstValidCustomMovement(): void {
99+
this.trackFunnelEventOnce(USAGE_EVENT_NAMES.movementAttempted, {});
100+
}
101+
102+
trackSuccessfulShare(shareKind: ShareKind): void {
103+
this.trackFunnelEventOnce(USAGE_EVENT_NAMES.shareCreated, {
104+
share_kind: shareKind,
105+
});
106+
}
73107

74108
trackFirstEdit(documentKind: DocumentKind): void {
75109
if (this.firstEditTracked) return;

playground/src/landing.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66
*/
77

88
import { parse } from "posecode-parser";
9-
import { inject } from "@vercel/analytics";
9+
import { UsageSession } from "./analytics.js";
10+
import { initializeAnalytics } from "./vercel-analytics.js";
1011
import { PRESETS } from "./presets.js";
1112
import llmPrompt from "../../spec/llm-authoring.md?raw";
1213

13-
inject();
14+
const usageSession = new UsageSession();
1415

1516
// Preserve permalinks shared before the tool moved from `/` to `/play`.
1617
if (location.hash.startsWith("#doc=")) {
1718
location.replace(`/play${location.hash}`);
19+
} else {
20+
// The redirect target records the visit; do not double-count the legacy URL.
21+
initializeAnalytics();
1822
}
1923

2024
const prefersReducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -155,6 +159,7 @@ for (const copyBtn of document.querySelectorAll<HTMLButtonElement>("[data-copy-p
155159
lbl.textContent = "Copying…";
156160
try {
157161
await writeClipboard(llmPrompt);
162+
usageSession.trackPromptCopied("landing");
158163
lbl.textContent = "Copied ✓";
159164
} catch {
160165
lbl.textContent = "Copy failed";

playground/src/main.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ function recompile(): void {
352352
if (ir && viewer) {
353353
viewer.load(ir);
354354
updateFloorGuideKey();
355+
if (
356+
errors.length === 0 &&
357+
pendingRenderTrigger === "editor_change" &&
358+
documentKind() === "custom"
359+
) {
360+
usageSession.trackFirstValidCustomMovement();
361+
}
355362
usageSession.trackSuccessfulRender(
356363
documentRevision,
357364
pendingRenderTrigger,
@@ -702,6 +709,7 @@ async function copyPrompt(btn: HTMLButtonElement): Promise<void> {
702709
flash(btn, "Copying…", "pending", 0);
703710
try {
704711
await navigator.clipboard.writeText(llmPrompt);
712+
usageSession.trackPromptCopied("playground");
705713
flash(btn, "Copied ✓", "success");
706714
} catch {
707715
flash(btn, "Copy failed", "error");
@@ -722,9 +730,9 @@ async function shareLink(): Promise<void> {
722730
const url = `${location.origin}${path}${hash}`;
723731
history.replaceState(null, "", `${path}${hash}`);
724732
await navigator.clipboard.writeText(url);
725-
trackUsageEvent(USAGE_EVENT_NAMES.shareCreated, {
726-
share_kind: path === "/play" ? "encoded" : "preset",
727-
});
733+
usageSession.trackSuccessfulShare(
734+
path === "/play" ? "encoded" : "preset",
735+
);
728736
flash(shareBtn, "Link copied ✓", "success");
729737
} catch (err) {
730738
const message =

playground/src/vercel-analytics.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,57 @@
1-
import { inject, track } from "@vercel/analytics";
1+
import {
2+
inject,
3+
track,
4+
type BeforeSendEvent,
5+
} from "@vercel/analytics";
26
import {
37
configureUsageAnalytics,
48
type UsageEventSink,
59
} from "./analytics.js";
610

11+
/** Collapse public aliases and dynamic movement paths into bounded route names. */
12+
export function analyticsRoute(pathname: string): string {
13+
if (/^\/(?:index\.html)?\/?$/.test(pathname)) return "/";
14+
if (/^\/play(?:\.html)?\/?$/.test(pathname)) return "/play";
15+
if (pathname.startsWith("/play/")) return "/play/[movement]";
16+
if (/^\/for-products(?:\.html)?\/?$/.test(pathname)) {
17+
return "/for-products";
18+
}
19+
return pathname;
20+
}
21+
22+
/**
23+
* Page URLs can contain an encoded Posecode document in the hash. Redact all
24+
* query/hash data and normalize dynamic movement paths before Vercel sees it.
25+
*/
26+
export function redactAnalyticsUrl(
27+
event: BeforeSendEvent,
28+
): BeforeSendEvent | null {
29+
try {
30+
const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(event.url);
31+
const url = new URL(event.url, "https://analytics.posecode.invalid");
32+
url.pathname = analyticsRoute(url.pathname);
33+
url.search = "";
34+
url.hash = "";
35+
return {
36+
...event,
37+
url: absolute ? `${url.origin}${url.pathname}` : url.pathname,
38+
};
39+
} catch {
40+
// Fail closed rather than risk forwarding an unrecognized URL shape.
41+
return null;
42+
}
43+
}
44+
745
/**
8-
* Pageviews remain enabled exactly as before. Product events are opt-in because
9-
* Vercel Hobby accepts pageviews but does not expose custom events.
46+
* Each HTML entry point records one pageview. The playground mutates history
47+
* as the source changes, so soft-navigation auto-tracking is disabled to avoid
48+
* treating edits as visits.
1049
*/
1150
export function initializeAnalytics(): void {
12-
inject();
51+
inject({
52+
beforeSend: redactAnalyticsUrl,
53+
disableAutoTrack: true,
54+
});
1355
if (import.meta.env.VITE_PRODUCT_ANALYTICS_PROVIDER !== "vercel") return;
1456
configureUsageAnalytics(((name, properties) => {
1557
track(name, properties);

playground/test/analytics-wiring.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
55
const root = resolve(import.meta.dirname, "../..");
66
const main = readFileSync(resolve(root, "playground/src/main.ts"), "utf8");
77
const editor = readFileSync(resolve(root, "playground/src/editor.ts"), "utf8");
8+
const landing = readFileSync(resolve(root, "playground/src/landing.ts"), "utf8");
89
const products = readFileSync(
910
resolve(root, "playground/src/for-products.ts"),
1011
"utf8",
@@ -24,9 +25,49 @@ describe("product analytics UI wiring", () => {
2425
expect(tracked).toBeGreaterThan(load);
2526
});
2627

28+
it("tracks a movement attempt only after a valid custom editor render", () => {
29+
const load = main.indexOf("viewer.load(ir)");
30+
const valid = main.indexOf('errors.length === 0', load);
31+
const editorChange = main.indexOf(
32+
'pendingRenderTrigger === "editor_change"',
33+
valid,
34+
);
35+
const custom = main.indexOf('documentKind() === "custom"', editorChange);
36+
const tracked = main.indexOf(
37+
"usageSession.trackFirstValidCustomMovement()",
38+
custom,
39+
);
40+
41+
expect(load).toBeGreaterThan(-1);
42+
expect(valid).toBeGreaterThan(load);
43+
expect(editorChange).toBeGreaterThan(valid);
44+
expect(custom).toBeGreaterThan(editorChange);
45+
expect(tracked).toBeGreaterThan(custom);
46+
});
47+
48+
it("tracks prompt copy only after clipboard success on both entry points", () => {
49+
const playgroundCopy = main.indexOf(
50+
"await navigator.clipboard.writeText(llmPrompt)",
51+
);
52+
const playgroundTracked = main.indexOf(
53+
'usageSession.trackPromptCopied("playground")',
54+
playgroundCopy,
55+
);
56+
const landingCopy = landing.indexOf("await writeClipboard(llmPrompt)");
57+
const landingTracked = landing.indexOf(
58+
'usageSession.trackPromptCopied("landing")',
59+
landingCopy,
60+
);
61+
62+
expect(playgroundCopy).toBeGreaterThan(-1);
63+
expect(playgroundTracked).toBeGreaterThan(playgroundCopy);
64+
expect(landingCopy).toBeGreaterThan(-1);
65+
expect(landingTracked).toBeGreaterThan(landingCopy);
66+
});
67+
2768
it("tracks share only after the link reaches the clipboard", () => {
2869
const copied = main.indexOf("await navigator.clipboard.writeText(url)");
29-
const tracked = main.indexOf("USAGE_EVENT_NAMES.shareCreated", copied);
70+
const tracked = main.indexOf("usageSession.trackSuccessfulShare", copied);
3071
expect(copied).toBeGreaterThan(-1);
3172
expect(tracked).toBeGreaterThan(copied);
3273
});

playground/test/analytics.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ describe("product usage analytics", () => {
2020
presetOpened: "preset_opened",
2121
editorChanged: "editor_changed",
2222
renderSucceeded: "render_succeeded",
23+
promptCopied: "prompt_copied",
24+
movementAttempted: "movement_attempted",
2325
shareCreated: "share_created",
2426
embedDocsClicked: "embed_docs_clicked",
2527
installCommandCopied: "install_command_copied",
@@ -46,6 +48,23 @@ describe("product usage analytics", () => {
4648
expect(sink).toHaveBeenCalledTimes(2);
4749
});
4850

51+
it("deduplicates each confirmed funnel outcome per page session", () => {
52+
const session = new UsageSession();
53+
54+
session.trackPromptCopied("landing");
55+
session.trackPromptCopied("playground");
56+
session.trackFirstValidCustomMovement();
57+
session.trackFirstValidCustomMovement();
58+
session.trackSuccessfulShare("encoded");
59+
session.trackSuccessfulShare("preset");
60+
61+
expect(sink.mock.calls).toEqual([
62+
["prompt_copied", { location: "landing" }],
63+
["movement_attempted", {}],
64+
["share_created", { share_kind: "encoded" }],
65+
]);
66+
});
67+
4968
it("does not let a blocked provider break product behavior", () => {
5069
configureUsageAnalytics((() => {
5170
throw new Error("blocked");

0 commit comments

Comments
 (0)