Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ The repository root retains the canonical [`LICENSE`](../LICENSE) and [`NOTICE`]

## Development references

- [Product usage analytics](product-analytics.md)
- [Vercel agent notes](development/VERCEL_AGENTS.md)
- [Pose diagnostics summary](diagnostics/pose-summary.json)
71 changes: 71 additions & 0 deletions docs/product-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Product usage analytics

Posecode keeps Vercel pageviews and product-usage events separate. Pageviews
answer “which routes were visited?”; the events below answer “did someone use
the product?”

## Provider and production configuration

The implementation is provider-neutral at the call sites. Event names, payload
types, failure isolation, and session deduplication live in
`playground/src/analytics.ts`. The current adapter is
`playground/src/vercel-analytics.ts`.

Vercel Web Analytics pageviews remain enabled without extra configuration.
Vercel's current plan table says custom events are **not available on Hobby**;
they are available on Pro and Enterprise. Pro allows at most two properties per
custom event. The schema below deliberately stays within that limit.

To enable product events on a Vercel Pro or Enterprise production project:

1. Enable Web Analytics for the project in Vercel.
2. Set `VITE_PRODUCT_ANALYTICS_PROVIDER=vercel` for the Production environment.
3. Redeploy so Vite includes the provider choice in the client bundle.
4. Exercise one event and confirm it in **Project → Analytics → Events**.

Do not set the variable on Hobby expecting dashboard data: Hobby continues to
show pageviews but does not expose custom events. No alternate paid analytics
vendor is installed. A future adapter can call `configureUsageAnalytics`
without changing UI event call sites.

Sources:

- [Vercel custom events](https://vercel.com/docs/analytics/custom-events)
- [Vercel Web Analytics limits and pricing](https://vercel.com/docs/analytics/limits-and-pricing)

## Event dictionary

| Event | Fires when | Properties |
|---|---|---|
| `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 |
| `editor_changed` | The first real CodeMirror user edit in the page session. Programmatic preset loads do not count. | `document_kind`: `preset`, `shared`, or `custom` |
| `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` |
| `share_created` | The generated preset/encoded URL has successfully been written to the clipboard. | `share_kind`: `preset` or `encoded` |
| `embed_docs_clicked` | The embed documentation CTA on `/for-products` is clicked. | `location`: `for_products` |
| `install_command_copied` | An npm/npx command on `/for-products` is successfully written to the clipboard. | `command`: `embed`, `packages`, or `mcp`; `location`: `for_products` |

## Reading the dashboard

Open **Analytics → Events**, select an event, then drill into its properties.
Useful readings include:

- `preset_opened` grouped by `source` separates library discovery from direct,
shared, and landing-page entry.
- Compare `editor_changed` and `render_succeeded` counts to see whether editing
reaches a valid renderer update. They are intentionally not a strict funnel:
initial and preset renders also count.
- `share_created` is a confirmed clipboard outcome, not a button-click count.
- Group `install_command_copied` by `command` to compare integration intent.

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

## Privacy and resilience

Events never contain Posecode source text, authoring prompts, personal data,
full share tokens, query strings, referrers, or sensitive URLs. `preset_id` is a
bounded public catalogue identifier; all other values are closed enums.

Every analytics call is best-effort and catches provider failures. Ad blockers,
network failures, a missing provider configuration, or plan limitations do not
change editing, rendering, sharing, navigation, or clipboard behavior.
10 changes: 10 additions & 0 deletions playground/for-products.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,28 @@ <h3>Drop in a movement player</h3>
<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>
<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;
&lt;posecode-player src="/moves/squat.posecode"&gt;&lt;/posecode-player&gt;</code></pre>
<div class="integration-actions">
<button class="code-action" type="button" data-copy-command="embed" data-command="npm install posecode-embed">Copy npm command</button>
<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>
</div>
</article>
<article class="integration-card reveal">
<span class="card-index">02 / compose</span>
<h3>Own the interface</h3>
<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>
<pre aria-label="npm installation example"><code>npm install posecode-parser posecode-render three</code></pre>
<div class="integration-actions">
<button class="code-action" type="button" data-copy-command="packages" data-command="npm install posecode-parser posecode-render three">Copy npm command</button>
</div>
</article>
<article class="integration-card reveal">
<span class="card-index">03 / agents</span>
<h3>Run movement tools locally</h3>
<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>
<pre aria-label="Local MCP command example"><code>npx -y posecode-mcp@latest</code></pre>
<div class="integration-actions">
<button class="code-action" type="button" data-copy-command="mcp" data-command="npx -y posecode-mcp@latest">Copy run command</button>
</div>
</article>
</div>
</section>
Expand Down
94 changes: 94 additions & 0 deletions playground/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Provider-neutral product usage analytics.
*
* Event payloads intentionally contain only low-cardinality product metadata.
* Never add Posecode source, share tokens, prompts, or URLs here.
*/

export const USAGE_EVENT_NAMES = {
presetOpened: "preset_opened",
editorChanged: "editor_changed",
renderSucceeded: "render_succeeded",
shareCreated: "share_created",
embedDocsClicked: "embed_docs_clicked",
installCommandCopied: "install_command_copied",
} as const;

export type PresetOpenSource =
| "library"
| "direct_url"
| "shared_link"
| "landing_cta";
export type DocumentKind = "preset" | "shared" | "custom";
export type RenderTrigger =
| "initial"
| "preset_open"
| "shared_link"
| "editor_change";
export type ShareKind = "preset" | "encoded";
export type InstallCommand = "embed" | "packages" | "mcp";

export interface UsageEventMap {
preset_opened: { source: PresetOpenSource; preset_id: string };
editor_changed: { document_kind: DocumentKind };
render_succeeded: { trigger: RenderTrigger; document_kind: DocumentKind };
share_created: { share_kind: ShareKind };
embed_docs_clicked: { location: "for_products" };
install_command_copied: {
command: InstallCommand;
location: "for_products";
};
}

export type UsageEventName = keyof UsageEventMap;
export type UsageEventSink = <Name extends UsageEventName>(
name: Name,
properties: UsageEventMap[Name],
) => void;

let sink: UsageEventSink | null = null;

export function configureUsageAnalytics(nextSink: UsageEventSink | null): void {
sink = nextSink;
}

export function trackUsageEvent<Name extends UsageEventName>(
name: Name,
properties: UsageEventMap[Name],
): void {
try {
sink?.(name, properties);
} catch {
// Analytics must never interrupt the product interaction being measured.
}
}

/**
* Per-page-session noise control. Internal revision keys are never sent to the
* provider; they only prevent duplicate render events during lazy boot/reparse.
*/
export class UsageSession {
private firstEditTracked = false;
private renderedRevisions = new Set<number>();

trackFirstEdit(documentKind: DocumentKind): void {
if (this.firstEditTracked) return;
this.firstEditTracked = true;
trackUsageEvent(USAGE_EVENT_NAMES.editorChanged, {
document_kind: documentKind,
});
}

trackSuccessfulRender(
revision: number,
trigger: RenderTrigger,
documentKind: DocumentKind,
): void {
if (this.renderedRevisions.has(revision)) return;
this.renderedRevisions.add(revision);
trackUsageEvent(USAGE_EVENT_NAMES.renderSucceeded, {
trigger,
document_kind: documentKind,
});
}
}
17 changes: 14 additions & 3 deletions playground/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
* `posecode-language` (shared with the LSP), so the editor never reimplements them.
*/

import { EditorState, StateEffect, StateField } from "@codemirror/state";
import {
EditorState,
StateEffect,
StateField,
Transaction,
} from "@codemirror/state";
import {
EditorView,
keymap,
Expand Down Expand Up @@ -301,7 +306,7 @@ export interface PosecodeEditor {

export interface PosecodeEditorOptions {
doc: string;
onChange: (value: string) => void;
onChange: (value: string, userInitiated: boolean) => void;
}

export function createPosecodeEditor(
Expand Down Expand Up @@ -341,7 +346,13 @@ export function createPosecodeEditor(
indentWithTab,
]),
EditorView.updateListener.of((u) => {
if (u.docChanged) opts.onChange(u.state.doc.toString());
if (u.docChanged) {
const userInitiated = u.transactions.some(
(transaction) =>
transaction.annotation(Transaction.userEvent) !== undefined,
);
opts.onChange(u.state.doc.toString(), userInitiated);
}
}),
],
}),
Expand Down
26 changes: 26 additions & 0 deletions playground/src/for-products.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,32 @@
font: inherit;
}

.integration-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 12px;
}

.code-action,
.docs-action {
color: var(--accent);
font: 600 11px/1.4 var(--mono);
}

.code-action {
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}

.code-action:hover,
.docs-action:hover {
color: var(--text);
}

.use-cases {
border-top: 1px solid var(--border);
}
Expand Down
44 changes: 42 additions & 2 deletions playground/src/for-products.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
import { inject } from "@vercel/analytics";
import {
trackUsageEvent,
USAGE_EVENT_NAMES,
type InstallCommand,
} from "./analytics.js";
import { initializeAnalytics } from "./vercel-analytics.js";

inject();
initializeAnalytics();

document.querySelector<HTMLElement>("[data-embed-docs]")?.addEventListener(
"click",
() => {
trackUsageEvent(USAGE_EVENT_NAMES.embedDocsClicked, {
location: "for_products",
});
},
);

for (const button of document.querySelectorAll<HTMLButtonElement>(
"[data-copy-command]",
)) {
button.addEventListener("click", async () => {
const command = button.dataset.command;
const commandKind = button.dataset.copyCommand as
| InstallCommand
| undefined;
if (!command || !commandKind) return;
const previous = button.textContent;
try {
await navigator.clipboard.writeText(command);
button.textContent = "Copied ✓";
trackUsageEvent(USAGE_EVENT_NAMES.installCommandCopied, {
command: commandKind,
location: "for_products",
});
} catch {
button.textContent = "Copy failed";
}
window.setTimeout(() => {
button.textContent = previous;
}, 1500);
});
}

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

Expand Down
Loading