diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..6141d37
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# Server-side PostHog credentials for /api/project-metrics
+POSTHOG_HOST=https://us.i.posthog.com
+POSTHOG_PROJECT_ID=68185
+
+# Required for reading event definitions and 30d volumes.
+# NOTE: this must be a PERSONAL API key (phx_), not the project token (phc_).
+# POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key
diff --git a/METRICS_SIGNAL_GUIDE.md b/METRICS_SIGNAL_GUIDE.md
new file mode 100644
index 0000000..e915ebd
--- /dev/null
+++ b/METRICS_SIGNAL_GUIDE.md
@@ -0,0 +1,105 @@
+# Homepage Metrics Signal Guide
+
+## Goal
+Show credibility metrics that reflect real usage, not only package installs.
+
+## Metric hierarchy
+1. **Primary**: GitHub traction + product usage metrics
+2. **Secondary**: PyPI download trends
+
+PyPI remains useful, but should not be the lead signal by itself.
+
+## Implemented API
+`GET /api/project-metrics`
+
+Returns:
+- `github`: stars/forks/watchers/issues
+- `usage`: total events + demos/runs/actions (30d/90d/all-time when available), source metadata, caveats
+- `warnings`: non-fatal fetch warnings
+
+## Usage metric sources
+### Source A: PostHog (preferred)
+If server env vars are set:
+- `POSTHOG_PERSONAL_API_KEY` (or `POSTHOG_API_KEY`)
+- optional `POSTHOG_PROJECT_ID` (defaults to `68185`, auto-resolved from API key if omitted)
+- optional `POSTHOG_HOST` (defaults to `https://us.posthog.com`)
+
+The API reads PostHog `event_definitions` and sums `volume_30_day` for matched events.
+
+### Source B: explicit overrides (fallback/manual)
+Use:
+- `OPENADAPT_METRIC_DEMOS_RECORDED_30D`
+- `OPENADAPT_METRIC_AGENT_RUNS_30D`
+- `OPENADAPT_METRIC_GUI_ACTIONS_30D`
+- `OPENADAPT_METRIC_TOTAL_EVENTS_30D`
+- `OPENADAPT_METRIC_TOTAL_EVENTS_90D`
+- `OPENADAPT_METRIC_TOTAL_EVENTS_ALL_TIME`
+- `OPENADAPT_METRIC_APPS_AUTOMATED`
+
+## UI behavior
+- The telemetry panel always shows **Total Events** first.
+- Detailed breakdown cards (Demos / Agent Runs / GUI Actions) are shown when telemetry has enough depth.
+- Current unlock gate:
+ - at least `100` total events in last 90 days, and
+ - at least `14` days of telemetry coverage (if coverage date is available).
+
+## Current event-name mapping (exact-first)
+### Demos
+- `recording_finished`
+- `recording_completed`
+- `demo_recorded`
+- `demo_completed`
+- `recording.stopped`
+- `recording.saved`
+- `record.stopped`
+- `capture.completed`
+- `capture.saved`
+
+### Runs
+- `automation_run`
+- `agent_run`
+- `benchmark_run`
+- `replay_started`
+- `episode_started`
+- `replay.started`
+
+### Actions
+- `action_executed`
+- `step_executed`
+- `mouse_click`
+- `keyboard_input`
+- `ui_action`
+- `action_triggered`
+
+Ignored low-signal events:
+- `function_trace`
+- `get_events.started`
+- `get_events.completed`
+- `visualize.started`
+- `visualize.completed`
+
+If exact mappings return zero for a category, the API automatically falls back to guarded pattern matching. This keeps counters populated for new event families (for example `command:*` / `operation:*`) without relying on env overrides.
+
+## Tradeoffs
+### PostHog event_definitions approach (exact-first + fallback)
+Pros:
+- no client auth exposure
+- lightweight implementation
+- easy to keep server-cached
+- uses real event names already emitted by OpenAdapt repos
+
+Cons:
+- still depends on naming consistency for best precision
+- fallback regex can overcount if external events use similar names
+
+### Env override approach
+Pros:
+- explicit and deterministic
+- useful before PostHog is fully instrumented
+
+Cons:
+- manual updates needed
+- risk of stale numbers without process discipline
+
+## Recommended next step
+Standardize new instrumentation to preserve one of the existing exact-name families to minimize reliance on fallback matching.
diff --git a/TELEMETRY_TRANSPARENCY_DESIGN.md b/TELEMETRY_TRANSPARENCY_DESIGN.md
new file mode 100644
index 0000000..2cf6406
--- /dev/null
+++ b/TELEMETRY_TRANSPARENCY_DESIGN.md
@@ -0,0 +1,113 @@
+# Telemetry Transparency Panel Design
+
+Date: 2026-03-06
+Scope: `openadapt-web` adoption telemetry section (`/` homepage)
+
+## Goal
+Make telemetry metrics auditable by users without adding modal friction or inaccurate copy.
+
+Users should be able to answer:
+1. What exactly do these counters represent?
+2. Which event names are included/excluded?
+3. What data fields are collected in telemetry events?
+4. How to opt out?
+
+## Constraints
+- Must be safe for preview branches/Netlify deploy previews.
+- Must avoid "copy drift" where docs/UI differ from code.
+- Must keep the homepage readable on mobile.
+- Must not expose secrets or raw event payloads.
+
+## Options Considered
+
+### Option A: Modal dialog ("Learn what we collect")
+Pros:
+- High visual focus
+- Plenty of space for detail
+
+Cons:
+- Extra click + context switch
+- Worse mobile ergonomics
+- More complexity (focus traps, keyboard handling)
+
+### Option B: Inline expandable panel (`details/summary`) under telemetry cards
+Pros:
+- Low friction, transparent by default location
+- Native accessibility semantics
+- Easy to keep synchronized with API payload
+
+Cons:
+- Adds vertical length to the section
+- Slightly less "spotlight" than modal
+
+### Option C: Separate telemetry transparency page
+Pros:
+- Max room for detail and diagrams
+- Better long-form documentation
+
+Cons:
+- Easy to ignore
+- Higher drift risk if disconnected from live classification constants
+
+## Decision
+Use **Option B** (inline expandable panel) and generate panel content from `/api/project-metrics` response metadata.
+
+Rationale:
+- Lowest UX friction
+- Strongest anti-drift path when metadata is API-backed from server constants
+- Works well in preview and production
+
+## Proposed Implementation
+
+### 1) API metadata contract
+Add `usage.transparency` object to `/api/project-metrics` response:
+- `classification_version`
+- `dashboard_scope`:
+ - this UI uses aggregate counts (not raw event payload display)
+ - event names + timestamps + counts for metrics
+- `included_event_names` (demos/runs/actions exact lists)
+- `ignored_event_names`
+- `ignored_event_patterns`
+- `fallback_patterns` (for each category)
+- `collection_fields`:
+ - common event envelope fields (e.g., category, timestamp)
+ - common tags (internal, package, package_version, os, python_version, ci)
+ - common operation fields (command/operation/success/duration/item_count)
+- `privacy_controls`:
+ - explicit never-collect list
+ - scrubbing behaviors
+ - opt-out env vars (`DO_NOT_TRACK`, `OPENADAPT_TELEMETRY_ENABLED`)
+- `source_links`:
+ - privacy policy
+ - telemetry source docs
+
+### 2) UI panel
+In `AdoptionSignals`:
+- Add collapsible section titled `Telemetry details (what we collect)`.
+- Render sections:
+ - Metric counting scope
+ - Included event names
+ - Excluded event names/patterns
+ - Collected fields/tags
+ - Privacy controls + opt-out
+ - Source links
+- Keep panel below metric cards and status messages.
+
+### 3) Safety / trust language
+- Avoid claiming raw payload access on the web page.
+- Clearly separate:
+ - "Data used for these counters"
+ - "Telemetry client fields in OpenAdapt instrumentation"
+
+## Tradeoffs
+- More UI density, but transparent and auditable.
+- Some duplication with privacy policy, but scoped to metrics and backed by API constants.
+- Requires periodic updates if telemetry schema changes; mitigated by API-backed metadata contract.
+
+## Preview-Branch Plan
+Roll out in PR preview first. Validate:
+1. Readability on desktop + mobile
+2. No contradiction with `/privacy-policy`
+3. Metadata stays aligned with event classification constants
+
+Then decide whether to merge to main.
diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js
new file mode 100644
index 0000000..3b17724
--- /dev/null
+++ b/components/AdoptionSignals.js
@@ -0,0 +1,625 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import {
+ faBolt,
+ faChartLine,
+ faComputerMouse,
+ faWindowRestore,
+} from '@fortawesome/free-solid-svg-icons'
+import styles from './AdoptionSignals.module.css'
+
+const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v4'
+const METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000
+const FETCH_TIMEOUT_MS = 10000
+const BREAKDOWN_MIN_EVENTS_90D = 100
+const BREAKDOWN_MIN_DAYS = 14
+
+function hasUsableUsageMetrics(payload) {
+ const usage = payload?.usage
+ if (!usage || usage.available !== true) return false
+ const candidates = [
+ usage.agentRuns30d,
+ usage.agentRuns90d,
+ usage.guiActions30d,
+ usage.guiActions90d,
+ usage.demosRecorded30d,
+ usage.demosRecorded90d,
+ usage.agentRunsAllTime,
+ usage.guiActionsAllTime,
+ usage.demosRecordedAllTime,
+ usage.totalEvents30d,
+ usage.totalEvents90d,
+ usage.totalEventsAllTime,
+ ]
+ return candidates.some((value) => typeof value === 'number')
+}
+
+function formatMetric(value) {
+ if (value === null || value === undefined || Number.isNaN(value)) return '—'
+ if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`
+ return value.toLocaleString()
+}
+
+function formatCoverageDate(value) {
+ if (!value) return null
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return null
+ return parsed.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })
+}
+
+function formatCoverageShortDate(value) {
+ if (!value) return null
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return null
+ return parsed.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })
+}
+
+function getDaysSince(value) {
+ if (!value) return null
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return null
+ const diffMs = Date.now() - parsed.getTime()
+ if (!Number.isFinite(diffMs) || diffMs < 0) return 0
+ return Math.floor(diffMs / (24 * 60 * 60 * 1000))
+}
+
+function shouldShowSecondary(primaryValue, secondaryValue) {
+ if (secondaryValue === null || secondaryValue === undefined || Number.isNaN(secondaryValue)) return false
+ if (primaryValue === null || primaryValue === undefined || Number.isNaN(primaryValue)) return true
+ if (Number(primaryValue) === Number(secondaryValue)) return false
+ return formatMetric(primaryValue) !== formatMetric(secondaryValue)
+}
+
+function MetricCard({
+ icon,
+ value,
+ label,
+ title,
+ secondaryValue,
+ secondaryLabel,
+ showSecondary = true,
+}) {
+ return (
+
+
+
+ {formatMetric(value)}
+
+
{label}
+ {showSecondary && secondaryValue !== null && secondaryValue !== undefined && (
+
+ {formatMetric(secondaryValue)} {secondaryLabel}
+
+ )}
+
+ )
+}
+
+function MetricSkeletonCard() {
+ return (
+
+ )
+}
+
+function asArray(value) {
+ return Array.isArray(value) ? value : []
+}
+
+function TelemetryTransparencyPanel({ transparency }) {
+ if (!transparency) return null
+
+ const included = transparency?.includedEventNames || {}
+ const metricFamilies = asArray(transparency?.metricScope?.includedMetricFamilies)
+ const ignoredNames = asArray(transparency?.ignoredEventNames)
+ const ignoredPatterns = asArray(transparency?.ignoredEventPatterns)
+ const fallbackPatterns = transparency?.fallbackPatterns || {}
+ const model = transparency?.telemetryDataModel || {}
+ const privacy = transparency?.privacyControls || {}
+ const links = transparency?.sourceLinks || {}
+ const version = transparency?.classificationVersion || 'unknown'
+
+ return (
+
+
+ Telemetry details (what we collect)
+
+
+
+ {transparency?.metricScope?.summary || 'Telemetry metric details.'}
+
+
+ Classification version: {version}
+
+
+
+
Metric Families
+
+ {metricFamilies.map((name) => (
+ -
+
{name}
+
+ ))}
+
+
+
+
+
Included Event Names
+
+
+
Demos
+
+ {asArray(included.demos).map((name) => (
+ -
+
{name}
+
+ ))}
+
+
+
+
Agent Runs
+
+ {asArray(included.runs).map((name) => (
+ -
+
{name}
+
+ ))}
+
+
+
+
GUI Actions
+
+ {asArray(included.actions).map((name) => (
+ -
+
{name}
+
+ ))}
+
+
+
+
+
+
+
Excluded / Ignored
+
+
+
Ignored Event Names
+
+ {ignoredNames.map((name) => (
+ -
+
{name}
+
+ ))}
+
+
+
+
Ignored Name Patterns
+
+ {ignoredPatterns.map((pattern) => (
+ -
+
{pattern}
+
+ ))}
+
+
+
+
+
+
+
Fallback Matching Patterns
+
+
+
Demos
+
+ {asArray(fallbackPatterns.demos).map((pattern) => (
+ -
+
{pattern}
+
+ ))}
+
+
+
+
Agent Runs
+
+ {asArray(fallbackPatterns.runs).map((pattern) => (
+ -
+
{pattern}
+
+ ))}
+
+
+
+
GUI Actions
+
+ {asArray(fallbackPatterns.actions).map((pattern) => (
+ -
+
{pattern}
+
+ ))}
+
+
+
+
+
+
+
Common Telemetry Fields
+
+
+
Dashboard Reads
+
+ {asArray(model.metricsDashboardReadsOnly).map((field) => (
+ -
+ {field}
+
+ ))}
+
+
+
+
Common Event Fields
+
+ {asArray(model.commonEventFields).map((field) => (
+ -
+
{field}
+
+ ))}
+
+
+
+
Common Tags
+
+ {asArray(model.commonTags).map((tag) => (
+ -
+
{tag}
+
+ ))}
+
+
+
+
+
+
+
Privacy Controls
+
+
+
Never Collected
+
+ {asArray(privacy.neverCollect).map((item) => (
+ - {item}
+ ))}
+
+
+
+
Automatic Scrubbing
+
+ {asArray(privacy.scrubPolicy).map((item) => (
+ - {item}
+ ))}
+
+
+
+
Opt-Out
+
+ {asArray(privacy.optOutEnvVars).map((item) => (
+ -
+
{item}
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function AdoptionSignals({ timeRange = 'all' }) {
+ const [loading, setLoading] = useState(true)
+ const [refreshing, setRefreshing] = useState(false)
+ const [showStaleNotice, setShowStaleNotice] = useState(false)
+ const [error, setError] = useState(null)
+ const [data, setData] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ function loadCachedMetrics() {
+ if (typeof window === 'undefined') return false
+ try {
+ const raw = window.localStorage.getItem(METRICS_CACHE_KEY)
+ if (!raw) return false
+ const parsed = JSON.parse(raw)
+ if (!parsed?.payload || !parsed?.savedAt) return false
+ const ageMs = Date.now() - parsed.savedAt
+ if (ageMs > METRICS_CACHE_TTL_MS) return false
+ if (!hasUsableUsageMetrics(parsed.payload)) return false
+ if (!cancelled) {
+ setData(parsed.payload)
+ setShowStaleNotice(true)
+ setLoading(false)
+ }
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ async function fetchMetrics({ initial }) {
+ if (initial) setLoading(true)
+ setRefreshing(true)
+ setError(null)
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
+ try {
+ const response = await fetch(`/api/project-metrics?ts=${Date.now()}`, {
+ signal: controller.signal,
+ cache: 'no-store',
+ })
+ if (!response.ok) {
+ throw new Error(`API returned ${response.status}`)
+ }
+ const payload = await response.json()
+ if (!cancelled) {
+ setData(payload)
+ setShowStaleNotice(false)
+ if (typeof window !== 'undefined' && hasUsableUsageMetrics(payload)) {
+ window.localStorage.setItem(
+ METRICS_CACHE_KEY,
+ JSON.stringify({ payload, savedAt: Date.now() })
+ )
+ }
+ }
+ } catch (fetchError) {
+ if (!cancelled) {
+ const message = fetchError?.name === 'AbortError'
+ ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
+ : fetchError.message || 'Failed to load metrics'
+ setError(message)
+ }
+ } finally {
+ clearTimeout(timeoutId)
+ if (!cancelled) {
+ setLoading(false)
+ setRefreshing(false)
+ }
+ }
+ }
+
+ const hasCachedData = loadCachedMetrics()
+ fetchMetrics({ initial: !hasCachedData })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const usageAvailable = Boolean(data?.usage?.available)
+ const usageSource = String(data?.usage?.source || '')
+ const isAllTime = timeRange === 'all'
+ const runsPrimary = isAllTime
+ ? data?.usage?.agentRunsAllTime
+ : (data?.usage?.agentRuns90d ?? data?.usage?.agentRuns30d)
+ const actionsPrimary = isAllTime
+ ? data?.usage?.guiActionsAllTime
+ : (data?.usage?.guiActions90d ?? data?.usage?.guiActions30d)
+ const demosPrimary = isAllTime
+ ? data?.usage?.demosRecordedAllTime
+ : (data?.usage?.demosRecorded90d ?? data?.usage?.demosRecorded30d)
+ const totalEventsPrimary = isAllTime
+ ? data?.usage?.totalEventsAllTime
+ : (data?.usage?.totalEvents90d ?? data?.usage?.totalEvents30d)
+ const runsSecondary = isAllTime
+ ? (data?.usage?.agentRuns90d ?? data?.usage?.agentRuns30d)
+ : data?.usage?.agentRunsAllTime
+ const actionsSecondary = isAllTime
+ ? (data?.usage?.guiActions90d ?? data?.usage?.guiActions30d)
+ : data?.usage?.guiActionsAllTime
+ const demosSecondary = isAllTime
+ ? (data?.usage?.demosRecorded90d ?? data?.usage?.demosRecorded30d)
+ : data?.usage?.demosRecordedAllTime
+ const totalEventsSecondary = isAllTime
+ ? (data?.usage?.totalEvents90d ?? data?.usage?.totalEvents30d)
+ : data?.usage?.totalEventsAllTime
+ const secondaryLabel = isAllTime ? 'in last 90d' : 'all-time total'
+ const runsShowSecondary = shouldShowSecondary(runsPrimary, runsSecondary)
+ const actionsShowSecondary = shouldShowSecondary(actionsPrimary, actionsSecondary)
+ const demosShowSecondary = shouldShowSecondary(demosPrimary, demosSecondary)
+ const totalEventsShowSecondary = shouldShowSecondary(totalEventsPrimary, totalEventsSecondary)
+ const sourceLabel = useMemo(() => {
+ if (!usageSource) return null
+ if (usageSource.startsWith('posthog')) return 'Usage metrics source: PostHog'
+ if (usageSource.startsWith('env_override')) return 'Usage metrics source: configured counters'
+ return `Usage metrics source: ${usageSource}`
+ }, [usageSource])
+ const usageStatusMessage = useMemo(() => {
+ if (usageAvailable) return null
+ if (usageSource === 'posthog_not_configured' || usageSource === 'env_override_not_set') {
+ return 'Usage metrics are not configured yet. Set PostHog credentials or OPENADAPT_METRIC_* overrides.'
+ }
+ if (usageSource.startsWith('posthog')) {
+ return 'Usage metrics are temporarily unavailable. We will retry automatically.'
+ }
+ return 'Usage metrics are currently unavailable.'
+ }, [usageAvailable, usageSource])
+ const coverageShortLabel = useMemo(
+ () => formatCoverageShortDate(data?.usage?.telemetryCoverageStartDate),
+ [data]
+ )
+ const telemetryWindowLabel = useMemo(() => {
+ if (!usageSource.startsWith('posthog')) return null
+ const formatted = formatCoverageDate(data?.usage?.telemetryCoverageStartDate)
+ if (!formatted) return null
+ return `Telemetry window: ${formatted} - present`
+ }, [data, usageSource])
+ const telemetryCardSuffix = coverageShortLabel ? ` (since ${coverageShortLabel})` : ''
+ const coverageAgeDays = getDaysSince(data?.usage?.telemetryCoverageStartDate)
+ const showEarlyDataNotice = coverageAgeDays !== null && coverageAgeDays < 30
+ const breakdownGateEvents = data?.usage?.totalEvents90d ?? data?.usage?.totalEvents30d
+ const hasBreakdownEventDepth = typeof breakdownGateEvents !== 'number'
+ ? true
+ : breakdownGateEvents >= BREAKDOWN_MIN_EVENTS_90D
+ const hasBreakdownCoverageDepth = coverageAgeDays === null
+ ? true
+ : coverageAgeDays >= BREAKDOWN_MIN_DAYS
+ const showBreakdownCards = usageAvailable && hasBreakdownEventDepth && hasBreakdownCoverageDepth
+ const breakdownGateMessage = useMemo(() => {
+ if (!usageAvailable || showBreakdownCards) return null
+ const requirements = []
+ if (
+ typeof breakdownGateEvents === 'number' &&
+ breakdownGateEvents < BREAKDOWN_MIN_EVENTS_90D
+ ) {
+ requirements.push(`${BREAKDOWN_MIN_EVENTS_90D.toLocaleString()}+ events in the last 90 days`)
+ }
+ if (
+ typeof coverageAgeDays === 'number' &&
+ coverageAgeDays < BREAKDOWN_MIN_DAYS
+ ) {
+ requirements.push(`${BREAKDOWN_MIN_DAYS}+ days of telemetry coverage`)
+ }
+ if (requirements.length === 0) {
+ return 'Detailed telemetry breakdown is temporarily hidden while we validate signal quality.'
+ }
+ return `Detailed telemetry breakdown unlocks after ${requirements.join(' and ')}.`
+ }, [usageAvailable, showBreakdownCards, breakdownGateEvents, coverageAgeDays])
+
+ const showSkeleton = loading && !data
+ const transparency = data?.usage?.transparency || null
+
+ return (
+
+
+
Product Telemetry
+
+ Privacy-preserving usage telemetry from OpenAdapt clients, shown as all-time and 90-day totals.
+
+ {telemetryWindowLabel &&
{telemetryWindowLabel}
}
+ {showEarlyDataNotice && (
+
+ Early data: telemetry collection started recently, so counts are still ramping.
+
+ )}
+
+
+ {showSkeleton && (
+ <>
+
+
+ Loading telemetry metrics...
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ >
+ )}
+ {error && !data &&
Unable to load telemetry metrics: {error}
}
+
+ {!showSkeleton && data && (
+ <>
+
+
+ {showBreakdownCards && (
+ <>
+
+
+
+ >
+ )}
+
+
+ {usageStatusMessage &&
{usageStatusMessage}
}
+ {breakdownGateMessage &&
{breakdownGateMessage}
}
+
+ {sourceLabel &&
{sourceLabel}
}
+ {refreshing &&
Refreshing latest metrics...
}
+ {showStaleNotice && !refreshing && (
+
+ Showing cached snapshot while background refresh completes.
+
+ )}
+ {error && (
+
+ Live refresh failed: {error}
+
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css
new file mode 100644
index 0000000..29bcb56
--- /dev/null
+++ b/components/AdoptionSignals.module.css
@@ -0,0 +1,335 @@
+.container {
+ background: linear-gradient(135deg, rgba(86, 13, 248, 0.11) 0%, rgba(0, 0, 30, 0.95) 100%);
+ border: 1px solid rgba(86, 13, 248, 0.28);
+ border-radius: 16px;
+ padding: 24px;
+ margin: 24px 0;
+ width: 100%;
+ max-width: none;
+ box-sizing: border-box;
+}
+
+.header {
+ text-align: center;
+ margin-bottom: 16px;
+}
+
+.title {
+ margin: 0 0 6px 0;
+ color: #ffffff;
+ font-size: 21px;
+ font-weight: 600;
+}
+
+.subtitle {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 14px;
+}
+
+.windowBadge {
+ margin: 10px auto 0 auto;
+ display: inline-block;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(147, 197, 253, 0.4);
+ background: rgba(147, 197, 253, 0.12);
+ color: #dbeafe;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+}
+
+.earlyNotice {
+ margin-top: 8px;
+ color: #fde68a;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+}
+
+.metricsGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ align-items: stretch;
+}
+
+.metricCard {
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ padding: 14px 10px;
+ text-align: center;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+}
+
+.metricValue {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ color: #93c5fd;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.metricIcon {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.metricLabel {
+ margin-top: 6px;
+ color: rgba(255, 255, 255, 0.67);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.metricSecondary {
+ margin-top: 4px;
+ color: rgba(255, 255, 255, 0.52);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.message {
+ margin-top: 12px;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 13px;
+}
+
+.loadingRow {
+ margin: 10px 0 14px 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ color: rgba(255, 255, 255, 0.74);
+ font-size: 13px;
+}
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.22);
+ border-top-color: #93c5fd;
+ border-radius: 50%;
+ animation: adoptionSpin 0.9s linear infinite;
+}
+
+@keyframes adoptionSpin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.error {
+ margin-top: 8px;
+ text-align: center;
+ color: #fca5a5;
+ font-size: 13px;
+}
+
+.source {
+ margin-top: 10px;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 11px;
+ letter-spacing: 0.2px;
+}
+
+.metricCardSkeleton {
+ position: relative;
+ overflow: hidden;
+}
+
+.metricCardSkeleton::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 255, 255, 0.06) 50%,
+ rgba(255, 255, 255, 0) 100%
+ );
+ transform: translateX(-100%);
+ animation: skeletonSweep 1.4s ease-in-out infinite;
+}
+
+.skeletonBarLarge {
+ width: 68px;
+ height: 18px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.skeletonBarSmall {
+ display: inline-block;
+ width: 88px;
+ height: 10px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+@keyframes skeletonSweep {
+ from {
+ transform: translateX(-100%);
+ }
+
+ to {
+ transform: translateX(100%);
+ }
+}
+
+@media (max-width: 1024px) {
+ .metricsGrid {
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .container {
+ padding: 16px;
+ }
+
+ .title {
+ font-size: 18px;
+ }
+}
+
+@media (max-width: 760px) {
+ .metricsGrid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.transparencyPanel {
+ margin-top: 14px;
+ background: rgba(0, 0, 0, 0.18);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.transparencySummary {
+ cursor: pointer;
+ padding: 12px 14px;
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.9);
+ list-style: none;
+}
+
+.transparencySummary::-webkit-details-marker {
+ display: none;
+}
+
+.transparencySummary::before {
+ content: '▸';
+ display: inline-block;
+ margin-right: 8px;
+ color: rgba(255, 255, 255, 0.72);
+ transform: translateY(-1px);
+ transition: transform 0.15s ease;
+}
+
+.transparencyPanel[open] .transparencySummary::before {
+ transform: rotate(90deg);
+}
+
+.transparencyBody {
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 12px 14px 14px 14px;
+}
+
+.transparencyIntro {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.transparencyMeta {
+ margin-top: 8px;
+ color: rgba(255, 255, 255, 0.62);
+ font-size: 11px;
+ letter-spacing: 0.2px;
+}
+
+.transparencySection {
+ margin-top: 12px;
+}
+
+.transparencyHeading {
+ margin: 0 0 6px 0;
+ color: #dbeafe;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.transparencySubheading {
+ margin: 0 0 4px 0;
+ color: rgba(255, 255, 255, 0.74);
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.transparencyColumns {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px 12px;
+}
+
+.transparencyList {
+ margin: 0;
+ padding-left: 18px;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.transparencyListCompact {
+ margin: 0;
+ padding-left: 18px;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.transparencyList code,
+.transparencyListCompact code {
+ color: #bfdbfe;
+ font-size: 11px;
+ word-break: break-word;
+}
+
+.transparencyLinks {
+ margin-top: 14px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.transparencyLink {
+ color: #93c5fd;
+ font-size: 12px;
+ text-decoration: none;
+ border-bottom: 1px dotted rgba(147, 197, 253, 0.7);
+}
+
+.transparencyLink:hover {
+ color: #dbeafe;
+ border-bottom-color: rgba(219, 234, 254, 0.85);
+}
diff --git a/components/Developers.js b/components/Developers.js
index dcdee5f..052d8ab 100644
--- a/components/Developers.js
+++ b/components/Developers.js
@@ -9,11 +9,13 @@ import EmailForm from '@components/EmailForm';
import InstallSection from '@components/InstallSection';
import DownloadGraph from './DownloadGraph';
import PyPIDownloadChart from './PyPIDownloadChart';
+import AdoptionSignals from './AdoptionSignals';
export default function Developers() {
const [latestRelease, setLatestRelease] = useState({ version: null, date: null });
const [downloadCount, setDownloadCount] = useState({ windows: 0, mac: 0 });
const [buildWarnings, setBuildWarnings] = useState([]);
+ const [insightRange, setInsightRange] = useState('all');
const macURL = latestRelease.version
? `https://github.com/OpenAdaptAI/OpenAdapt/releases/download/${latestRelease.version}/OpenAdapt-${latestRelease.version}.dmg`
: '';
@@ -126,8 +128,35 @@ export default function Developers() {
{/* New uv-first Installation Section */}
+ {/* Primary trust signals: GitHub + usage telemetry */}
+
+
Window:
+
+
+
+
+
+
{/* PyPI Download Statistics */}
-
+
+
+
{/* Legacy Desktop App Downloads - Disabled during transition to new architecture
diff --git a/components/Developers.module.css b/components/Developers.module.css
index fdcd610..82913d2 100644
--- a/components/Developers.module.css
+++ b/components/Developers.module.css
@@ -64,3 +64,54 @@
border: 1px solid rgba(86, 13, 248, 0.5);
z-index: 1000;
}
+
+.insightControls {
+ margin: 8px auto 0 auto;
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 10px;
+}
+
+.insightLabel {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.insightToggleGroup {
+ display: inline-flex;
+ gap: 8px;
+}
+
+.insightToggleBtn {
+ background: rgba(0, 0, 0, 0.22);
+ color: rgba(255, 255, 255, 0.78);
+ border: 1px solid rgba(86, 13, 248, 0.45);
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+ padding: 6px 12px;
+ transition: all 0.2s ease;
+}
+
+.insightToggleBtn:hover {
+ color: #fff;
+ border-color: rgba(127, 76, 255, 0.7);
+}
+
+.insightToggleBtnActive {
+ background: rgba(86, 13, 248, 0.32);
+ color: #fff;
+ border-color: rgba(127, 76, 255, 0.85);
+ box-shadow: 0 0 0 1px rgba(127, 76, 255, 0.25) inset;
+}
+
+@media (max-width: 640px) {
+ .insightControls {
+ justify-content: center;
+ }
+}
diff --git a/components/InstallSection.js b/components/InstallSection.js
index a2b42ff..3a0f345 100644
--- a/components/InstallSection.js
+++ b/components/InstallSection.js
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWindows, faApple, faLinux, faPython } from '@fortawesome/free-brands-svg-icons';
-import { faCopy, faCheck, faTerminal, faDownload } from '@fortawesome/free-solid-svg-icons';
+import { faCopy, faCheck, faTerminal } from '@fortawesome/free-solid-svg-icons';
import styles from './InstallSection.module.css';
import { getPyPIDownloadStats, formatDownloadCount } from 'utils/pypiStats';
const platforms = {
'macOS': {
+ oneLiner: '(command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh) && (uv tool install openadapt || ~/.local/bin/uv tool install openadapt) && (openadapt --help || ~/.local/bin/openadapt --help)',
icon: faApple,
commands: [
'curl -LsSf https://astral.sh/uv/install.sh | sh',
@@ -16,6 +17,7 @@ const platforms = {
note: 'Works on Intel and Apple Silicon Macs'
},
'Linux': {
+ oneLiner: '(command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh) && (uv tool install openadapt || ~/.local/bin/uv tool install openadapt) && (openadapt --help || ~/.local/bin/openadapt --help)',
icon: faLinux,
commands: [
'curl -LsSf https://astral.sh/uv/install.sh | sh',
@@ -25,6 +27,7 @@ const platforms = {
note: 'Works on most modern Linux distributions'
},
'Windows': {
+ oneLiner: 'powershell -NoProfile -ExecutionPolicy Bypass -Command "if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { irm https://astral.sh/uv/install.ps1 | iex }; uv tool install openadapt; openadapt --help"',
icon: faWindows,
commands: [
'powershell -c "irm https://astral.sh/uv/install.ps1 | iex"',
@@ -86,7 +89,7 @@ export default function InstallSection() {
}, []);
const currentPlatform = platforms[selectedPlatform];
- const allCommands = currentPlatform.commands.join('\n');
+ const fallbackCommands = currentPlatform.commands.join('\n');
return (
@@ -132,8 +135,24 @@ export default function InstallSection() {
{/* Code Block */}
- Terminal
-
+ One-liner (recommended)
+
+
+
+
+ $
+ {currentPlatform.oneLiner}
+
+
+
+ {currentPlatform.note}
+
+
+
+
+
+ Fallback (step-by-step)
+
{currentPlatform.commands.map((cmd, index) => (
@@ -143,9 +162,6 @@ export default function InstallSection() {
))}
-
- {currentPlatform.note}
-
{/* What is uv? */}
diff --git a/components/InstallSection.module.css b/components/InstallSection.module.css
index 1674b45..f8cc65c 100644
--- a/components/InstallSection.module.css
+++ b/components/InstallSection.module.css
@@ -196,6 +196,14 @@
font-size: 12px;
}
+.fallbackSection {
+ margin-top: 12px;
+ background: #1a1a2e;
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
/* UV Info */
.uvInfo {
margin-top: 16px;
diff --git a/components/PyPIDownloadChart.js b/components/PyPIDownloadChart.js
index ddf5088..eb35a6c 100644
--- a/components/PyPIDownloadChart.js
+++ b/components/PyPIDownloadChart.js
@@ -159,18 +159,35 @@ const packageColors = {
},
};
-const PyPIDownloadChart = () => {
+const PyPIDownloadChart = ({ timeRange: controlledTimeRange = null, onTimeRangeChange = null, hideRangeControl = false }) => {
const [historyData, setHistoryData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [chartType, setChartType] = useState('cumulative'); // 'cumulative', 'combined', or 'packages'
const [period, setPeriod] = useState('month');
- const [timeRange, setTimeRange] = useState('all'); // 'all' for all available (~6 months), '3m' for 3 months
+ const [internalTimeRange, setInternalTimeRange] = useState(controlledTimeRange || 'all');
const [growthStats, setGrowthStats] = useState(null);
const [recentStats, setRecentStats] = useState(null);
const [githubStats, setGithubStats] = useState(null);
const [versionHistory, setVersionHistory] = useState([]);
+ const isRangeControlled = controlledTimeRange === 'all' || controlledTimeRange === '90d'
+ const timeRange = isRangeControlled ? controlledTimeRange : internalTimeRange
+
+ const setTimeRange = (nextRange) => {
+ if (isRangeControlled) {
+ onTimeRangeChange?.(nextRange)
+ return
+ }
+ setInternalTimeRange(nextRange)
+ }
+
+ useEffect(() => {
+ if (isRangeControlled && controlledTimeRange !== internalTimeRange) {
+ setInternalTimeRange(controlledTimeRange)
+ }
+ }, [controlledTimeRange, internalTimeRange, isRangeControlled])
+
useEffect(() => {
const fetchData = async () => {
setLoading(true);
@@ -182,7 +199,7 @@ const PyPIDownloadChart = () => {
if (timeRange === 'all') {
limit = 9999; // Get all available data (~6 months from pypistats.org)
} else {
- // 3 months of data
+ // 90 days of data
limit = period === 'day' ? 90 : period === 'week' ? 13 : 3;
}
const data = await getPyPIDownloadHistoryLimited(period, limit);
@@ -591,7 +608,7 @@ const PyPIDownloadChart = () => {
PyPI Download Trends
- Historical download statistics for OpenAdapt packages
+ Useful for package momentum, but can be inflated by CI and dependency installs.
@@ -725,24 +742,26 @@ const PyPIDownloadChart = () => {
-
-
Range:
-
-
-
+ {!hideRangeControl && (
+
+
Range:
+
+
+
+
-
+ )}
{/* Chart */}
@@ -778,7 +797,7 @@ const PyPIDownloadChart = () => {
>
PyPI OpenAdapt packages
- {' '}via pypistats.org API (data retained for ~180 days)
+ {' '}via pypistats.org API (data retained for ~180 days). Treat as directional alongside usage metrics.
diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js
new file mode 100644
index 0000000..2dd30cf
--- /dev/null
+++ b/pages/api/project-metrics.js
@@ -0,0 +1,887 @@
+/**
+ * Aggregated project metrics for homepage credibility signals.
+ *
+ * Priority:
+ * 1) GitHub repository stats (stars/forks/watchers/issues)
+ * 2) Usage metrics from PostHog (if configured) or env overrides
+ * 3) Explicit caveats/source metadata for transparency
+ */
+
+const DEFAULT_POSTHOG_HOST = 'https://us.posthog.com'
+const DEFAULT_POSTHOG_PROJECT_ID = '68185'
+const MAX_EVENT_DEFINITION_PAGES = 5
+const FALLBACK_PATTERN_LIMIT = 30
+const GITHUB_ORG = 'OpenAdaptAI'
+const CLASSIFICATION_VERSION = '2026-03-06'
+
+// Canonical event names from OpenAdapt codebases (legacy PostHog + shared telemetry conventions).
+const EVENT_CLASSIFICATION = {
+ demos: {
+ exact: [
+ 'recording_finished',
+ 'recording_completed',
+ 'demo_recorded',
+ 'demo_completed',
+ 'recording.stopped',
+ 'recording.saved',
+ 'record.stopped',
+ 'capture.completed',
+ 'capture.saved',
+ ],
+ fallbackPatterns: [
+ /(?:^|[._:-])(demo|record|recording|capture)(?:[._:-]|$)/i,
+ /(?:finished|completed|saved|recorded|stopped)$/i,
+ /^command:(capture|record)/i,
+ /^operation:(capture|record)/i,
+ ],
+ },
+ runs: {
+ exact: [
+ 'automation_run',
+ 'agent_run',
+ 'benchmark_run',
+ 'replay_started',
+ 'episode_started',
+ 'replay.started',
+ ],
+ fallbackPatterns: [
+ /(?:^|[._:-])(replay|benchmark|eval|episode|agent_run|automation_run)(?:[._:-]|$)/i,
+ /^command:(replay|run|eval|benchmark|execute|agent)/i,
+ /^operation:(replay|run|eval|benchmark|execute|agent)/i,
+ ],
+ },
+ actions: {
+ exact: [
+ 'action_executed',
+ 'step_executed',
+ 'mouse_click',
+ 'keyboard_input',
+ 'ui_action',
+ 'action_triggered',
+ ],
+ fallbackPatterns: [
+ /(?:^|[._:-])(action|step|click|keyboard|mouse|scroll|key|keypress|type|drag)(?:[._:-]|$)/i,
+ /^operation:.*(?:action|step|click|type|scroll|press|key|mouse)/i,
+ ],
+ },
+}
+
+const IGNORED_EVENT_NAMES = new Set([
+ 'function_trace',
+ 'get_events.started',
+ 'get_events.completed',
+ 'visualize.started',
+ 'visualize.completed',
+])
+
+const IGNORED_EVENT_PATTERNS = [
+ /^error[:._-]/i,
+ /^exception[:._-]/i,
+ /(?:^|[._:-])(startup|shutdown)(?:[._:-]|$)/i,
+]
+
+const TELEMETRY_DATA_MODEL = {
+ sdk: 'openadapt-telemetry',
+ sdkVersion: '0.1.0',
+ metricsDashboardReadsOnly: [
+ 'event name',
+ 'event timestamp (aggregated windows: 30d/90d/all-time)',
+ 'aggregated event count',
+ ],
+ commonEventFields: [
+ 'category',
+ 'timestamp',
+ 'package_name',
+ 'success',
+ 'duration_ms',
+ 'item_count',
+ 'command',
+ 'operation',
+ ],
+ commonTags: [
+ 'internal',
+ 'package',
+ 'package_version',
+ 'python_version',
+ 'os',
+ 'os_version',
+ 'ci',
+ ],
+}
+
+const TELEMETRY_PRIVACY_CONTROLS = {
+ neverCollect: [
+ 'screenshots or images',
+ 'raw text or file contents',
+ 'API keys or passwords',
+ 'PII user profile fields (name/email/IP)',
+ ],
+ scrubPolicy: [
+ 'PII-like keys are redacted (password/token/api_key/etc.)',
+ 'emails/phones/secrets are pattern-scrubbed',
+ 'file paths are sanitized to remove usernames',
+ 'user IDs are anonymized before upload',
+ 'send_default_pii is enforced false',
+ ],
+ optOutEnvVars: [
+ 'DO_NOT_TRACK=1',
+ 'OPENADAPT_TELEMETRY_ENABLED=false',
+ ],
+}
+
+function parseIntEnv(name) {
+ const raw = process.env[name]
+ if (raw === undefined || raw === null || raw === '') return null
+ const value = Number.parseInt(raw, 10)
+ return Number.isFinite(value) ? value : null
+}
+
+function formatError(error) {
+ if (!error) return 'unknown'
+ if (typeof error === 'string') return error
+ return error.message || String(error)
+}
+
+function _serializePattern(pattern) {
+ if (pattern instanceof RegExp) {
+ return pattern.toString()
+ }
+ return String(pattern)
+}
+
+function buildTransparencyMetadata() {
+ return {
+ classificationVersion: CLASSIFICATION_VERSION,
+ metricScope: {
+ summary: 'Homepage metrics use aggregate telemetry counts (no raw event payloads are displayed).',
+ includedMetricFamilies: ['total_events', 'agent_runs', 'gui_actions', 'demos_recorded'],
+ },
+ includedEventNames: {
+ demos: [...EVENT_CLASSIFICATION.demos.exact],
+ runs: [...EVENT_CLASSIFICATION.runs.exact],
+ actions: [...EVENT_CLASSIFICATION.actions.exact],
+ },
+ ignoredEventNames: Array.from(IGNORED_EVENT_NAMES).sort(),
+ ignoredEventPatterns: IGNORED_EVENT_PATTERNS.map(_serializePattern),
+ fallbackPatterns: {
+ demos: EVENT_CLASSIFICATION.demos.fallbackPatterns.map(_serializePattern),
+ runs: EVENT_CLASSIFICATION.runs.fallbackPatterns.map(_serializePattern),
+ actions: EVENT_CLASSIFICATION.actions.fallbackPatterns.map(_serializePattern),
+ },
+ telemetryDataModel: TELEMETRY_DATA_MODEL,
+ privacyControls: TELEMETRY_PRIVACY_CONTROLS,
+ sourceLinks: {
+ privacyPolicy: '/privacy-policy',
+ telemetryReadme: 'https://github.com/OpenAdaptAI/openadapt-telemetry#readme',
+ telemetryPrivacyCode:
+ 'https://github.com/OpenAdaptAI/openadapt-telemetry/blob/main/src/openadapt_telemetry/privacy.py',
+ },
+ }
+}
+
+async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ return await fetch(url, { ...options, signal: controller.signal })
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ throw new Error(`Request timed out after ${timeoutMs}ms`)
+ }
+ throw error
+ } finally {
+ clearTimeout(timeoutId)
+ }
+}
+
+async function fetchGitHubStats() {
+ const repos = []
+ let page = 1
+ while (true) {
+ const response = await fetchWithTimeout(
+ `https://api.github.com/orgs/${GITHUB_ORG}/repos?per_page=100&page=${page}&type=public`,
+ {
+ headers: { 'User-Agent': 'OpenAdapt-Web/1.0 (https://openadapt.ai)' },
+ }
+ )
+ if (!response.ok) {
+ throw new Error(`GitHub API returned ${response.status}`)
+ }
+ const batch = await response.json()
+ if (!Array.isArray(batch) || batch.length === 0) break
+ repos.push(...batch)
+ page += 1
+ }
+
+ const openadaptRepos = repos.filter((repo) => {
+ const name = String(repo?.name || '').toLowerCase()
+ return !repo?.private && !repo?.archived && (name === 'openadapt' || name.startsWith('openadapt-'))
+ })
+
+ const stars = openadaptRepos.reduce((sum, repo) => sum + (repo?.stargazers_count || 0), 0)
+ const forks = openadaptRepos.reduce((sum, repo) => sum + (repo?.forks_count || 0), 0)
+
+ return {
+ stars,
+ forks,
+ repoCount: openadaptRepos.length,
+ }
+}
+
+function getPosthogConfig() {
+ const host = process.env.POSTHOG_HOST || process.env.NEXT_PUBLIC_POSTHOG_HOST || DEFAULT_POSTHOG_HOST
+ const projectId =
+ process.env.POSTHOG_PROJECT_ID ||
+ process.env.NEXT_PUBLIC_POSTHOG_PROJECT_ID ||
+ DEFAULT_POSTHOG_PROJECT_ID
+ const apiKey = process.env.POSTHOG_PERSONAL_API_KEY || process.env.POSTHOG_API_KEY
+
+ if (!apiKey) return null
+ return { host: normalizePosthogApiHost(host), projectId, apiKey }
+}
+
+function normalizePosthogApiHost(host) {
+ if (!host) return DEFAULT_POSTHOG_HOST
+ // Private API endpoints should target app domains, not ingestion domains.
+ // e.g. us.i.posthog.com -> us.posthog.com
+ return String(host).replace('.i.posthog.com', '.posthog.com')
+}
+
+async function resolveProjectId({ host, projectId, apiKey }) {
+ if (projectId) return projectId
+
+ const response = await fetchWithTimeout(`${host}/api/projects/?limit=100`, {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenAdapt-Web/1.0',
+ },
+ })
+ if (!response.ok) {
+ throw new Error(`PostHog API returned ${response.status} for projects`)
+ }
+ const payload = await response.json()
+ const projects = Array.isArray(payload) ? payload : payload?.results || []
+
+ if (!projects.length) {
+ throw new Error('No PostHog projects returned for API key')
+ }
+
+ const preferred = projects.find((project) =>
+ String(project?.name || '').toLowerCase().includes('openadapt')
+ )
+ const selected = preferred || projects[0]
+ return String(selected.id)
+}
+
+function uniqueNames(input) {
+ const out = new Set()
+ for (const raw of input || []) {
+ const name = String(raw || '').trim().toLowerCase()
+ if (name) out.add(name)
+ }
+ return [...out]
+}
+
+function toSqlInList(names) {
+ return names
+ .map((name) => `'${name.replaceAll("'", "''")}'`)
+ .join(', ')
+}
+
+async function runHogQLCount({ host, projectId, apiKey, eventNames }) {
+ const response = await fetchWithTimeout(`${host}/api/projects/${projectId}/query/`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenAdapt-Web/1.0',
+ },
+ body: JSON.stringify({
+ query: {
+ kind: 'HogQLQuery',
+ query: `
+ SELECT
+ countIf(event IN (${toSqlInList(eventNames)}) AND timestamp >= now() - INTERVAL 30 DAY),
+ countIf(event IN (${toSqlInList(eventNames)}) AND timestamp >= now() - INTERVAL 90 DAY),
+ countIf(event IN (${toSqlInList(eventNames)}))
+ FROM events
+ `.trim(),
+ },
+ }),
+ })
+
+ let payload = {}
+ try {
+ payload = await response.json()
+ } catch {
+ payload = {}
+ }
+
+ if (!response.ok) {
+ const detail = payload?.detail || payload?.code || `HTTP ${response.status}`
+ throw new Error(`PostHog query API returned ${response.status}: ${detail}`)
+ }
+
+ const value30d = Number(payload?.results?.[0]?.[0])
+ const value90d = Number(payload?.results?.[0]?.[1])
+ const valueAllTime = Number(payload?.results?.[0]?.[2])
+ return {
+ value30d: Number.isFinite(value30d) ? value30d : 0,
+ value90d: Number.isFinite(value90d) ? value90d : 0,
+ valueAllTime: Number.isFinite(valueAllTime) ? valueAllTime : 0,
+ }
+}
+
+async function runHogQLTotalCounts({ host, projectId, apiKey }) {
+ const ignoredNames = uniqueNames(Array.from(IGNORED_EVENT_NAMES))
+ const ignoredClause = ignoredNames.length > 0
+ ? `event NOT IN (${toSqlInList(ignoredNames)})`
+ : '1 = 1'
+ const response = await fetchWithTimeout(`${host}/api/projects/${projectId}/query/`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenAdapt-Web/1.0',
+ },
+ body: JSON.stringify({
+ query: {
+ kind: 'HogQLQuery',
+ query: `
+ SELECT
+ countIf(${ignoredClause} AND timestamp >= now() - INTERVAL 30 DAY),
+ countIf(${ignoredClause} AND timestamp >= now() - INTERVAL 90 DAY),
+ countIf(${ignoredClause})
+ FROM events
+ `.trim(),
+ },
+ }),
+ })
+
+ let payload = {}
+ try {
+ payload = await response.json()
+ } catch {
+ payload = {}
+ }
+
+ if (!response.ok) {
+ const detail = payload?.detail || payload?.code || `HTTP ${response.status}`
+ throw new Error(`PostHog total-events query returned ${response.status}: ${detail}`)
+ }
+
+ const value30d = Number(payload?.results?.[0]?.[0])
+ const value90d = Number(payload?.results?.[0]?.[1])
+ const valueAllTime = Number(payload?.results?.[0]?.[2])
+ return {
+ value30d: Number.isFinite(value30d) ? value30d : 0,
+ value90d: Number.isFinite(value90d) ? value90d : 0,
+ valueAllTime: Number.isFinite(valueAllTime) ? valueAllTime : 0,
+ }
+}
+
+async function runHogQLFirstSeen({ host, projectId, apiKey, eventNames }) {
+ const response = await fetchWithTimeout(`${host}/api/projects/${projectId}/query/`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenAdapt-Web/1.0',
+ },
+ body: JSON.stringify({
+ query: {
+ kind: 'HogQLQuery',
+ query: `
+ SELECT
+ min(timestamp)
+ FROM events
+ WHERE event IN (${toSqlInList(eventNames)})
+ `.trim(),
+ },
+ }),
+ })
+
+ let payload = {}
+ try {
+ payload = await response.json()
+ } catch {
+ payload = {}
+ }
+
+ if (!response.ok) {
+ const detail = payload?.detail || payload?.code || `HTTP ${response.status}`
+ throw new Error(`PostHog first-seen query returned ${response.status}: ${detail}`)
+ }
+
+ const raw = payload?.results?.[0]?.[0]
+ return raw ? String(raw) : null
+}
+
+async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) {
+ const demosNames = uniqueNames(EVENT_CLASSIFICATION.demos.exact)
+ const runsNames = uniqueNames(EVENT_CLASSIFICATION.runs.exact)
+ const actionsNames = uniqueNames(EVENT_CLASSIFICATION.actions.exact)
+ const coverageStartNames = uniqueNames([...demosNames, ...runsNames, ...actionsNames])
+
+ const [demos, runs, actions, totals, telemetryCoverageStartDate] = await Promise.all([
+ runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }),
+ runHogQLCount({ host, projectId, apiKey, eventNames: runsNames }),
+ runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames }),
+ runHogQLTotalCounts({ host, projectId, apiKey }),
+ runHogQLFirstSeen({ host, projectId, apiKey, eventNames: coverageStartNames }),
+ ])
+
+ return {
+ available: true,
+ source: 'posthog_query_api',
+ demosRecorded30d: demos.value30d,
+ demosRecorded90d: demos.value90d,
+ agentRuns30d: runs.value30d,
+ agentRuns90d: runs.value90d,
+ guiActions30d: actions.value30d,
+ guiActions90d: actions.value90d,
+ demosRecordedAllTime: demos.valueAllTime,
+ agentRunsAllTime: runs.valueAllTime,
+ guiActionsAllTime: actions.valueAllTime,
+ totalEvents30d: totals.value30d,
+ totalEvents90d: totals.value90d,
+ totalEventsAllTime: totals.valueAllTime,
+ telemetryCoverageStartDate,
+ hasAnyVolume:
+ demos.value30d > 0 ||
+ runs.value30d > 0 ||
+ actions.value30d > 0 ||
+ demos.value90d > 0 ||
+ runs.value90d > 0 ||
+ actions.value90d > 0 ||
+ totals.value30d > 0 ||
+ totals.value90d > 0,
+ caveats: ['Derived from PostHog query API (exact event-name classification + non-ignored totals)'],
+ }
+}
+
+async function fetchAllEventDefinitions({ host, projectId, apiKey }) {
+ const all = []
+ let nextUrl = `${host}/api/projects/${projectId}/event_definitions/?limit=100`
+ let pages = 0
+
+ while (nextUrl && pages < MAX_EVENT_DEFINITION_PAGES) {
+ const response = await fetchWithTimeout(nextUrl, {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenAdapt-Web/1.0',
+ },
+ })
+ if (!response.ok) {
+ throw new Error(`PostHog API returned ${response.status} for event_definitions`)
+ }
+ const data = await response.json()
+ const results = Array.isArray(data.results) ? data.results : []
+ all.push(...results)
+ nextUrl = data.next || null
+ pages += 1
+ }
+
+ return all
+}
+
+function valueFromDefinition(definition) {
+ const rawValue = definition?.volume_30_day
+ if (rawValue === null || rawValue === undefined) return null
+ const maybeNumber = Number(rawValue)
+ return Number.isFinite(maybeNumber) ? maybeNumber : null
+}
+
+function normalizeEventName(name) {
+ return String(name || '').trim().toLowerCase()
+}
+
+function shouldIgnoreEvent(name) {
+ if (IGNORED_EVENT_NAMES.has(name)) return true
+ return IGNORED_EVENT_PATTERNS.some((pattern) => pattern.test(name))
+}
+
+function toEventEntries(definitions) {
+ return definitions
+ .map((definition) => ({
+ definition,
+ name: normalizeEventName(definition?.name),
+ volume_30_day: valueFromDefinition(definition),
+ }))
+ .filter((entry) => entry.name && entry.volume_30_day !== null && !shouldIgnoreEvent(entry.name))
+}
+
+function sumByEventNames(entries, candidateNames) {
+ const target = new Set(candidateNames.map((name) => name.toLowerCase()))
+ let total = 0
+ const matched = []
+
+ for (const entry of entries) {
+ if (!target.has(entry.name)) continue
+ total += entry.volume_30_day
+ matched.push({ name: entry.definition.name, volume_30_day: entry.volume_30_day })
+ }
+
+ return { total, matched, strategy: 'exact' }
+}
+
+function sumByFallbackPatterns(entries, patterns, excludedNames = new Set()) {
+ let total = 0
+ const matched = []
+
+ for (const entry of entries) {
+ if (excludedNames.has(entry.name)) continue
+ if (!patterns.some((pattern) => pattern.test(entry.name))) continue
+ total += entry.volume_30_day
+ matched.push({ name: entry.definition.name, volume_30_day: entry.volume_30_day })
+ }
+
+ matched.sort((a, b) => b.volume_30_day - a.volume_30_day)
+ const sliced = matched.slice(0, FALLBACK_PATTERN_LIMIT)
+
+ return {
+ total,
+ matched: sliced,
+ strategy: 'pattern_fallback',
+ truncated: matched.length > FALLBACK_PATTERN_LIMIT,
+ }
+}
+
+function buildCategoryMetrics(entries, config) {
+ const exact = sumByEventNames(entries, config.exact)
+ if (exact.total > 0) {
+ return exact
+ }
+
+ const fallback = sumByFallbackPatterns(entries, config.fallbackPatterns || [])
+ if (fallback.total > 0) {
+ return fallback
+ }
+
+ return { total: 0, matched: [], strategy: 'none' }
+}
+
+async function fetchPosthogUsageMetrics() {
+ const config = getPosthogConfig()
+ if (!config) {
+ return {
+ available: false,
+ source: 'posthog_not_configured',
+ caveats: ['PostHog personal API key not configured on server'],
+ }
+ }
+ if (String(config.apiKey).startsWith('phc_')) {
+ return {
+ available: false,
+ source: 'posthog_not_configured',
+ caveats: [
+ 'POSTHOG_PERSONAL_API_KEY (phx_) is required for metrics reads',
+ 'Legacy POSTHOG_PUBLIC_KEY (phc_) is ingestion-only and cannot query event definitions',
+ ],
+ }
+ }
+
+ const resolvedProjectId = await resolveProjectId(config)
+ try {
+ const queryUsage = await fetchPosthogQueryUsageMetrics({
+ ...config,
+ projectId: resolvedProjectId,
+ })
+ return {
+ ...queryUsage,
+ caveats: [
+ ...(queryUsage.caveats || []),
+ config.projectId ? 'Using configured POSTHOG_PROJECT_ID' : 'POSTHOG_PROJECT_ID auto-resolved from API key',
+ ],
+ }
+ } catch (error) {
+ const errorMessage = formatError(error)
+ const missingQueryScope = errorMessage.includes("scope 'query:read'")
+ const fallbackCaveats = missingQueryScope
+ ? ["PostHog key missing 'query:read'; falling back to event definitions"]
+ : [`PostHog query API unavailable (${errorMessage}); falling back to event definitions`]
+
+ const fallbackUsage = await fetchPosthogUsageFromEventDefinitions({
+ ...config,
+ projectId: resolvedProjectId,
+ })
+ return {
+ ...fallbackUsage,
+ caveats: [...fallbackCaveats, ...(fallbackUsage.caveats || [])],
+ }
+ }
+}
+
+async function fetchPosthogUsageFromEventDefinitions(config) {
+ const definitions = await fetchAllEventDefinitions({
+ ...config,
+ projectId: config.projectId,
+ })
+
+ const hasAny30dVolumeData = definitions.some((definition) => {
+ const value = valueFromDefinition(definition)
+ return value !== null
+ })
+
+ if (!hasAny30dVolumeData) {
+ return {
+ available: false,
+ source: 'posthog_event_definitions_unavailable',
+ demosRecorded30d: null,
+ demosRecorded90d: null,
+ agentRuns30d: null,
+ agentRuns90d: null,
+ guiActions30d: null,
+ guiActions90d: null,
+ demosRecordedAllTime: null,
+ agentRunsAllTime: null,
+ guiActionsAllTime: null,
+ totalEvents30d: null,
+ totalEvents90d: null,
+ totalEventsAllTime: null,
+ telemetryCoverageStartDate: null,
+ hasAnyVolume: false,
+ matchedEvents: {
+ demos: [],
+ runs: [],
+ actions: [],
+ },
+ strategies: {
+ demos: 'none',
+ runs: 'none',
+ actions: 'none',
+ },
+ caveats: [
+ 'PostHog event_definitions API did not return usable volume_30_day values',
+ "Grant 'query:read' to POSTHOG_PERSONAL_API_KEY for accurate usage counters",
+ ],
+ }
+ }
+
+ const entries = toEventEntries(definitions)
+ const demos = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.demos)
+ const runs = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.runs)
+ const actions = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.actions)
+ const totalEvents30d = entries.reduce((sum, entry) => sum + entry.volume_30_day, 0)
+ const hasAnyVolume =
+ demos.total > 0 ||
+ runs.total > 0 ||
+ actions.total > 0 ||
+ totalEvents30d > 0
+
+ return {
+ // "available" means PostHog data source is configured/reachable, not
+ // necessarily that recent event volume is non-zero.
+ available: true,
+ source: 'posthog_event_definitions',
+ demosRecorded30d: demos.total,
+ demosRecorded90d: null,
+ agentRuns30d: runs.total,
+ agentRuns90d: null,
+ guiActions30d: actions.total,
+ guiActions90d: null,
+ demosRecordedAllTime: null,
+ agentRunsAllTime: null,
+ guiActionsAllTime: null,
+ totalEvents30d,
+ totalEvents90d: null,
+ totalEventsAllTime: null,
+ telemetryCoverageStartDate: null,
+ hasAnyVolume,
+ matchedEvents: {
+ demos: demos.matched,
+ runs: runs.matched,
+ actions: actions.matched,
+ },
+ strategies: {
+ demos: demos.strategy,
+ runs: runs.strategy,
+ actions: actions.strategy,
+ },
+ classificationVersion: CLASSIFICATION_VERSION,
+ caveats: [
+ 'Derived from PostHog volume_30_day by exact event-name classification',
+ 'Falls back to guarded pattern matching only when exact mapping has no data',
+ ],
+ }
+}
+
+function getEnvOverrideUsageMetrics() {
+ const demos = parseIntEnv('OPENADAPT_METRIC_DEMOS_RECORDED_30D')
+ const demos90d = parseIntEnv('OPENADAPT_METRIC_DEMOS_RECORDED_90D')
+ const runs = parseIntEnv('OPENADAPT_METRIC_AGENT_RUNS_30D')
+ const runs90d = parseIntEnv('OPENADAPT_METRIC_AGENT_RUNS_90D')
+ const actions = parseIntEnv('OPENADAPT_METRIC_GUI_ACTIONS_30D')
+ const actions90d = parseIntEnv('OPENADAPT_METRIC_GUI_ACTIONS_90D')
+ const demosAllTime = parseIntEnv('OPENADAPT_METRIC_DEMOS_RECORDED_ALL_TIME')
+ const runsAllTime = parseIntEnv('OPENADAPT_METRIC_AGENT_RUNS_ALL_TIME')
+ const actionsAllTime = parseIntEnv('OPENADAPT_METRIC_GUI_ACTIONS_ALL_TIME')
+ const totalEvents30d = parseIntEnv('OPENADAPT_METRIC_TOTAL_EVENTS_30D')
+ const totalEvents90d = parseIntEnv('OPENADAPT_METRIC_TOTAL_EVENTS_90D')
+ const totalEventsAllTime = parseIntEnv('OPENADAPT_METRIC_TOTAL_EVENTS_ALL_TIME')
+ const apps = parseIntEnv('OPENADAPT_METRIC_APPS_AUTOMATED')
+ const telemetryCoverageStartDate = process.env.OPENADAPT_METRIC_USAGE_START_DATE || null
+
+ const hasAny = [
+ demos, demos90d, runs, runs90d, actions, actions90d,
+ demosAllTime, runsAllTime, actionsAllTime,
+ totalEvents30d, totalEvents90d, totalEventsAllTime,
+ apps,
+ ]
+ .some((value) => value !== null)
+ return {
+ available: hasAny,
+ source: hasAny ? 'env_override' : 'env_override_not_set',
+ demosRecorded30d: demos,
+ demosRecorded90d: demos90d ?? demos,
+ agentRuns30d: runs,
+ agentRuns90d: runs90d ?? runs,
+ guiActions30d: actions,
+ guiActions90d: actions90d ?? actions,
+ demosRecordedAllTime: demosAllTime,
+ agentRunsAllTime: runsAllTime,
+ guiActionsAllTime: actionsAllTime,
+ totalEvents30d,
+ totalEvents90d: totalEvents90d ?? totalEvents30d,
+ totalEventsAllTime,
+ appsAutomated: apps,
+ telemetryCoverageStartDate,
+ caveats: hasAny
+ ? ['Values supplied via OPENADAPT_METRIC_* environment variables']
+ : ['No OPENADAPT_METRIC_* overrides configured'],
+ }
+}
+
+function mergeUsageMetrics(primary, fallback) {
+ const merged = {
+ demosRecorded30d:
+ primary.demosRecorded30d ?? fallback.demosRecorded30d ?? null,
+ demosRecorded90d:
+ primary.demosRecorded90d ?? fallback.demosRecorded90d ?? null,
+ agentRuns30d:
+ primary.agentRuns30d ?? fallback.agentRuns30d ?? null,
+ agentRuns90d:
+ primary.agentRuns90d ?? fallback.agentRuns90d ?? null,
+ guiActions30d:
+ primary.guiActions30d ?? fallback.guiActions30d ?? null,
+ guiActions90d:
+ primary.guiActions90d ?? fallback.guiActions90d ?? null,
+ demosRecordedAllTime:
+ primary.demosRecordedAllTime ?? fallback.demosRecordedAllTime ?? null,
+ agentRunsAllTime:
+ primary.agentRunsAllTime ?? fallback.agentRunsAllTime ?? null,
+ guiActionsAllTime:
+ primary.guiActionsAllTime ?? fallback.guiActionsAllTime ?? null,
+ totalEvents30d:
+ primary.totalEvents30d ?? fallback.totalEvents30d ?? null,
+ totalEvents90d:
+ primary.totalEvents90d ?? fallback.totalEvents90d ?? null,
+ totalEventsAllTime:
+ primary.totalEventsAllTime ?? fallback.totalEventsAllTime ?? null,
+ appsAutomated:
+ primary.appsAutomated ?? fallback.appsAutomated ?? null,
+ telemetryCoverageStartDate:
+ primary.telemetryCoverageStartDate ?? fallback.telemetryCoverageStartDate ?? null,
+ }
+
+ return {
+ available: [
+ merged.demosRecorded30d,
+ merged.demosRecorded90d,
+ merged.agentRuns30d,
+ merged.agentRuns90d,
+ merged.guiActions30d,
+ merged.guiActions90d,
+ merged.demosRecordedAllTime,
+ merged.agentRunsAllTime,
+ merged.guiActionsAllTime,
+ merged.totalEvents30d,
+ merged.totalEvents90d,
+ merged.totalEventsAllTime,
+ merged.appsAutomated,
+ ].some((value) => typeof value === 'number'),
+ source:
+ primary.available
+ ? primary.source
+ : (fallback.available ? fallback.source : (primary.source || fallback.source)),
+ caveats: [...(primary.caveats || []), ...(fallback.caveats || [])],
+ matchedEvents: primary.matchedEvents || null,
+ strategies: primary.strategies || null,
+ transparency: buildTransparencyMetadata(),
+ ...merged,
+ }
+}
+
+function isCacheableUsageSource(source) {
+ const normalized = String(source || '')
+ return normalized === 'posthog_query_api' || normalized === 'posthog_event_definitions'
+}
+
+function hasUsableGithubPayload(github) {
+ return typeof github?.stars === 'number' && typeof github?.forks === 'number'
+}
+
+function cacheControlForResponse(response) {
+ const posthogOk = isCacheableUsageSource(response?.usage?.source)
+ const githubOk = hasUsableGithubPayload(response?.github)
+ // Only cache when both sources are healthy to avoid caching partial dashboards.
+ if (posthogOk && githubOk) {
+ return 'public, max-age=30, s-maxage=120, stale-while-revalidate=300'
+ }
+ return 'no-store, max-age=0'
+}
+
+export default async function handler(req, res) {
+ const response = {
+ github: null,
+ usage: null,
+ generated_at: new Date().toISOString(),
+ warnings: [],
+ }
+
+ const envUsage = getEnvOverrideUsageMetrics()
+ const [githubResult, posthogResult] = await Promise.allSettled([
+ fetchGitHubStats(),
+ fetchPosthogUsageMetrics(),
+ ])
+
+ if (githubResult.status === 'fulfilled') {
+ response.github = githubResult.value
+ } else {
+ response.warnings.push(`github_fetch_failed:${formatError(githubResult.reason)}`)
+ }
+
+ if (posthogResult.status === 'fulfilled') {
+ response.usage = mergeUsageMetrics(posthogResult.value, envUsage)
+ } else {
+ response.warnings.push(`posthog_fetch_failed:${formatError(posthogResult.reason)}`)
+ response.usage = mergeUsageMetrics(
+ {
+ available: false,
+ source: 'posthog_error',
+ caveats: ['PostHog request failed'],
+ demosRecorded30d: null,
+ demosRecorded90d: null,
+ agentRuns30d: null,
+ agentRuns90d: null,
+ guiActions30d: null,
+ guiActions90d: null,
+ demosRecordedAllTime: null,
+ agentRunsAllTime: null,
+ guiActionsAllTime: null,
+ totalEvents30d: null,
+ totalEvents90d: null,
+ totalEventsAllTime: null,
+ appsAutomated: null,
+ telemetryCoverageStartDate: null,
+ },
+ envUsage
+ )
+ }
+
+ res.setHeader('Cache-Control', cacheControlForResponse(response))
+ res.setHeader('X-OpenAdapt-Metrics-Source', String(response?.usage?.source || 'unknown'))
+ return res.status(200).json(response)
+}