Reports the activation + demo funnels the app instruments (app_opened -> connection_succeeded, demo_started -> demo_completed -> demo_exited_to_connect) and the money metric: of users who started the offline demo, how many later reached a working connection. Read-only HogQL. Needs a PostHog PERSONAL API key (read scope) — the app only ships the write-only ingest key, so funnel data can't be read without one. This makes that the single remaining unblock: set POSTHOG_PERSONAL_API_KEY + POSTHOG_PROJECT_ID and run it. Prints setup instructions if unset. Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6 Co-authored-by: engineer <engineer@macbookpro.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
135 lines
5.4 KiB
JavaScript
135 lines
5.4 KiB
JavaScript
#!/usr/bin/env node
|
|
//
|
|
// Activation + demo funnel report from PostHog.
|
|
//
|
|
// Answers the retention question this app's analytics exist for: of everyone
|
|
// who opens the app, how many reach a working connection — and does the
|
|
// offline demo (demo_started) move that number?
|
|
//
|
|
// Requires a PostHog PERSONAL API key (read scope) — NOT the client ingest
|
|
// key (EXPO_PUBLIC_POSTHOG_KEY) shipped in the app, which is write-only.
|
|
//
|
|
// POSTHOG_PERSONAL_API_KEY personal API key, "Read" scope (Settings ->
|
|
// Personal API keys)
|
|
// POSTHOG_PROJECT_ID numeric project id (Settings -> Project)
|
|
// POSTHOG_HOST optional, default https://eu.posthog.com
|
|
// FUNNEL_DAYS optional lookback window, default 30
|
|
//
|
|
// Usage: node scripts/funnel-report.mjs
|
|
//
|
|
// Read-only: issues HogQL SELECTs only. No data is written.
|
|
|
|
const KEY = process.env.POSTHOG_PERSONAL_API_KEY
|
|
const PROJECT = process.env.POSTHOG_PROJECT_ID
|
|
const HOST = (process.env.POSTHOG_HOST || "https://eu.posthog.com").replace(/\/+$/, "")
|
|
const DAYS = Number(process.env.FUNNEL_DAYS || 30)
|
|
|
|
if (!KEY || !PROJECT) {
|
|
console.error(
|
|
[
|
|
"Missing credentials. This report needs a PostHog PERSONAL API key (read scope),",
|
|
"not the app's client ingest key.",
|
|
"",
|
|
" export POSTHOG_PERSONAL_API_KEY=phx_... # Settings > Personal API keys (Read)",
|
|
" export POSTHOG_PROJECT_ID=12345 # Settings > Project ID",
|
|
" export POSTHOG_HOST=https://eu.posthog.com # optional (EU default)",
|
|
" node scripts/funnel-report.mjs",
|
|
].join("\n"),
|
|
)
|
|
process.exit(2)
|
|
}
|
|
|
|
async function hogql(query) {
|
|
const res = await fetch(`${HOST}/api/projects/${PROJECT}/query/`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ query: { kind: "HogQLQuery", query } }),
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.text()
|
|
throw new Error(`PostHog query failed (HTTP ${res.status}): ${body.slice(0, 300)}`)
|
|
}
|
|
const json = await res.json()
|
|
return json.results || []
|
|
}
|
|
|
|
const pct = (n, d) => (d > 0 ? `${((100 * n) / d).toFixed(1)}%` : "—")
|
|
|
|
async function main() {
|
|
const since = `now() - INTERVAL ${DAYS} DAY`
|
|
|
|
// 1. Per-event volume + unique users in the window.
|
|
const events = [
|
|
"app_opened",
|
|
"connection_form_submitted",
|
|
"connection_attempted",
|
|
"connection_succeeded",
|
|
"connection_failed",
|
|
"message_sent",
|
|
"response_received",
|
|
"demo_started",
|
|
"demo_step_advanced",
|
|
"demo_completed",
|
|
"demo_exited_to_connect",
|
|
]
|
|
const rows = await hogql(
|
|
`SELECT event, count() AS events, count(DISTINCT person_id) AS users
|
|
FROM events
|
|
WHERE event IN (${events.map((e) => `'${e}'`).join(",")})
|
|
AND timestamp > ${since}
|
|
GROUP BY event`,
|
|
)
|
|
const u = {}
|
|
for (const [event, , users] of rows) u[event] = Number(users)
|
|
const has = (e) => u[e] || 0
|
|
|
|
// 2. Demo -> real connection correlation (the money metric): of users who
|
|
// started the demo, how many later reached a successful connection.
|
|
const [[demoUsers = 0, demoThenConnected = 0] = []] = await hogql(
|
|
`SELECT
|
|
count(DISTINCT person_id) AS demo_users,
|
|
count(DISTINCT if(connected, person_id, NULL)) AS demo_then_connected
|
|
FROM (
|
|
SELECT person_id,
|
|
maxIf(1, event='connection_succeeded') AS connected
|
|
FROM events
|
|
WHERE timestamp > ${since}
|
|
AND person_id IN (
|
|
SELECT DISTINCT person_id FROM events
|
|
WHERE event='demo_started' AND timestamp > ${since}
|
|
)
|
|
GROUP BY person_id
|
|
)`,
|
|
)
|
|
|
|
console.log(`\n=== OpenCode Mobile funnel — last ${DAYS} days (${HOST}, project ${PROJECT}) ===\n`)
|
|
|
|
console.log("Activation funnel (unique users):")
|
|
const opened = has("app_opened")
|
|
console.log(` app_opened ${opened}`)
|
|
console.log(` connection_form_submitted ${has("connection_form_submitted")} (${pct(has("connection_form_submitted"), opened)} of opens)`)
|
|
console.log(` connection_succeeded ${has("connection_succeeded")} (${pct(has("connection_succeeded"), opened)} of opens)`)
|
|
console.log(` message_sent ${has("message_sent")} (${pct(has("message_sent"), opened)} of opens)`)
|
|
console.log(` response_received ${has("response_received")}`)
|
|
console.log(` connection_failed ${has("connection_failed")}`)
|
|
|
|
console.log("\nDemo funnel (unique users):")
|
|
const dStart = has("demo_started")
|
|
console.log(` demo_started ${dStart}`)
|
|
console.log(` demo_step_advanced ${has("demo_step_advanced")} (${pct(has("demo_step_advanced"), dStart)})`)
|
|
console.log(` demo_completed ${has("demo_completed")} (${pct(has("demo_completed"), dStart)})`)
|
|
console.log(` demo_exited_to_connect ${has("demo_exited_to_connect")} (${pct(has("demo_exited_to_connect"), dStart)})`)
|
|
|
|
console.log("\nDemo → connection (does the demo drive activation?):")
|
|
console.log(` started demo ${Number(demoUsers)}`)
|
|
console.log(` ...later connected ${Number(demoThenConnected)} (${pct(Number(demoThenConnected), Number(demoUsers))} of demo users)`)
|
|
const nonDemoOpen = opened - dStart
|
|
console.log(` baseline connect rate ${pct(has("connection_succeeded"), opened)} (all opens)`)
|
|
console.log("")
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(String(e.message || e))
|
|
process.exit(1)
|
|
})
|