Skip to content

Commit c60f7e4

Browse files
committed
Add meaningful product usage analytics
1 parent 094d453 commit c60f7e4

12 files changed

Lines changed: 461 additions & 8 deletions

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ The repository root retains the canonical [`LICENSE`](../LICENSE) and [`NOTICE`]
2626

2727
## Development references
2828

29+
- [Product usage analytics](product-analytics.md)
2930
- [Vercel agent notes](development/VERCEL_AGENTS.md)
3031
- [Pose diagnostics summary](diagnostics/pose-summary.json)

docs/product-analytics.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Product usage analytics
2+
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?”
6+
7+
## Provider and production configuration
8+
9+
The implementation is provider-neutral at the call sites. Event names, payload
10+
types, failure isolation, and session deduplication live in
11+
`playground/src/analytics.ts`. The current adapter is
12+
`playground/src/vercel-analytics.ts`.
13+
14+
Vercel Web Analytics pageviews remain enabled without extra configuration.
15+
Vercel's current plan table says custom events are **not available on Hobby**;
16+
they are available on Pro and Enterprise. Pro allows at most two properties per
17+
custom event. The schema below deliberately stays within that limit.
18+
19+
To enable product events on a Vercel Pro or Enterprise production project:
20+
21+
1. Enable Web Analytics for the project in Vercel.
22+
2. Set `VITE_PRODUCT_ANALYTICS_PROVIDER=vercel` for the Production environment.
23+
3. Redeploy so Vite includes the provider choice in the client bundle.
24+
4. Exercise one event and confirm it in **Project → Analytics → Events**.
25+
26+
Do not set the variable on Hobby expecting dashboard data: Hobby continues to
27+
show pageviews but does not expose custom events. No alternate paid analytics
28+
vendor is installed. A future adapter can call `configureUsageAnalytics`
29+
without changing UI event call sites.
30+
31+
Sources:
32+
33+
- [Vercel custom events](https://vercel.com/docs/analytics/custom-events)
34+
- [Vercel Web Analytics limits and pricing](https://vercel.com/docs/analytics/limits-and-pricing)
35+
36+
## Event dictionary
37+
38+
| Event | Fires when | Properties |
39+
|---|---|---|
40+
| `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 |
41+
| `editor_changed` | The first real CodeMirror user edit in the page session. Programmatic preset loads do not count. | `document_kind`: `preset`, `shared`, or `custom` |
42+
| `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` |
44+
| `embed_docs_clicked` | The embed documentation CTA on `/for-products` is clicked. | `location`: `for_products` |
45+
| `install_command_copied` | An npm/npx command on `/for-products` is successfully written to the clipboard. | `command`: `embed`, `packages`, or `mcp`; `location`: `for_products` |
46+
47+
## Reading the dashboard
48+
49+
Open **Analytics → Events**, select an event, then drill into its properties.
50+
Useful readings include:
51+
52+
- `preset_opened` grouped by `source` separates library discovery from direct,
53+
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.
58+
- Group `install_command_copied` by `command` to compare integration intent.
59+
60+
Vercel reports aggregate events rather than a user-level funnel. Do not attempt
61+
to join individual visitors or reconstruct sessions from these payloads.
62+
63+
## Privacy and resilience
64+
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.
68+
69+
Every analytics call is best-effort and catches provider failures. Ad blockers,
70+
network failures, a missing provider configuration, or plan limitations do not
71+
change editing, rendering, sharing, navigation, or clipboard behavior.

playground/for-products.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,28 @@ <h3>Drop in a movement player</h3>
7777
<p>Use the framework-free <code>&lt;posecode-player&gt;</code> web component with inline Posecode, a <code>.posecode</code> URL, or a share token.</p>
7878
<pre aria-label="HTML web component example"><code>&lt;script src="https://unpkg.com/posecode-embed@0.2.2/dist/posecode-embed.js"&gt;&lt;/script&gt;
7979
&lt;posecode-player src="/moves/squat.posecode"&gt;&lt;/posecode-player&gt;</code></pre>
80+
<div class="integration-actions">
81+
<button class="code-action" type="button" data-copy-command="embed" data-command="npm install posecode-embed">Copy npm command</button>
82+
<a class="docs-action" data-embed-docs href="https://github.com/posecode-dev/posecode/tree/main/packages/posecode-embed#readme" target="_blank" rel="noopener">Read embed docs&nbsp;→</a>
83+
</div>
8084
</article>
8185
<article class="integration-card reveal">
8286
<span class="card-index">02 / compose</span>
8387
<h3>Own the interface</h3>
8488
<p>Parse text into a typed, range-of-motion-clamped IR, then drive the Three.js renderer inside your own editor, lesson, or workflow.</p>
8589
<pre aria-label="npm installation example"><code>npm install posecode-parser posecode-render three</code></pre>
90+
<div class="integration-actions">
91+
<button class="code-action" type="button" data-copy-command="packages" data-command="npm install posecode-parser posecode-render three">Copy npm command</button>
92+
</div>
8693
</article>
8794
<article class="integration-card reveal">
8895
<span class="card-index">03 / agents</span>
8996
<h3>Run movement tools locally</h3>
9097
<p>The npm MCP server runs over stdio on your machine. It teaches an MCP client the language, validates documents, and creates playground links.</p>
9198
<pre aria-label="Local MCP command example"><code>npx -y posecode-mcp@latest</code></pre>
99+
<div class="integration-actions">
100+
<button class="code-action" type="button" data-copy-command="mcp" data-command="npx -y posecode-mcp@latest">Copy run command</button>
101+
</div>
92102
</article>
93103
</div>
94104
</section>

playground/src/analytics.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Provider-neutral product usage analytics.
3+
*
4+
* Event payloads intentionally contain only low-cardinality product metadata.
5+
* Never add Posecode source, share tokens, prompts, or URLs here.
6+
*/
7+
8+
export const USAGE_EVENT_NAMES = {
9+
presetOpened: "preset_opened",
10+
editorChanged: "editor_changed",
11+
renderSucceeded: "render_succeeded",
12+
shareCreated: "share_created",
13+
embedDocsClicked: "embed_docs_clicked",
14+
installCommandCopied: "install_command_copied",
15+
} as const;
16+
17+
export type PresetOpenSource =
18+
| "library"
19+
| "direct_url"
20+
| "shared_link"
21+
| "landing_cta";
22+
export type DocumentKind = "preset" | "shared" | "custom";
23+
export type RenderTrigger =
24+
| "initial"
25+
| "preset_open"
26+
| "shared_link"
27+
| "editor_change";
28+
export type ShareKind = "preset" | "encoded";
29+
export type InstallCommand = "embed" | "packages" | "mcp";
30+
31+
export interface UsageEventMap {
32+
preset_opened: { source: PresetOpenSource; preset_id: string };
33+
editor_changed: { document_kind: DocumentKind };
34+
render_succeeded: { trigger: RenderTrigger; document_kind: DocumentKind };
35+
share_created: { share_kind: ShareKind };
36+
embed_docs_clicked: { location: "for_products" };
37+
install_command_copied: {
38+
command: InstallCommand;
39+
location: "for_products";
40+
};
41+
}
42+
43+
export type UsageEventName = keyof UsageEventMap;
44+
export type UsageEventSink = <Name extends UsageEventName>(
45+
name: Name,
46+
properties: UsageEventMap[Name],
47+
) => void;
48+
49+
let sink: UsageEventSink | null = null;
50+
51+
export function configureUsageAnalytics(nextSink: UsageEventSink | null): void {
52+
sink = nextSink;
53+
}
54+
55+
export function trackUsageEvent<Name extends UsageEventName>(
56+
name: Name,
57+
properties: UsageEventMap[Name],
58+
): void {
59+
try {
60+
sink?.(name, properties);
61+
} catch {
62+
// Analytics must never interrupt the product interaction being measured.
63+
}
64+
}
65+
66+
/**
67+
* Per-page-session noise control. Internal revision keys are never sent to the
68+
* provider; they only prevent duplicate render events during lazy boot/reparse.
69+
*/
70+
export class UsageSession {
71+
private firstEditTracked = false;
72+
private renderedRevisions = new Set<number>();
73+
74+
trackFirstEdit(documentKind: DocumentKind): void {
75+
if (this.firstEditTracked) return;
76+
this.firstEditTracked = true;
77+
trackUsageEvent(USAGE_EVENT_NAMES.editorChanged, {
78+
document_kind: documentKind,
79+
});
80+
}
81+
82+
trackSuccessfulRender(
83+
revision: number,
84+
trigger: RenderTrigger,
85+
documentKind: DocumentKind,
86+
): void {
87+
if (this.renderedRevisions.has(revision)) return;
88+
this.renderedRevisions.add(revision);
89+
trackUsageEvent(USAGE_EVENT_NAMES.renderSucceeded, {
90+
trigger,
91+
document_kind: documentKind,
92+
});
93+
}
94+
}

playground/src/editor.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
* `posecode-language` (shared with the LSP), so the editor never reimplements them.
66
*/
77

8-
import { EditorState, StateEffect, StateField } from "@codemirror/state";
8+
import {
9+
EditorState,
10+
StateEffect,
11+
StateField,
12+
Transaction,
13+
} from "@codemirror/state";
914
import {
1015
EditorView,
1116
keymap,
@@ -301,7 +306,7 @@ export interface PosecodeEditor {
301306

302307
export interface PosecodeEditorOptions {
303308
doc: string;
304-
onChange: (value: string) => void;
309+
onChange: (value: string, userInitiated: boolean) => void;
305310
}
306311

307312
export function createPosecodeEditor(
@@ -341,7 +346,13 @@ export function createPosecodeEditor(
341346
indentWithTab,
342347
]),
343348
EditorView.updateListener.of((u) => {
344-
if (u.docChanged) opts.onChange(u.state.doc.toString());
349+
if (u.docChanged) {
350+
const userInitiated = u.transactions.some(
351+
(transaction) =>
352+
transaction.annotation(Transaction.userEvent) !== undefined,
353+
);
354+
opts.onChange(u.state.doc.toString(), userInitiated);
355+
}
345356
}),
346357
],
347358
}),

playground/src/for-products.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,32 @@
174174
font: inherit;
175175
}
176176

177+
.integration-actions {
178+
display: flex;
179+
flex-wrap: wrap;
180+
align-items: center;
181+
gap: 12px;
182+
margin-top: 12px;
183+
}
184+
185+
.code-action,
186+
.docs-action {
187+
color: var(--accent);
188+
font: 600 11px/1.4 var(--mono);
189+
}
190+
191+
.code-action {
192+
padding: 0;
193+
border: 0;
194+
background: transparent;
195+
cursor: pointer;
196+
}
197+
198+
.code-action:hover,
199+
.docs-action:hover {
200+
color: var(--text);
201+
}
202+
177203
.use-cases {
178204
border-top: 1px solid var(--border);
179205
}

playground/src/for-products.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
1-
import { inject } from "@vercel/analytics";
1+
import {
2+
trackUsageEvent,
3+
USAGE_EVENT_NAMES,
4+
type InstallCommand,
5+
} from "./analytics.js";
6+
import { initializeAnalytics } from "./vercel-analytics.js";
27

3-
inject();
8+
initializeAnalytics();
9+
10+
document.querySelector<HTMLElement>("[data-embed-docs]")?.addEventListener(
11+
"click",
12+
() => {
13+
trackUsageEvent(USAGE_EVENT_NAMES.embedDocsClicked, {
14+
location: "for_products",
15+
});
16+
},
17+
);
18+
19+
for (const button of document.querySelectorAll<HTMLButtonElement>(
20+
"[data-copy-command]",
21+
)) {
22+
button.addEventListener("click", async () => {
23+
const command = button.dataset.command;
24+
const commandKind = button.dataset.copyCommand as
25+
| InstallCommand
26+
| undefined;
27+
if (!command || !commandKind) return;
28+
const previous = button.textContent;
29+
try {
30+
await navigator.clipboard.writeText(command);
31+
button.textContent = "Copied ✓";
32+
trackUsageEvent(USAGE_EVENT_NAMES.installCommandCopied, {
33+
command: commandKind,
34+
location: "for_products",
35+
});
36+
} catch {
37+
button.textContent = "Copy failed";
38+
}
39+
window.setTimeout(() => {
40+
button.textContent = previous;
41+
}, 1500);
42+
});
43+
}
444

545
const prefersReducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
646

0 commit comments

Comments
 (0)