Front-end analytics
Analytics
Chai's components report what people do and see in them, one event at a time, to the analytics tool you already use. One function, passed to each component, and nothing else to build.
Front end only
Events describe the person's actions and what the components showed them. No prompt text, file names, URLs or outputs are sent: only lengths, counts and Chai's own ids. Model calls, latency, cost and storage belong to your back end, where they're accurate.
Try it
Use the bar and the result cards below: pick a use case or a model, attach an image, enhance the prompt, talk to the mic, submit. Your events appear on the right as they fire, and go to this site's PostHog project with everyone else's, in the dashboard below. Results are placeholders that take a few seconds, so there's time to click Stop: the docs make no model calls.
None yet. Pick a model, type a prompt and submit.
The next runs end in an error, so you can see chai_result_shown with status: error and try Retry.
Analytics Dashboard
The same events from every visitor to these previews, charted by PostHog. It refreshes every 30 minutes, so your clicks show up here on the next refresh.
Set it up
PostHog is the example; any tool's capture call works the same way. Initialize your tool once, write one track function, and pass it to each Chai component as onEvent (@event in Vue). Cookieless mode needs no consent banner; turn on cookieless tracking in your PostHog project too.
import posthog from "posthog-js";import { Composer, ResultCard, useComposer, type ChaiEvent } from "@chai-ui/react";import { useState } from "react";
// Once, at your app's entry point. Cookieless: no cookies, no banner.posthog.init("<your PostHog project token>", { api_host: "https://us.i.posthog.com", cookieless_mode: "always",});
// One function for every Chai component.const track = (e: ChaiEvent) => posthog.capture(e.name, e.properties);
export function Create() { const [value, setValue] = useState(""); const { runs, submit } = useComposer();
return ( <> <Composer value={value} onChange={setValue} onSubmit={submit} onEvent={track} /> {runs.map((run) => ( <ResultCard key={run.id} results={run.results} prompt={run.request.prompt} onAction={() => {}} onEvent={track} /> ))} </> );}To keep ad blockers from dropping events, send them through your own domain with a reverse proxy, as PostHog's guides describe. See also Develop for the rest of the setup.
The events
| Event | Fires when the person… | Properties |
|---|---|---|
chai_composer_submitted | submits | use_cases, model_count, auto_select, aspect_ratio, attachment_count, prompt_length, retry |
chai_composer_stopped | clicks Stop | none |
chai_composer_use_case_picked | picks a + menu item | action |
chai_composer_use_case_removed | removes a use-case chip | use_case |
chai_composer_media_attached | adds files | count, unsupported_count |
chai_composer_model_picked | picks a model | use_case, model_id |
chai_composer_auto_select_toggled | flips auto-select | on |
chai_composer_option_picked | picks an aspect ratio, voice, genre or frame rate | option, value |
chai_composer_enhance_clicked | clicks Enhance | field, prompt_length |
chai_composer_enhance_reverted | reverts an Enhance to the original | field |
chai_composer_voice_started | starts voice input | none |
chai_result_shown | sees a result or an error appear (once each) | status, output_kind, result_count |
chai_result_action | likes, downloads, shares, copies or retries | action |
chai_result_paged | pages to another result | index, count |
chai_result_info_opened | opens a result's details | none |
Build the dashboard
Chai's events are ordinary events in your analytics tool, so its own dashboards chart them. In PostHog, build each chart below as an insight on one dashboard, by hand or through PostHog's API. To show it in your own app, turn on the dashboard's public sharing and embed the link in an iframe.
Building through the API needs a personal API key (phx_…), scoped to the project with insight, dashboard and sharing write access. Keep it on your server or your machine: unlike the project token, it reads and changes your data, so it never goes in the browser or in a VITE_ or NEXT_PUBLIC_ variable.
| Chart | Events | Breakdown or filter |
|---|---|---|
| Most used UI elements | every chai_ event but chai_result_shown | UI element, 30-day total |
| Submits | chai_composer_submitted | per day |
| Use case mix | chai_composer_submitted | use_cases |
| Model picks | chai_composer_model_picked | model_id |
| Funnel: submitted, shown, downloaded | chai_composer_submittedchai_result_shownchai_result_action | status done, action download |
| Enhance and voice input | chai_composer_enhance_clickedchai_composer_voice_started | per day |
The script that builds this site's dashboard does all of it: it creates each chart at its size, swaps in new charts on every run while keeping the dashboard (so the embed link never changes), turns on sharing and prints the URL to embed. Run it with Node 20 or later, with your key and project ID in an env file.
// Builds a PostHog dashboard from Chai UI's front-end events, through// PostHog's API: one insight per chart, sized on the dashboard, then a public// share link to embed. Run it again after editing: it keeps the dashboard (so// the embed link stays the same) and replaces its charts.//// POSTHOG_PERSONAL_API_KEY a personal API key (phx_…), server side only:// never VITE_ or NEXT_PUBLIC_, never in the browser.// Scopes: insight, dashboard and sharing_configuration write// POSTHOG_PROJECT_ID the number in your PostHog project's URL// POSTHOG_HOST optional, default https://us.posthog.com (EU: https://eu.posthog.com)//// node --env-file=.env.local posthog-dashboard.mjs create or replace, share, print the embed URL// node posthog-dashboard.mjs --dry-run print what would be sent
const NAME = "Chai UI: Composer";const DESCRIPTION = "What people do in Chai's Composer and result cards. Front-end events only.";const LAST_30_DAYS = { date_from: "-30d" };
const event = (name, properties = []) => ({ kind: "EventsNode", event: name, math: "total", properties });const where = (key, value) => ({ type: "event", key, value: [value], operator: "exact" });const trends = (series, extra = {}) => ({ kind: "InsightVizNode", source: { kind: "TrendsQuery", series, dateRange: LAST_30_DAYS, interval: "day", ...extra },});
// Every Chai event a person triggers (not results appearing), named by the UI element used.const interaction = { kind: "EventsNode", event: null, name: "UI interactions", math: "total", properties: [{ type: "hogql", key: "startsWith(event, 'chai_') and event != 'chai_result_shown'" }],};const ELEMENTS = { chai_composer_submitted: "Submit", chai_composer_stopped: "Stop", chai_composer_use_case_picked: "+ menu", chai_composer_use_case_removed: "Use case chip", chai_composer_media_attached: "Attach media", chai_composer_model_picked: "Model menu", chai_composer_auto_select_toggled: "Auto-select switch", chai_composer_option_picked: "Option menus", chai_composer_enhance_clicked: "Enhance", chai_composer_enhance_reverted: "Enhance revert", chai_composer_voice_started: "Mic", chai_result_paged: "Result paging", chai_result_info_opened: "Result details",};const elementName = `multiIf(${Object.entries(ELEMENTS) .map(([name, label]) => `event = '${name}', '${label}'`) .join(", ")}, event = 'chai_result_action', concat('Result: ', properties.action), event)`;
/** The charts, each placed on PostHog's 12-column grid (x, y, w, h). */const INSIGHTS = [ { name: "Most used UI elements", description: "Interactions with each part of Chai's components, last 30 days.", query: trends([interaction], { breakdownFilter: { breakdown: elementName, breakdown_type: "hogql", breakdown_limit: 25 }, trendsFilter: { display: "ActionsBarValue" }, }), layout: { x: 0, y: 0, w: 12, h: 5 }, }, { name: "Submits", query: trends([event("chai_composer_submitted")]), layout: { x: 0, y: 5, w: 4, h: 3 }, }, { name: "Use case mix", query: trends([event("chai_composer_submitted")], { breakdownFilter: { breakdown: "use_cases", breakdown_type: "event" }, trendsFilter: { display: "ActionsPie" }, }), layout: { x: 4, y: 5, w: 4, h: 3 }, }, { name: "Model picks", query: trends([event("chai_composer_model_picked")], { breakdownFilter: { breakdown: "model_id", breakdown_type: "event" }, trendsFilter: { display: "ActionsBarValue" }, }), layout: { x: 8, y: 5, w: 4, h: 3 }, }, { name: "Funnel: submitted, shown, downloaded", query: { kind: "InsightVizNode", source: { kind: "FunnelsQuery", series: [ event("chai_composer_submitted"), event("chai_result_shown", [where("status", "done")]), event("chai_result_action", [where("action", "download")]), ], funnelsFilter: { layout: "horizontal" }, dateRange: LAST_30_DAYS, }, }, layout: { x: 0, y: 8, w: 6, h: 3 }, }, { name: "Enhance and voice input", query: trends([event("chai_composer_enhance_clicked"), event("chai_composer_voice_started")]), layout: { x: 6, y: 8, w: 6, h: 3 }, },];
const dryRun = process.argv.includes("--dry-run");const host = process.env.POSTHOG_HOST ?? "https://us.posthog.com";const key = process.env.POSTHOG_PERSONAL_API_KEY;const project = process.env.POSTHOG_PROJECT_ID;
if (dryRun) { console.log(JSON.stringify({ dashboard: { name: NAME, description: DESCRIPTION }, insights: INSIGHTS }, null, 2)); process.exit(0);}if (!key || !project) { console.error("Set POSTHOG_PERSONAL_API_KEY and POSTHOG_PROJECT_ID (or pass --dry-run)."); process.exit(1);}
async function api(method, path, body) { const res = await fetch(`${host}/api/projects/${project}${path}`, { method, headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: body && JSON.stringify(body), }); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`); return res.json();}
// Keep an earlier run's dashboard, so its share link stays the same, and replace its charts.const existing = await api("GET", `/dashboards/?search=${encodeURIComponent(NAME)}`);let dashboard = existing.results.find((d) => d.name === NAME);if (dashboard) { const full = await api("GET", `/dashboards/${dashboard.id}/`); for (const tile of full.tiles ?? []) { if (tile.insight) await api("PATCH", `/insights/${tile.insight.id}/`, { deleted: true }); } await api("PATCH", `/dashboards/${dashboard.id}/`, { description: DESCRIPTION });} else { dashboard = await api("POST", "/dashboards/", { name: NAME, description: DESCRIPTION });}
const layoutById = new Map();for (const { layout, ...insight } of INSIGHTS) { const created = await api("POST", "/insights/", { ...insight, dashboards: [dashboard.id] }); layoutById.set(created.id, layout);}
// Size each chart: the wide grid, and one column on phones.const { tiles } = await api("GET", `/dashboards/${dashboard.id}/`);await api("PATCH", `/dashboards/${dashboard.id}/`, { tiles: tiles .filter((t) => t.insight && layoutById.has(t.insight.id)) .map((t) => { const sm = layoutById.get(t.insight.id); return { id: t.id, layouts: { sm, xs: { x: 0, y: sm.y, w: 1, h: sm.h } } }; }),});
// A public, read-only link: anyone with it sees the charts, not your project.const sharing = await api("PATCH", `/dashboards/${dashboard.id}/sharing/`, { enabled: true });console.log(`Dashboard: ${host}/project/${project}/dashboard/${dashboard.id}`);console.log(`Embed URL: ${host}/embedded/${sharing.access_token}`);Every chart counts events; none follows a person. That fits cookieless tracking, where PostHog can't tell one visitor's sessions apart over days.
What Chai doesn't track
Model call success and latency, tokens, cost, and generated files stored: these happen on your server and at your providers (Fal.ai, OpenRouter), which report them accurately. Chai covers the front end only.