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} +
  • + ))} +
+
+
+
+ +
+ {links.privacyPolicy && ( + + Privacy policy + + )} + {links.telemetryReadme && ( + + Telemetry README + + )} + {links.telemetryPrivacyCode && ( + + Privacy scrubber code + + )} +
+
+
+ ) +} + +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) +}