From 344ed5287026531ddfcda07cc5130db8034bd320 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 01:05:58 -0500 Subject: [PATCH 01/24] feat(web): add posthog-backed adoption signals and demote PyPI to secondary --- METRICS_SIGNAL_GUIDE.md | 80 +++++++++ components/AdoptionSignals.js | 128 +++++++++++++++ components/AdoptionSignals.module.css | 106 ++++++++++++ components/Developers.js | 4 + components/PyPIDownloadChart.js | 6 +- pages/api/project-metrics.js | 228 ++++++++++++++++++++++++++ 6 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 METRICS_SIGNAL_GUIDE.md create mode 100644 components/AdoptionSignals.js create mode 100644 components/AdoptionSignals.module.css create mode 100644 pages/api/project-metrics.js diff --git a/METRICS_SIGNAL_GUIDE.md b/METRICS_SIGNAL_GUIDE.md new file mode 100644 index 0000000..1494ee0 --- /dev/null +++ b/METRICS_SIGNAL_GUIDE.md @@ -0,0 +1,80 @@ +# 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`: demos/runs/actions (30d), 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`) +- `POSTHOG_PROJECT_ID` +- 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_APPS_AUTOMATED` + +## Current event-name mapping +### Demos +- `recording_finished` +- `recording_completed` +- `demo_recorded` +- `demo_completed` + +### Runs +- `automation_run` +- `agent_run` +- `benchmark_run` +- `replay_started` +- `episode_started` + +### Actions +- `action_executed` +- `step_executed` +- `mouse_click` +- `keyboard_input` +- `ui_action` + +If telemetry names differ, update mapping in `pages/api/project-metrics.js`. + +## Tradeoffs +### PostHog event_definitions approach +Pros: +- no client auth exposure +- lightweight implementation +- easy to keep server-cached + +Cons: +- depends on naming consistency +- may undercount if instrumentation uses different event 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 instrumentation event names in product repos to match this mapping, then remove env overrides once PostHog is complete. diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js new file mode 100644 index 0000000..e70734c --- /dev/null +++ b/components/AdoptionSignals.js @@ -0,0 +1,128 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faCodeBranch, + faStar, + faChartLine, + faComputerMouse, + faWindowRestore, +} from '@fortawesome/free-solid-svg-icons' +import styles from './AdoptionSignals.module.css' + +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 MetricCard({ icon, value, label, title }) { + return ( +
+
+ + {formatMetric(value)} +
+
{label}
+
+ ) +} + +export default function AdoptionSignals() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [data, setData] = useState(null) + + useEffect(() => { + let cancelled = false + + async function fetchMetrics() { + setLoading(true) + setError(null) + try { + const response = await fetch('/api/project-metrics') + if (!response.ok) { + throw new Error(`API returned ${response.status}`) + } + const payload = await response.json() + if (!cancelled) setData(payload) + } catch (fetchError) { + if (!cancelled) setError(fetchError.message || 'Failed to load metrics') + } finally { + if (!cancelled) setLoading(false) + } + } + + fetchMetrics() + return () => { + cancelled = true + } + }, []) + + const usageAvailable = Boolean(data?.usage?.available) + const sourceLabel = useMemo(() => { + if (!data?.usage?.source) return null + if (String(data.usage.source).startsWith('posthog')) return 'Usage metrics source: PostHog' + if (String(data.usage.source).startsWith('env_override')) return 'Usage metrics source: configured counters' + return `Usage metrics source: ${data.usage.source}` + }, [data]) + + return ( +
+
+

OpenAdapt Adoption Signals

+

+ Prioritizing product usage and GitHub traction; package downloads are shown separately. +

+
+ + {loading &&
Loading adoption metrics...
} + {error &&
Unable to load adoption metrics: {error}
} + + {!loading && !error && ( + <> +
+ + + + + +
+ + {!usageAvailable && ( +
+ Usage metrics are not configured yet. Set PostHog credentials or OPENADAPT_METRIC_* overrides. +
+ )} + + {sourceLabel &&
{sourceLabel}
} + + )} +
+ ) +} diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css new file mode 100644 index 0000000..b5cdb5d --- /dev/null +++ b/components/AdoptionSignals.module.css @@ -0,0 +1,106 @@ +.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 auto; + max-width: 1100px; +} + +.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; +} + +.metricsGrid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.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; +} + +.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; +} + +.message { + margin-top: 12px; + text-align: center; + color: rgba(255, 255, 255, 0.7); + font-size: 13px; +} + +.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; +} + +@media (max-width: 1024px) { + .metricsGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .container { + padding: 16px; + } + + .metricsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .title { + font-size: 18px; + } +} diff --git a/components/Developers.js b/components/Developers.js index dcdee5f..d56a4e2 100644 --- a/components/Developers.js +++ b/components/Developers.js @@ -9,6 +9,7 @@ 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 }); @@ -126,6 +127,9 @@ export default function Developers() { {/* New uv-first Installation Section */} + {/* Primary trust signals: GitHub + usage telemetry */} + + {/* PyPI Download Statistics */} diff --git a/components/PyPIDownloadChart.js b/components/PyPIDownloadChart.js index ddf5088..fc5c2fd 100644 --- a/components/PyPIDownloadChart.js +++ b/components/PyPIDownloadChart.js @@ -588,10 +588,10 @@ const PyPIDownloadChart = () => {
-

PyPI Download Trends

+

PyPI Download Trends (Secondary Signal)

- Historical download statistics for OpenAdapt packages + Useful for package momentum, but can be inflated by CI and dependency installs.

@@ -778,7 +778,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..615be2b --- /dev/null +++ b/pages/api/project-metrics.js @@ -0,0 +1,228 @@ +/** + * 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 MAX_EVENT_DEFINITION_PAGES = 5 + +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) +} + +async function fetchGitHubStats() { + const response = await fetch('https://api.github.com/repos/OpenAdaptAI/OpenAdapt', { + headers: { 'User-Agent': 'OpenAdapt-Web/1.0 (https://openadapt.ai)' }, + }) + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}`) + } + const data = await response.json() + return { + stars: data.stargazers_count || 0, + forks: data.forks_count || 0, + watchers: data.subscribers_count || 0, + issues: data.open_issues_count || 0, + } +} + +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 + const apiKey = process.env.POSTHOG_PERSONAL_API_KEY || process.env.POSTHOG_API_KEY + + if (!projectId || !apiKey) return null + return { host, projectId, apiKey } +} + +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 fetch(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 maybeNumber = Number(definition?.volume_30_day) + return Number.isFinite(maybeNumber) ? maybeNumber : 0 +} + +function sumByEventNames(definitions, candidateNames) { + const target = new Set(candidateNames.map((name) => name.toLowerCase())) + let total = 0 + const matched = [] + + for (const definition of definitions) { + const name = String(definition?.name || '').toLowerCase() + if (!target.has(name)) continue + const value = valueFromDefinition(definition) + total += value + matched.push({ name: definition.name, volume_30_day: value }) + } + + return { total, matched } +} + +async function fetchPosthogUsageMetrics() { + const config = getPosthogConfig() + if (!config) { + return { + available: false, + source: 'posthog_not_configured', + caveats: ['PostHog credentials not configured on server'], + } + } + + const definitions = await fetchAllEventDefinitions(config) + + const demos = sumByEventNames(definitions, [ + 'recording_finished', + 'recording_completed', + 'demo_recorded', + 'demo_completed', + ]) + const runs = sumByEventNames(definitions, [ + 'automation_run', + 'agent_run', + 'benchmark_run', + 'replay_started', + 'episode_started', + ]) + const actions = sumByEventNames(definitions, [ + 'action_executed', + 'step_executed', + 'mouse_click', + 'keyboard_input', + 'ui_action', + ]) + + return { + available: demos.total > 0 || runs.total > 0 || actions.total > 0, + source: 'posthog_event_definitions', + demosRecorded30d: demos.total, + agentRuns30d: runs.total, + guiActions30d: actions.total, + matchedEvents: { + demos: demos.matched, + runs: runs.matched, + actions: actions.matched, + }, + caveats: [ + 'Derived from PostHog volume_30_day on matched event names', + 'Event naming consistency affects metric completeness', + ], + } +} + +function getEnvOverrideUsageMetrics() { + const demos = parseIntEnv('OPENADAPT_METRIC_DEMOS_RECORDED_30D') + const runs = parseIntEnv('OPENADAPT_METRIC_AGENT_RUNS_30D') + const actions = parseIntEnv('OPENADAPT_METRIC_GUI_ACTIONS_30D') + const apps = parseIntEnv('OPENADAPT_METRIC_APPS_AUTOMATED') + + const hasAny = [demos, runs, actions, apps].some((value) => value !== null) + return { + available: hasAny, + source: hasAny ? 'env_override' : 'env_override_not_set', + demosRecorded30d: demos, + agentRuns30d: runs, + guiActions30d: actions, + appsAutomated: apps, + 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, + agentRuns30d: + primary.agentRuns30d ?? fallback.agentRuns30d ?? null, + guiActions30d: + primary.guiActions30d ?? fallback.guiActions30d ?? null, + appsAutomated: + primary.appsAutomated ?? fallback.appsAutomated ?? null, + } + + return { + available: [merged.demosRecorded30d, merged.agentRuns30d, merged.guiActions30d, merged.appsAutomated] + .some((value) => typeof value === 'number'), + source: primary.available ? primary.source : fallback.source, + caveats: [...(primary.caveats || []), ...(fallback.caveats || [])], + matchedEvents: primary.matchedEvents || null, + ...merged, + } +} + +export default async function handler(req, res) { + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=900') + + const response = { + github: null, + usage: null, + generated_at: new Date().toISOString(), + warnings: [], + } + + try { + response.github = await fetchGitHubStats() + } catch (error) { + response.warnings.push(`github_fetch_failed:${formatError(error)}`) + } + + const envUsage = getEnvOverrideUsageMetrics() + try { + const posthogUsage = await fetchPosthogUsageMetrics() + response.usage = mergeUsageMetrics(posthogUsage, envUsage) + } catch (error) { + response.warnings.push(`posthog_fetch_failed:${formatError(error)}`) + response.usage = mergeUsageMetrics( + { + available: false, + source: 'posthog_error', + caveats: ['PostHog request failed'], + demosRecorded30d: null, + agentRuns30d: null, + guiActions30d: null, + appsAutomated: null, + }, + envUsage + ) + } + + return res.status(200).json(response) +} From 7de9012b43ee74173ae5a2bae23907ae85611bfe Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 01:19:15 -0500 Subject: [PATCH 02/24] Improve usage metric auto-mapping and add one-line install UX --- METRICS_SIGNAL_GUIDE.md | 27 ++++- components/InstallSection.js | 30 +++-- components/InstallSection.module.css | 8 ++ pages/api/project-metrics.js | 170 ++++++++++++++++++++++----- 4 files changed, 192 insertions(+), 43 deletions(-) diff --git a/METRICS_SIGNAL_GUIDE.md b/METRICS_SIGNAL_GUIDE.md index 1494ee0..f2c76d9 100644 --- a/METRICS_SIGNAL_GUIDE.md +++ b/METRICS_SIGNAL_GUIDE.md @@ -33,12 +33,17 @@ Use: - `OPENADAPT_METRIC_GUI_ACTIONS_30D` - `OPENADAPT_METRIC_APPS_AUTOMATED` -## Current event-name mapping +## 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` @@ -46,6 +51,7 @@ Use: - `benchmark_run` - `replay_started` - `episode_started` +- `replay.started` ### Actions - `action_executed` @@ -53,19 +59,28 @@ Use: - `mouse_click` - `keyboard_input` - `ui_action` +- `action_triggered` -If telemetry names differ, update mapping in `pages/api/project-metrics.js`. +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 +### 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: -- depends on naming consistency -- may undercount if instrumentation uses different event names +- still depends on naming consistency for best precision +- fallback regex can overcount if external events use similar names ### Env override approach Pros: @@ -77,4 +92,4 @@ Cons: - risk of stale numbers without process discipline ## Recommended next step -Standardize instrumentation event names in product repos to match this mapping, then remove env overrides once PostHog is complete. +Standardize new instrumentation to preserve one of the existing exact-name families to minimize reliance on fallback matching. 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/pages/api/project-metrics.js b/pages/api/project-metrics.js index 615be2b..c109be5 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -9,6 +9,73 @@ const DEFAULT_POSTHOG_HOST = 'https://us.posthog.com' const MAX_EVENT_DEFINITION_PAGES = 5 +const FALLBACK_PATTERN_LIMIT = 30 + +// 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, +] function parseIntEnv(name) { const raw = process.env[name] @@ -79,20 +146,73 @@ function valueFromDefinition(definition) { return Number.isFinite(maybeNumber) ? maybeNumber : 0 } -function sumByEventNames(definitions, candidateNames) { +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 > 0 && !shouldIgnoreEvent(entry.name)) +} + +function sumByEventNames(entries, candidateNames) { const target = new Set(candidateNames.map((name) => name.toLowerCase())) let total = 0 const matched = [] - for (const definition of definitions) { - const name = String(definition?.name || '').toLowerCase() - if (!target.has(name)) continue - const value = valueFromDefinition(definition) - total += value - matched.push({ name: definition.name, volume_30_day: value }) + 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 } + 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() { @@ -107,26 +227,10 @@ async function fetchPosthogUsageMetrics() { const definitions = await fetchAllEventDefinitions(config) - const demos = sumByEventNames(definitions, [ - 'recording_finished', - 'recording_completed', - 'demo_recorded', - 'demo_completed', - ]) - const runs = sumByEventNames(definitions, [ - 'automation_run', - 'agent_run', - 'benchmark_run', - 'replay_started', - 'episode_started', - ]) - const actions = sumByEventNames(definitions, [ - 'action_executed', - 'step_executed', - 'mouse_click', - 'keyboard_input', - 'ui_action', - ]) + 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) return { available: demos.total > 0 || runs.total > 0 || actions.total > 0, @@ -139,9 +243,15 @@ async function fetchPosthogUsageMetrics() { runs: runs.matched, actions: actions.matched, }, + strategies: { + demos: demos.strategy, + runs: runs.strategy, + actions: actions.strategy, + }, + classificationVersion: '2026-03-05', caveats: [ - 'Derived from PostHog volume_30_day on matched event names', - 'Event naming consistency affects metric completeness', + 'Derived from PostHog volume_30_day by exact event-name classification', + 'Falls back to guarded pattern matching only when exact mapping has no data', ], } } From 984544dbb8bc24e5f7a58307e7c39d02ed3cbc2b Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 10:44:28 -0500 Subject: [PATCH 03/24] Auto-resolve PostHog project ID when API key is present --- METRICS_SIGNAL_GUIDE.md | 2 +- pages/api/project-metrics.js | 38 +++++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/METRICS_SIGNAL_GUIDE.md b/METRICS_SIGNAL_GUIDE.md index f2c76d9..a347bc9 100644 --- a/METRICS_SIGNAL_GUIDE.md +++ b/METRICS_SIGNAL_GUIDE.md @@ -21,7 +21,7 @@ Returns: ### Source A: PostHog (preferred) If server env vars are set: - `POSTHOG_PERSONAL_API_KEY` (or `POSTHOG_API_KEY`) -- `POSTHOG_PROJECT_ID` +- optional `POSTHOG_PROJECT_ID` (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. diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index c109be5..6acf54c 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -111,10 +111,37 @@ function getPosthogConfig() { const projectId = process.env.POSTHOG_PROJECT_ID || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_ID const apiKey = process.env.POSTHOG_PERSONAL_API_KEY || process.env.POSTHOG_API_KEY - if (!projectId || !apiKey) return null + if (!apiKey) return null return { host, projectId, apiKey } } +async function resolveProjectId({ host, projectId, apiKey }) { + if (projectId) return projectId + + const response = await fetch(`${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) +} + async function fetchAllEventDefinitions({ host, projectId, apiKey }) { const all = [] let nextUrl = `${host}/api/projects/${projectId}/event_definitions/?limit=100` @@ -221,11 +248,15 @@ async function fetchPosthogUsageMetrics() { return { available: false, source: 'posthog_not_configured', - caveats: ['PostHog credentials not configured on server'], + caveats: ['PostHog personal API key not configured on server'], } } - const definitions = await fetchAllEventDefinitions(config) + const resolvedProjectId = await resolveProjectId(config) + const definitions = await fetchAllEventDefinitions({ + ...config, + projectId: resolvedProjectId, + }) const entries = toEventEntries(definitions) const demos = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.demos) @@ -252,6 +283,7 @@ async function fetchPosthogUsageMetrics() { 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', + config.projectId ? 'Using configured POSTHOG_PROJECT_ID' : 'POSTHOG_PROJECT_ID auto-resolved from API key', ], } } From 30d4c5c605065b71934eb97f0b777bea1b8d62e9 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 10:45:27 -0500 Subject: [PATCH 04/24] Handle phc project tokens with explicit metrics auth guidance --- pages/api/project-metrics.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 6acf54c..53c3643 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -251,6 +251,16 @@ async function fetchPosthogUsageMetrics() { 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) const definitions = await fetchAllEventDefinitions({ From c282bb1dd8ecf362376545c22441eda7694d2ab0 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 10:46:15 -0500 Subject: [PATCH 05/24] Default PostHog project ID and document env setup --- .env.example | 7 +++++++ METRICS_SIGNAL_GUIDE.md | 2 +- pages/api/project-metrics.js | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .env.example 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 index a347bc9..1928b33 100644 --- a/METRICS_SIGNAL_GUIDE.md +++ b/METRICS_SIGNAL_GUIDE.md @@ -21,7 +21,7 @@ Returns: ### Source A: PostHog (preferred) If server env vars are set: - `POSTHOG_PERSONAL_API_KEY` (or `POSTHOG_API_KEY`) -- optional `POSTHOG_PROJECT_ID` (auto-resolved from API key if omitted) +- 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. diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 53c3643..15d46ad 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -8,6 +8,7 @@ */ 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 @@ -108,7 +109,10 @@ async function fetchGitHubStats() { 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 + 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 From 9e3a7b63dd50428ab9f7c0cbf3248c4f9efc24b7 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 12:51:15 -0500 Subject: [PATCH 06/24] chore: trigger netlify preview after env update From 21b992e0cd6c9940a51e106edd661ed487449a2d Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 12:54:25 -0500 Subject: [PATCH 07/24] Treat reachable PostHog source as available even at zero volume --- pages/api/project-metrics.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 15d46ad..dc43f9b 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -276,13 +276,17 @@ async function fetchPosthogUsageMetrics() { const demos = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.demos) const runs = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.runs) const actions = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.actions) + const hasAnyVolume = demos.total > 0 || runs.total > 0 || actions.total > 0 return { - available: demos.total > 0 || runs.total > 0 || actions.total > 0, + // "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, agentRuns30d: runs.total, guiActions30d: actions.total, + hasAnyVolume, matchedEvents: { demos: demos.matched, runs: runs.matched, From d5b705a0c5bdcf411f8d6ebe025f27b944db002a Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 15:07:50 -0500 Subject: [PATCH 08/24] fix(metrics): avoid false zeros when PostHog volume fields are unavailable --- pages/api/project-metrics.js | 208 +++++++++++++++++++++++++++++++++-- 1 file changed, 199 insertions(+), 9 deletions(-) diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index dc43f9b..7e78590 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -116,7 +116,14 @@ function getPosthogConfig() { const apiKey = process.env.POSTHOG_PERSONAL_API_KEY || process.env.POSTHOG_API_KEY if (!apiKey) return null - return { host, projectId, apiKey } + 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 }) { @@ -146,6 +153,97 @@ async function resolveProjectId({ host, projectId, apiKey }) { 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, days }) { + const whereDays = Number.isFinite(days) && days > 0 + ? ` AND timestamp >= now() - INTERVAL ${Math.floor(days)} DAY` + : '' + const query = ` + SELECT count() + FROM events + WHERE event IN (${toSqlInList(eventNames)})${whereDays} + `.trim() + + const response = await fetch(`${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, + }, + }), + }) + + 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 value = Number(payload?.results?.[0]?.[0]) + return Number.isFinite(value) ? value : 0 +} + +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 [ + demos30d, + runs30d, + actions30d, + demosAllTime, + runsAllTime, + actionsAllTime, + ] = await Promise.all([ + runHogQLCount({ host, projectId, apiKey, eventNames: demosNames, days: 30 }), + runHogQLCount({ host, projectId, apiKey, eventNames: runsNames, days: 30 }), + runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames, days: 30 }), + runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }), + runHogQLCount({ host, projectId, apiKey, eventNames: runsNames }), + runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames }), + ]) + + return { + available: true, + source: 'posthog_query_api', + demosRecorded30d: demos30d, + agentRuns30d: runs30d, + guiActions30d: actions30d, + demosRecordedAllTime: demosAllTime, + agentRunsAllTime: runsAllTime, + guiActionsAllTime: actionsAllTime, + hasAnyVolume: demos30d > 0 || runs30d > 0 || actions30d > 0, + caveats: ['Derived from PostHog query API (exact event-name families)'], + } +} + async function fetchAllEventDefinitions({ host, projectId, apiKey }) { const all = [] let nextUrl = `${host}/api/projects/${projectId}/event_definitions/?limit=100` @@ -173,8 +271,10 @@ async function fetchAllEventDefinitions({ host, projectId, apiKey }) { } function valueFromDefinition(definition) { - const maybeNumber = Number(definition?.volume_30_day) - return Number.isFinite(maybeNumber) ? maybeNumber : 0 + 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) { @@ -193,7 +293,7 @@ function toEventEntries(definitions) { name: normalizeEventName(definition?.name), volume_30_day: valueFromDefinition(definition), })) - .filter((entry) => entry.name && entry.volume_30_day > 0 && !shouldIgnoreEvent(entry.name)) + .filter((entry) => entry.name && entry.volume_30_day !== null && !shouldIgnoreEvent(entry.name)) } function sumByEventNames(entries, candidateNames) { @@ -267,11 +367,75 @@ async function fetchPosthogUsageMetrics() { } 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: resolvedProjectId, + 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, + agentRuns30d: null, + guiActions30d: null, + demosRecordedAllTime: null, + agentRunsAllTime: null, + guiActionsAllTime: 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) @@ -286,6 +450,9 @@ async function fetchPosthogUsageMetrics() { demosRecorded30d: demos.total, agentRuns30d: runs.total, guiActions30d: actions.total, + demosRecordedAllTime: null, + agentRunsAllTime: null, + guiActionsAllTime: null, hasAnyVolume, matchedEvents: { demos: demos.matched, @@ -301,7 +468,6 @@ async function fetchPosthogUsageMetrics() { 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', - config.projectId ? 'Using configured POSTHOG_PROJECT_ID' : 'POSTHOG_PROJECT_ID auto-resolved from API key', ], } } @@ -310,15 +476,22 @@ function getEnvOverrideUsageMetrics() { const demos = parseIntEnv('OPENADAPT_METRIC_DEMOS_RECORDED_30D') const runs = parseIntEnv('OPENADAPT_METRIC_AGENT_RUNS_30D') const actions = parseIntEnv('OPENADAPT_METRIC_GUI_ACTIONS_30D') + 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 apps = parseIntEnv('OPENADAPT_METRIC_APPS_AUTOMATED') - const hasAny = [demos, runs, actions, apps].some((value) => value !== null) + const hasAny = [demos, runs, actions, demosAllTime, runsAllTime, actionsAllTime, apps] + .some((value) => value !== null) return { available: hasAny, source: hasAny ? 'env_override' : 'env_override_not_set', demosRecorded30d: demos, agentRuns30d: runs, guiActions30d: actions, + demosRecordedAllTime: demosAllTime, + agentRunsAllTime: runsAllTime, + guiActionsAllTime: actionsAllTime, appsAutomated: apps, caveats: hasAny ? ['Values supplied via OPENADAPT_METRIC_* environment variables'] @@ -334,16 +507,30 @@ function mergeUsageMetrics(primary, fallback) { primary.agentRuns30d ?? fallback.agentRuns30d ?? null, guiActions30d: primary.guiActions30d ?? fallback.guiActions30d ?? null, + demosRecordedAllTime: + primary.demosRecordedAllTime ?? fallback.demosRecordedAllTime ?? null, + agentRunsAllTime: + primary.agentRunsAllTime ?? fallback.agentRunsAllTime ?? null, + guiActionsAllTime: + primary.guiActionsAllTime ?? fallback.guiActionsAllTime ?? null, appsAutomated: primary.appsAutomated ?? fallback.appsAutomated ?? null, } return { - available: [merged.demosRecorded30d, merged.agentRuns30d, merged.guiActions30d, merged.appsAutomated] - .some((value) => typeof value === 'number'), + available: [ + merged.demosRecorded30d, + merged.agentRuns30d, + merged.guiActions30d, + merged.demosRecordedAllTime, + merged.agentRunsAllTime, + merged.guiActionsAllTime, + merged.appsAutomated, + ].some((value) => typeof value === 'number'), source: primary.available ? primary.source : fallback.source, caveats: [...(primary.caveats || []), ...(fallback.caveats || [])], matchedEvents: primary.matchedEvents || null, + strategies: primary.strategies || null, ...merged, } } @@ -378,6 +565,9 @@ export default async function handler(req, res) { demosRecorded30d: null, agentRuns30d: null, guiActions30d: null, + demosRecordedAllTime: null, + agentRunsAllTime: null, + guiActionsAllTime: null, appsAutomated: null, }, envUsage From 0a71bfa56ca8cfc9e220d970986a31d38eba189f Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 15:28:49 -0500 Subject: [PATCH 09/24] perf(ui): add cached adoption metrics UX and faster API fetch path --- components/AdoptionSignals.js | 122 +++++++++++++++++++++++--- components/AdoptionSignals.module.css | 81 +++++++++++++++++ pages/api/project-metrics.js | 97 ++++++++++---------- 3 files changed, 245 insertions(+), 55 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index e70734c..06472bf 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -9,6 +9,10 @@ import { } from '@fortawesome/free-solid-svg-icons' import styles from './AdoptionSignals.module.css' +const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v1' +const METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 10000 + function formatMetric(value) { if (value === null || value === undefined || Number.isNaN(value)) return '—' if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M` @@ -16,7 +20,7 @@ function formatMetric(value) { return value.toLocaleString() } -function MetricCard({ icon, value, label, title }) { +function MetricCard({ icon, value, label, title, secondaryValue, secondaryLabel }) { return (
@@ -24,36 +28,101 @@ function MetricCard({ icon, value, label, title }) { {formatMetric(value)}
{label}
+ {secondaryValue !== null && secondaryValue !== undefined && ( +
+ {formatMetric(secondaryValue)} {secondaryLabel} +
+ )} +
+ ) +} + +function MetricSkeletonCard() { + return ( + ) } export default function AdoptionSignals() { 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 - async function fetchMetrics() { - setLoading(true) + 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 (!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') + 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) + if (!cancelled) { + setData(payload) + setShowStaleNotice(false) + if (typeof window !== 'undefined') { + window.localStorage.setItem( + METRICS_CACHE_KEY, + JSON.stringify({ payload, savedAt: Date.now() }) + ) + } + } } catch (fetchError) { - if (!cancelled) setError(fetchError.message || 'Failed to load metrics') + if (!cancelled) { + const message = fetchError?.name === 'AbortError' + ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` + : fetchError.message || 'Failed to load metrics' + setError(message) + } } finally { - if (!cancelled) setLoading(false) + clearTimeout(timeoutId) + if (!cancelled) { + setLoading(false) + setRefreshing(false) + } } } - fetchMetrics() + const hasCachedData = loadCachedMetrics() + fetchMetrics({ initial: !hasCachedData }) return () => { cancelled = true } @@ -67,6 +136,8 @@ export default function AdoptionSignals() { return `Usage metrics source: ${data.usage.source}` }, [data]) + const showSkeleton = loading && !data + return (
@@ -76,10 +147,22 @@ export default function AdoptionSignals() {

- {loading &&
Loading adoption metrics...
} - {error &&
Unable to load adoption metrics: {error}
} + {showSkeleton && ( + <> +
+ + Loading adoption metrics... +
+
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ + )} + {error && !data &&
Unable to load adoption metrics: {error}
} - {!loading && !error && ( + {!showSkeleton && data && ( <>
@@ -121,6 +210,17 @@ export default function AdoptionSignals() { )} {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 index b5cdb5d..6aec18c 100644 --- a/components/AdoptionSignals.module.css +++ b/components/AdoptionSignals.module.css @@ -63,6 +63,14 @@ 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; @@ -70,6 +78,35 @@ 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; @@ -85,6 +122,50 @@ 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(3, minmax(0, 1fr)); diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 7e78590..84c87a7 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -91,8 +91,23 @@ function formatError(error) { return error.message || String(error) } +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 response = await fetch('https://api.github.com/repos/OpenAdaptAI/OpenAdapt', { + const response = await fetchWithTimeout('https://api.github.com/repos/OpenAdaptAI/OpenAdapt', { headers: { 'User-Agent': 'OpenAdapt-Web/1.0 (https://openadapt.ai)' }, }) if (!response.ok) { @@ -129,7 +144,7 @@ function normalizePosthogApiHost(host) { async function resolveProjectId({ host, projectId, apiKey }) { if (projectId) return projectId - const response = await fetch(`${host}/api/projects/?limit=100`, { + const response = await fetchWithTimeout(`${host}/api/projects/?limit=100`, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', @@ -169,16 +184,7 @@ function toSqlInList(names) { } async function runHogQLCount({ host, projectId, apiKey, eventNames, days }) { - const whereDays = Number.isFinite(days) && days > 0 - ? ` AND timestamp >= now() - INTERVAL ${Math.floor(days)} DAY` - : '' - const query = ` - SELECT count() - FROM events - WHERE event IN (${toSqlInList(eventNames)})${whereDays} - `.trim() - - const response = await fetch(`${host}/api/projects/${projectId}/query/`, { + const response = await fetchWithTimeout(`${host}/api/projects/${projectId}/query/`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, @@ -188,7 +194,12 @@ async function runHogQLCount({ host, projectId, apiKey, eventNames, days }) { body: JSON.stringify({ query: { kind: 'HogQLQuery', - query, + query: ` + SELECT + countIf(event IN (${toSqlInList(eventNames)}) AND timestamp >= now() - INTERVAL ${Math.floor(days)} DAY), + countIf(event IN (${toSqlInList(eventNames)})) + FROM events + `.trim(), }, }), }) @@ -205,8 +216,12 @@ async function runHogQLCount({ host, projectId, apiKey, eventNames, days }) { throw new Error(`PostHog query API returned ${response.status}: ${detail}`) } - const value = Number(payload?.results?.[0]?.[0]) - return Number.isFinite(value) ? value : 0 + const value30d = Number(payload?.results?.[0]?.[0]) + const valueAllTime = Number(payload?.results?.[0]?.[1]) + return { + value30d: Number.isFinite(value30d) ? value30d : 0, + valueAllTime: Number.isFinite(valueAllTime) ? valueAllTime : 0, + } } async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { @@ -214,32 +229,22 @@ async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { const runsNames = uniqueNames(EVENT_CLASSIFICATION.runs.exact) const actionsNames = uniqueNames(EVENT_CLASSIFICATION.actions.exact) - const [ - demos30d, - runs30d, - actions30d, - demosAllTime, - runsAllTime, - actionsAllTime, - ] = await Promise.all([ + const [demos, runs, actions] = await Promise.all([ runHogQLCount({ host, projectId, apiKey, eventNames: demosNames, days: 30 }), runHogQLCount({ host, projectId, apiKey, eventNames: runsNames, days: 30 }), runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames, days: 30 }), - runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }), - runHogQLCount({ host, projectId, apiKey, eventNames: runsNames }), - runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames }), ]) return { available: true, source: 'posthog_query_api', - demosRecorded30d: demos30d, - agentRuns30d: runs30d, - guiActions30d: actions30d, - demosRecordedAllTime: demosAllTime, - agentRunsAllTime: runsAllTime, - guiActionsAllTime: actionsAllTime, - hasAnyVolume: demos30d > 0 || runs30d > 0 || actions30d > 0, + demosRecorded30d: demos.value30d, + agentRuns30d: runs.value30d, + guiActions30d: actions.value30d, + demosRecordedAllTime: demos.valueAllTime, + agentRunsAllTime: runs.valueAllTime, + guiActionsAllTime: actions.valueAllTime, + hasAnyVolume: demos.value30d > 0 || runs.value30d > 0 || actions.value30d > 0, caveats: ['Derived from PostHog query API (exact event-name families)'], } } @@ -250,7 +255,7 @@ async function fetchAllEventDefinitions({ host, projectId, apiKey }) { let pages = 0 while (nextUrl && pages < MAX_EVENT_DEFINITION_PAGES) { - const response = await fetch(nextUrl, { + const response = await fetchWithTimeout(nextUrl, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', @@ -545,18 +550,22 @@ export default async function handler(req, res) { warnings: [], } - try { - response.github = await fetchGitHubStats() - } catch (error) { - response.warnings.push(`github_fetch_failed:${formatError(error)}`) + 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)}`) } - const envUsage = getEnvOverrideUsageMetrics() - try { - const posthogUsage = await fetchPosthogUsageMetrics() - response.usage = mergeUsageMetrics(posthogUsage, envUsage) - } catch (error) { - response.warnings.push(`posthog_fetch_failed:${formatError(error)}`) + 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, From 4568183254f4a0b5e4e82b136344982e06aab327 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 15:30:32 -0500 Subject: [PATCH 10/24] feat(metrics): improve loading UX and use ecosystem GitHub totals --- components/AdoptionSignals.js | 8 +++--- components/AdoptionSignals.module.css | 10 ++++--- pages/api/project-metrics.js | 39 ++++++++++++++++++++------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index 06472bf..8e9d932 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -168,14 +168,14 @@ export default function AdoptionSignals() { { + 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: data.stargazers_count || 0, - forks: data.forks_count || 0, - watchers: data.subscribers_count || 0, - issues: data.open_issues_count || 0, + stars, + forks, + repoCount: openadaptRepos.length, } } From 6ef4346425aa3a88c7d775e697ff12f306f756fb Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 15:33:54 -0500 Subject: [PATCH 11/24] fix(ui): ignore stale unconfigured adoption-metrics cache --- components/AdoptionSignals.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index 8e9d932..5fbd3be 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -13,6 +13,20 @@ const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v1' const METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000 const FETCH_TIMEOUT_MS = 10000 +function hasUsableUsageMetrics(payload) { + const usage = payload?.usage + if (!usage || usage.available !== true) return false + const candidates = [ + usage.agentRuns30d, + usage.guiActions30d, + usage.demosRecorded30d, + usage.agentRunsAllTime, + usage.guiActionsAllTime, + usage.demosRecordedAllTime, + ] + 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` @@ -69,6 +83,7 @@ export default function AdoptionSignals() { 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) @@ -98,7 +113,7 @@ export default function AdoptionSignals() { if (!cancelled) { setData(payload) setShowStaleNotice(false) - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && hasUsableUsageMetrics(payload)) { window.localStorage.setItem( METRICS_CACHE_KEY, JSON.stringify({ payload, savedAt: Date.now() }) From f6305812d3f71b7f144913e29c7df6bd32511a1d Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 5 Mar 2026 15:42:03 -0500 Subject: [PATCH 12/24] fix(copy+layout): remove secondary label and align adoption panel width --- components/AdoptionSignals.js | 2 +- components/AdoptionSignals.module.css | 6 ++++-- components/PyPIDownloadChart.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index 5fbd3be..a8048f5 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -156,7 +156,7 @@ export default function AdoptionSignals() { return (
-

OpenAdapt Adoption Signals

+

Adoption Signals

Prioritizing product usage and GitHub traction; package downloads are shown separately.

diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css index 59068a9..733d2ef 100644 --- a/components/AdoptionSignals.module.css +++ b/components/AdoptionSignals.module.css @@ -3,8 +3,10 @@ border: 1px solid rgba(86, 13, 248, 0.28); border-radius: 16px; padding: 24px; - margin: 24px auto; - max-width: 1100px; + margin: 24px 0; + width: 100%; + max-width: none; + box-sizing: border-box; } .header { diff --git a/components/PyPIDownloadChart.js b/components/PyPIDownloadChart.js index fc5c2fd..8419504 100644 --- a/components/PyPIDownloadChart.js +++ b/components/PyPIDownloadChart.js @@ -588,7 +588,7 @@ const PyPIDownloadChart = () => {
-

PyPI Download Trends (Secondary Signal)

+

PyPI Download Trends

Useful for package momentum, but can be inflated by CI and dependency installs. From 6dc3823961ef041a2a977abf733af7962acf7795 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:10:43 -0500 Subject: [PATCH 13/24] feat(adoption): share all-time/90-day window across adoption and PyPI --- components/AdoptionSignals.js | 53 ++++++++++++++++++++-------- components/Developers.js | 29 ++++++++++++++-- components/Developers.module.css | 51 +++++++++++++++++++++++++++ components/PyPIDownloadChart.js | 59 +++++++++++++++++++++----------- pages/api/project-metrics.js | 52 +++++++++++++++++++++++----- 5 files changed, 199 insertions(+), 45 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index a8048f5..f8f4762 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -9,7 +9,7 @@ import { } from '@fortawesome/free-solid-svg-icons' import styles from './AdoptionSignals.module.css' -const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v1' +const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v2' const METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000 const FETCH_TIMEOUT_MS = 10000 @@ -18,8 +18,11 @@ function hasUsableUsageMetrics(payload) { 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, @@ -64,7 +67,7 @@ function MetricSkeletonCard() { ) } -export default function AdoptionSignals() { +export default function AdoptionSignals({ timeRange = 'all' }) { const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [showStaleNotice, setShowStaleNotice] = useState(false) @@ -144,6 +147,26 @@ export default function AdoptionSignals() { }, []) const usageAvailable = Boolean(data?.usage?.available) + 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 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 secondaryLabel = isAllTime ? '90d' : 'all-time' const sourceLabel = useMemo(() => { if (!data?.usage?.source) return null if (String(data.usage.source).startsWith('posthog')) return 'Usage metrics source: PostHog' @@ -158,7 +181,7 @@ export default function AdoptionSignals() {

Adoption Signals

- Prioritizing product usage and GitHub traction; package downloads are shown separately. + Track OpenAdapt's all-time footprint and 90-day momentum.

@@ -194,27 +217,27 @@ export default function AdoptionSignals() { />
diff --git a/components/Developers.js b/components/Developers.js index d56a4e2..d9d1507 100644 --- a/components/Developers.js +++ b/components/Developers.js @@ -15,6 +15,7 @@ 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` : ''; @@ -128,10 +129,34 @@ export default function Developers() { {/* 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/PyPIDownloadChart.js b/components/PyPIDownloadChart.js index 8419504..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); @@ -725,24 +742,26 @@ const PyPIDownloadChart = () => {
-
- Range: -
- - + {!hideRangeControl && ( +
+ Range: +
+ + +
-
+ )}
{/* Chart */} diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 67c3014..314dadb 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -202,7 +202,7 @@ function toSqlInList(names) { .join(', ') } -async function runHogQLCount({ host, projectId, apiKey, eventNames, days }) { +async function runHogQLCount({ host, projectId, apiKey, eventNames }) { const response = await fetchWithTimeout(`${host}/api/projects/${projectId}/query/`, { method: 'POST', headers: { @@ -215,7 +215,8 @@ async function runHogQLCount({ host, projectId, apiKey, eventNames, days }) { kind: 'HogQLQuery', query: ` SELECT - countIf(event IN (${toSqlInList(eventNames)}) AND timestamp >= now() - INTERVAL ${Math.floor(days)} DAY), + 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(), @@ -236,9 +237,11 @@ async function runHogQLCount({ host, projectId, apiKey, eventNames, days }) { } const value30d = Number(payload?.results?.[0]?.[0]) - const valueAllTime = Number(payload?.results?.[0]?.[1]) + 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, } } @@ -249,21 +252,30 @@ async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { const actionsNames = uniqueNames(EVENT_CLASSIFICATION.actions.exact) const [demos, runs, actions] = await Promise.all([ - runHogQLCount({ host, projectId, apiKey, eventNames: demosNames, days: 30 }), - runHogQLCount({ host, projectId, apiKey, eventNames: runsNames, days: 30 }), - runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames, days: 30 }), + runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }), + runHogQLCount({ host, projectId, apiKey, eventNames: runsNames }), + runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames }), ]) 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, - hasAnyVolume: demos.value30d > 0 || runs.value30d > 0 || actions.value30d > 0, + hasAnyVolume: + demos.value30d > 0 || + runs.value30d > 0 || + actions.value30d > 0 || + demos.value90d > 0 || + runs.value90d > 0 || + actions.value90d > 0, caveats: ['Derived from PostHog query API (exact event-name families)'], } } @@ -437,8 +449,11 @@ async function fetchPosthogUsageFromEventDefinitions(config) { 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, @@ -472,8 +487,11 @@ async function fetchPosthogUsageFromEventDefinitions(config) { 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, @@ -498,21 +516,27 @@ async function fetchPosthogUsageFromEventDefinitions(config) { 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 apps = parseIntEnv('OPENADAPT_METRIC_APPS_AUTOMATED') - const hasAny = [demos, runs, actions, demosAllTime, runsAllTime, actionsAllTime, apps] + const hasAny = [demos, demos90d, runs, runs90d, actions, actions90d, demosAllTime, runsAllTime, actionsAllTime, 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, @@ -527,10 +551,16 @@ 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: @@ -544,8 +574,11 @@ function mergeUsageMetrics(primary, fallback) { return { available: [ merged.demosRecorded30d, + merged.demosRecorded90d, merged.agentRuns30d, + merged.agentRuns90d, merged.guiActions30d, + merged.guiActions90d, merged.demosRecordedAllTime, merged.agentRunsAllTime, merged.guiActionsAllTime, @@ -591,8 +624,11 @@ export default async function handler(req, res) { 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, From 868c7043f8aeae03193c60c1ef17e86a37e89c74 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:15:58 -0500 Subject: [PATCH 14/24] refine(adoption): reduce duplicate secondary metrics and add telemetry start note --- components/AdoptionSignals.js | 42 ++++++++++++++++++++++++++---- pages/api/project-metrics.js | 49 ++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index f8f4762..db7e1cc 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -37,7 +37,25 @@ function formatMetric(value) { return value.toLocaleString() } -function MetricCard({ icon, value, label, title, secondaryValue, secondaryLabel }) { +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 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 (
@@ -45,7 +63,7 @@ function MetricCard({ icon, value, label, title, secondaryValue, secondaryLabel {formatMetric(value)}
{label}
- {secondaryValue !== null && secondaryValue !== undefined && ( + {showSecondary && secondaryValue !== null && secondaryValue !== undefined && (
{formatMetric(secondaryValue)} {secondaryLabel}
@@ -167,12 +185,22 @@ export default function AdoptionSignals({ timeRange = 'all' }) { ? (data?.usage?.demosRecorded90d ?? data?.usage?.demosRecorded30d) : data?.usage?.demosRecordedAllTime const secondaryLabel = isAllTime ? '90d' : 'all-time' + const runsShowSecondary = shouldShowSecondary(runsPrimary, runsSecondary) + const actionsShowSecondary = shouldShowSecondary(actionsPrimary, actionsSecondary) + const demosShowSecondary = shouldShowSecondary(demosPrimary, demosSecondary) const sourceLabel = useMemo(() => { if (!data?.usage?.source) return null if (String(data.usage.source).startsWith('posthog')) return 'Usage metrics source: PostHog' if (String(data.usage.source).startsWith('env_override')) return 'Usage metrics source: configured counters' return `Usage metrics source: ${data.usage.source}` }, [data]) + const coverageStartLabel = useMemo(() => { + const source = String(data?.usage?.source || '') + if (!source.startsWith('posthog')) return null + const formatted = formatCoverageDate(data?.usage?.telemetryCoverageStartDate) + if (!formatted) return null + return `Telemetry coverage starts ${formatted}.` + }, [data]) const showSkeleton = loading && !data @@ -218,26 +246,29 @@ export default function AdoptionSignals({ timeRange = 'all' }) {
@@ -248,6 +279,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { )} {sourceLabel &&
{sourceLabel}
} + {coverageStartLabel &&
{coverageStartLabel}
} {refreshing &&
Refreshing latest metrics...
} {showStaleNotice && !refreshing && (
diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 314dadb..809a4a3 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -246,15 +246,54 @@ async function runHogQLCount({ host, projectId, apiKey, eventNames }) { } } +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 allNames = uniqueNames([...demosNames, ...runsNames, ...actionsNames]) - const [demos, runs, actions] = await Promise.all([ + const [demos, runs, actions, telemetryCoverageStartDate] = await Promise.all([ runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }), runHogQLCount({ host, projectId, apiKey, eventNames: runsNames }), runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames }), + runHogQLFirstSeen({ host, projectId, apiKey, eventNames: allNames }), ]) return { @@ -269,6 +308,7 @@ async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { demosRecordedAllTime: demos.valueAllTime, agentRunsAllTime: runs.valueAllTime, guiActionsAllTime: actions.valueAllTime, + telemetryCoverageStartDate, hasAnyVolume: demos.value30d > 0 || runs.value30d > 0 || @@ -457,6 +497,7 @@ async function fetchPosthogUsageFromEventDefinitions(config) { demosRecordedAllTime: null, agentRunsAllTime: null, guiActionsAllTime: null, + telemetryCoverageStartDate: null, hasAnyVolume: false, matchedEvents: { demos: [], @@ -495,6 +536,7 @@ async function fetchPosthogUsageFromEventDefinitions(config) { demosRecordedAllTime: null, agentRunsAllTime: null, guiActionsAllTime: null, + telemetryCoverageStartDate: null, hasAnyVolume, matchedEvents: { demos: demos.matched, @@ -525,6 +567,7 @@ function getEnvOverrideUsageMetrics() { const runsAllTime = parseIntEnv('OPENADAPT_METRIC_AGENT_RUNS_ALL_TIME') const actionsAllTime = parseIntEnv('OPENADAPT_METRIC_GUI_ACTIONS_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, apps] .some((value) => value !== null) @@ -541,6 +584,7 @@ function getEnvOverrideUsageMetrics() { agentRunsAllTime: runsAllTime, guiActionsAllTime: actionsAllTime, appsAutomated: apps, + telemetryCoverageStartDate, caveats: hasAny ? ['Values supplied via OPENADAPT_METRIC_* environment variables'] : ['No OPENADAPT_METRIC_* overrides configured'], @@ -569,6 +613,8 @@ function mergeUsageMetrics(primary, fallback) { primary.guiActionsAllTime ?? fallback.guiActionsAllTime ?? null, appsAutomated: primary.appsAutomated ?? fallback.appsAutomated ?? null, + telemetryCoverageStartDate: + primary.telemetryCoverageStartDate ?? fallback.telemetryCoverageStartDate ?? null, } return { @@ -633,6 +679,7 @@ export default async function handler(req, res) { agentRunsAllTime: null, guiActionsAllTime: null, appsAutomated: null, + telemetryCoverageStartDate: null, }, envUsage ) From 07de4a2cb6299c01a79dd0ac6a143f079c71c4e8 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:25:27 -0500 Subject: [PATCH 15/24] fix(adoption): compute coverage start from canonical telemetry events --- components/AdoptionSignals.js | 2 +- pages/api/project-metrics.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index db7e1cc..826694b 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -9,7 +9,7 @@ import { } from '@fortawesome/free-solid-svg-icons' import styles from './AdoptionSignals.module.css' -const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v2' +const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v3' const METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000 const FETCH_TIMEOUT_MS = 10000 diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 809a4a3..40e960f 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -12,6 +12,7 @@ const DEFAULT_POSTHOG_PROJECT_ID = '68185' const MAX_EVENT_DEFINITION_PAGES = 5 const FALLBACK_PATTERN_LIMIT = 30 const GITHUB_ORG = 'OpenAdaptAI' +const COVERAGE_START_EVENT_NAMES = ['demo_recorded', 'agent_run', 'action_executed'] // Canonical event names from OpenAdapt codebases (legacy PostHog + shared telemetry conventions). const EVENT_CLASSIFICATION = { @@ -287,13 +288,13 @@ 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 allNames = uniqueNames([...demosNames, ...runsNames, ...actionsNames]) + const coverageStartNames = uniqueNames(COVERAGE_START_EVENT_NAMES) const [demos, runs, actions, telemetryCoverageStartDate] = await Promise.all([ runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }), runHogQLCount({ host, projectId, apiKey, eventNames: runsNames }), runHogQLCount({ host, projectId, apiKey, eventNames: actionsNames }), - runHogQLFirstSeen({ host, projectId, apiKey, eventNames: allNames }), + runHogQLFirstSeen({ host, projectId, apiKey, eventNames: coverageStartNames }), ]) return { From a85e716b13de9ec4f92a7865268e6b99ed1bcc48 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:45:19 -0500 Subject: [PATCH 16/24] fix: handle transient posthog failures without false not-configured state --- components/AdoptionSignals.js | 32 +++++++++++++++++++------------- pages/api/project-metrics.js | 19 ++++++++++++++++--- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index 826694b..ef00104 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -165,6 +165,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { }, []) const usageAvailable = Boolean(data?.usage?.available) + const usageSource = String(data?.usage?.source || '') const isAllTime = timeRange === 'all' const runsPrimary = isAllTime ? data?.usage?.agentRunsAllTime @@ -189,18 +190,27 @@ export default function AdoptionSignals({ timeRange = 'all' }) { const actionsShowSecondary = shouldShowSecondary(actionsPrimary, actionsSecondary) const demosShowSecondary = shouldShowSecondary(demosPrimary, demosSecondary) const sourceLabel = useMemo(() => { - if (!data?.usage?.source) return null - if (String(data.usage.source).startsWith('posthog')) return 'Usage metrics source: PostHog' - if (String(data.usage.source).startsWith('env_override')) return 'Usage metrics source: configured counters' - return `Usage metrics source: ${data.usage.source}` - }, [data]) + 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 coverageStartLabel = useMemo(() => { - const source = String(data?.usage?.source || '') - if (!source.startsWith('posthog')) return null + if (!usageSource.startsWith('posthog')) return null const formatted = formatCoverageDate(data?.usage?.telemetryCoverageStartDate) if (!formatted) return null return `Telemetry coverage starts ${formatted}.` - }, [data]) + }, [data, usageSource]) const showSkeleton = loading && !data @@ -272,11 +282,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { />
- {!usageAvailable && ( -
- Usage metrics are not configured yet. Set PostHog credentials or OPENADAPT_METRIC_* overrides. -
- )} + {usageStatusMessage &&
{usageStatusMessage}
} {sourceLabel &&
{sourceLabel}
} {coverageStartLabel &&
{coverageStartLabel}
} diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 40e960f..cdfae50 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -631,7 +631,10 @@ function mergeUsageMetrics(primary, fallback) { merged.guiActionsAllTime, merged.appsAutomated, ].some((value) => typeof value === 'number'), - source: primary.available ? primary.source : fallback.source, + 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, @@ -639,9 +642,17 @@ function mergeUsageMetrics(primary, fallback) { } } -export default async function handler(req, res) { - res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=900') +function cacheControlForUsageSource(source) { + const normalized = String(source || '') + // Cache successful PostHog-backed payloads briefly. + if (normalized === 'posthog_query_api' || normalized === 'posthog_event_definitions') { + return 'public, max-age=30, s-maxage=120, stale-while-revalidate=300' + } + // Do not cache degraded/unconfigured payloads at the edge. + return 'no-store, max-age=0' +} +export default async function handler(req, res) { const response = { github: null, usage: null, @@ -686,5 +697,7 @@ export default async function handler(req, res) { ) } + res.setHeader('Cache-Control', cacheControlForUsageSource(response?.usage?.source)) + res.setHeader('X-OpenAdapt-Metrics-Source', String(response?.usage?.source || 'unknown')) return res.status(200).json(response) } From 478810974135e284fe8454af49746200395f2a6c Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:51:53 -0500 Subject: [PATCH 17/24] feat: emphasize telemetry coverage window in adoption cards --- components/AdoptionSignals.js | 42 ++++++++++++++++++++++----- components/AdoptionSignals.module.css | 27 +++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index ef00104..2fa8c75 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -48,6 +48,16 @@ function formatCoverageDate(value) { }) } +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 shouldShowSecondary(primaryValue, secondaryValue) { if (secondaryValue === null || secondaryValue === undefined || Number.isNaN(secondaryValue)) return false if (primaryValue === null || primaryValue === undefined || Number.isNaN(primaryValue)) return true @@ -55,7 +65,16 @@ function shouldShowSecondary(primaryValue, secondaryValue) { return formatMetric(primaryValue) !== formatMetric(secondaryValue) } -function MetricCard({ icon, value, label, title, secondaryValue, secondaryLabel, showSecondary = true }) { +function MetricCard({ + icon, + value, + label, + title, + secondaryValue, + secondaryLabel, + showSecondary = true, + chipLabel = null, +}) { return (
@@ -68,6 +87,7 @@ function MetricCard({ icon, value, label, title, secondaryValue, secondaryLabel, {formatMetric(secondaryValue)} {secondaryLabel}
)} + {chipLabel &&
{chipLabel}
}
) } @@ -205,12 +225,19 @@ export default function AdoptionSignals({ timeRange = 'all' }) { } return 'Usage metrics are currently unavailable.' }, [usageAvailable, usageSource]) - const coverageStartLabel = useMemo(() => { + 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 coverage starts ${formatted}.` + return `Telemetry window: ${formatted} - present` }, [data, usageSource]) + const telemetryCardSuffix = coverageShortLabel ? ` (since ${coverageShortLabel})` : '' + const demosAllTime = data?.usage?.demosRecordedAllTime + const demosEarlyData = typeof demosAllTime === 'number' && demosAllTime < 25 const showSkeleton = loading && !data @@ -221,6 +248,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) {

Track OpenAdapt's all-time footprint and 90-day momentum.

+ {telemetryWindowLabel &&
{telemetryWindowLabel}
}
{showSkeleton && ( @@ -256,7 +284,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { {usageStatusMessage &&
{usageStatusMessage}
} {sourceLabel &&
{sourceLabel}
} - {coverageStartLabel &&
{coverageStartLabel}
} {refreshing &&
Refreshing latest metrics...
} {showStaleNotice && !refreshing && (
diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css index 733d2ef..9b37e31 100644 --- a/components/AdoptionSignals.module.css +++ b/components/AdoptionSignals.module.css @@ -27,6 +27,19 @@ 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; +} + .metricsGrid { display: grid; grid-template-columns: repeat(5, minmax(160px, 1fr)); @@ -77,6 +90,20 @@ letter-spacing: 0.3px; } +.metricChip { + margin-top: 6px; + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(251, 191, 36, 0.45); + background: rgba(251, 191, 36, 0.15); + color: #fde68a; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; +} + .message { margin-top: 12px; text-align: center; From f504147bf79b288c7804d6ec16642ec62a7a76bb Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:54:27 -0500 Subject: [PATCH 18/24] chore: move adoption signals below pypi chart --- components/Developers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/Developers.js b/components/Developers.js index d9d1507..052d8ab 100644 --- a/components/Developers.js +++ b/components/Developers.js @@ -149,8 +149,6 @@ export default function Developers() {
- - {/* PyPI Download Statistics */} + + {/* Legacy Desktop App Downloads - Disabled during transition to new architecture

From a291d5eb15ee6414876cecf6508f92955abcdae7 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 12:58:48 -0500 Subject: [PATCH 19/24] fix: stabilize adoption metrics cache and clarify telemetry card labels --- components/AdoptionSignals.js | 41 ++++++++++++++++++++++++++++------- pages/api/project-metrics.js | 19 +++++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index 2fa8c75..ed0cfd0 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -30,6 +30,14 @@ function hasUsableUsageMetrics(payload) { return candidates.some((value) => typeof value === 'number') } +function hasUsableGithubMetrics(payload) { + return typeof payload?.github?.stars === 'number' && typeof payload?.github?.forks === 'number' +} + +function hasUsableCachedSnapshot(payload) { + return hasUsableUsageMetrics(payload) && hasUsableGithubMetrics(payload) +} + function formatMetric(value) { if (value === null || value === undefined || Number.isNaN(value)) return '—' if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M` @@ -58,6 +66,15 @@ function formatCoverageShortDate(value) { }) } +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 @@ -111,6 +128,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { const [showStaleNotice, setShowStaleNotice] = useState(false) const [error, setError] = useState(null) const [data, setData] = useState(null) + const [lastGoodGithub, setLastGoodGithub] = useState(null) useEffect(() => { let cancelled = false @@ -124,9 +142,10 @@ export default function AdoptionSignals({ timeRange = 'all' }) { 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 (!hasUsableCachedSnapshot(parsed.payload)) return false if (!cancelled) { setData(parsed.payload) + setLastGoodGithub(parsed.payload.github) setShowStaleNotice(true) setLoading(false) } @@ -152,9 +171,12 @@ export default function AdoptionSignals({ timeRange = 'all' }) { } const payload = await response.json() if (!cancelled) { + if (hasUsableGithubMetrics(payload)) { + setLastGoodGithub(payload.github) + } setData(payload) setShowStaleNotice(false) - if (typeof window !== 'undefined' && hasUsableUsageMetrics(payload)) { + if (typeof window !== 'undefined' && hasUsableCachedSnapshot(payload)) { window.localStorage.setItem( METRICS_CACHE_KEY, JSON.stringify({ payload, savedAt: Date.now() }) @@ -186,6 +208,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { const usageAvailable = Boolean(data?.usage?.available) const usageSource = String(data?.usage?.source || '') + const githubStats = hasUsableGithubMetrics(data) ? data.github : lastGoodGithub const isAllTime = timeRange === 'all' const runsPrimary = isAllTime ? data?.usage?.agentRunsAllTime @@ -205,7 +228,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { const demosSecondary = isAllTime ? (data?.usage?.demosRecorded90d ?? data?.usage?.demosRecorded30d) : data?.usage?.demosRecordedAllTime - const secondaryLabel = isAllTime ? '90d' : 'all-time' + const secondaryLabel = isAllTime ? 'in last 90d' : 'all-time total' const runsShowSecondary = shouldShowSecondary(runsPrimary, runsSecondary) const actionsShowSecondary = shouldShowSecondary(actionsPrimary, actionsSecondary) const demosShowSecondary = shouldShowSecondary(demosPrimary, demosSecondary) @@ -236,8 +259,8 @@ export default function AdoptionSignals({ timeRange = 'all' }) { return `Telemetry window: ${formatted} - present` }, [data, usageSource]) const telemetryCardSuffix = coverageShortLabel ? ` (since ${coverageShortLabel})` : '' - const demosAllTime = data?.usage?.demosRecordedAllTime - const demosEarlyData = typeof demosAllTime === 'number' && demosAllTime < 25 + const coverageAgeDays = getDaysSince(data?.usage?.telemetryCoverageStartDate) + const showEarlyDataChip = coverageAgeDays !== null && coverageAgeDays < 30 const showSkeleton = loading && !data @@ -271,13 +294,13 @@ export default function AdoptionSignals({ timeRange = 'all' }) {
@@ -289,6 +312,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { secondaryValue={runsSecondary} secondaryLabel={secondaryLabel} showSecondary={runsShowSecondary} + chipLabel={showEarlyDataChip ? 'Early data' : null} />
diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index cdfae50..74fc037 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -642,13 +642,22 @@ function mergeUsageMetrics(primary, fallback) { } } -function cacheControlForUsageSource(source) { +function isCacheableUsageSource(source) { const normalized = String(source || '') - // Cache successful PostHog-backed payloads briefly. - if (normalized === 'posthog_query_api' || normalized === 'posthog_event_definitions') { + 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' } - // Do not cache degraded/unconfigured payloads at the edge. return 'no-store, max-age=0' } @@ -697,7 +706,7 @@ export default async function handler(req, res) { ) } - res.setHeader('Cache-Control', cacheControlForUsageSource(response?.usage?.source)) + res.setHeader('Cache-Control', cacheControlForResponse(response)) res.setHeader('X-OpenAdapt-Metrics-Source', String(response?.usage?.source || 'unknown')) return res.status(200).json(response) } From c2a5fd49abf908e91f79da594069952c27a29587 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 13:03:04 -0500 Subject: [PATCH 20/24] fix: align coverage date with strict telemetry event counters --- pages/api/project-metrics.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 74fc037..8da9af1 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -12,7 +12,11 @@ const DEFAULT_POSTHOG_PROJECT_ID = '68185' const MAX_EVENT_DEFINITION_PAGES = 5 const FALLBACK_PATTERN_LIMIT = 30 const GITHUB_ORG = 'OpenAdaptAI' -const COVERAGE_START_EVENT_NAMES = ['demo_recorded', 'agent_run', 'action_executed'] +const CANONICAL_USAGE_EVENTS = { + demos: ['demo_recorded'], + runs: ['agent_run'], + actions: ['action_executed'], +} // Canonical event names from OpenAdapt codebases (legacy PostHog + shared telemetry conventions). const EVENT_CLASSIFICATION = { @@ -285,10 +289,10 @@ async function runHogQLFirstSeen({ host, projectId, apiKey, eventNames }) { } 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(COVERAGE_START_EVENT_NAMES) + const demosNames = uniqueNames(CANONICAL_USAGE_EVENTS.demos) + const runsNames = uniqueNames(CANONICAL_USAGE_EVENTS.runs) + const actionsNames = uniqueNames(CANONICAL_USAGE_EVENTS.actions) + const coverageStartNames = uniqueNames([...demosNames, ...runsNames, ...actionsNames]) const [demos, runs, actions, telemetryCoverageStartDate] = await Promise.all([ runHogQLCount({ host, projectId, apiKey, eventNames: demosNames }), @@ -317,7 +321,7 @@ async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { demos.value90d > 0 || runs.value90d > 0 || actions.value90d > 0, - caveats: ['Derived from PostHog query API (exact event-name families)'], + caveats: ['Derived from PostHog query API (strict canonical events only)'], } } From 4759a287354c3e1979fda4dec4b820e000043720 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 13:08:52 -0500 Subject: [PATCH 21/24] refactor: focus adoption panel on product telemetry metrics --- components/AdoptionSignals.js | 44 +++++---------------------- components/AdoptionSignals.module.css | 10 ++++-- 2 files changed, 16 insertions(+), 38 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index ed0cfd0..cbe934f 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -1,15 +1,13 @@ import React, { useEffect, useMemo, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { - faCodeBranch, - faStar, faChartLine, faComputerMouse, faWindowRestore, } from '@fortawesome/free-solid-svg-icons' import styles from './AdoptionSignals.module.css' -const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v3' +const METRICS_CACHE_KEY = 'openadapt:adoption-signals:v4' const METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000 const FETCH_TIMEOUT_MS = 10000 @@ -30,14 +28,6 @@ function hasUsableUsageMetrics(payload) { return candidates.some((value) => typeof value === 'number') } -function hasUsableGithubMetrics(payload) { - return typeof payload?.github?.stars === 'number' && typeof payload?.github?.forks === 'number' -} - -function hasUsableCachedSnapshot(payload) { - return hasUsableUsageMetrics(payload) && hasUsableGithubMetrics(payload) -} - function formatMetric(value) { if (value === null || value === undefined || Number.isNaN(value)) return '—' if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M` @@ -128,7 +118,6 @@ export default function AdoptionSignals({ timeRange = 'all' }) { const [showStaleNotice, setShowStaleNotice] = useState(false) const [error, setError] = useState(null) const [data, setData] = useState(null) - const [lastGoodGithub, setLastGoodGithub] = useState(null) useEffect(() => { let cancelled = false @@ -142,10 +131,9 @@ export default function AdoptionSignals({ timeRange = 'all' }) { if (!parsed?.payload || !parsed?.savedAt) return false const ageMs = Date.now() - parsed.savedAt if (ageMs > METRICS_CACHE_TTL_MS) return false - if (!hasUsableCachedSnapshot(parsed.payload)) return false + if (!hasUsableUsageMetrics(parsed.payload)) return false if (!cancelled) { setData(parsed.payload) - setLastGoodGithub(parsed.payload.github) setShowStaleNotice(true) setLoading(false) } @@ -171,12 +159,9 @@ export default function AdoptionSignals({ timeRange = 'all' }) { } const payload = await response.json() if (!cancelled) { - if (hasUsableGithubMetrics(payload)) { - setLastGoodGithub(payload.github) - } setData(payload) setShowStaleNotice(false) - if (typeof window !== 'undefined' && hasUsableCachedSnapshot(payload)) { + if (typeof window !== 'undefined' && hasUsableUsageMetrics(payload)) { window.localStorage.setItem( METRICS_CACHE_KEY, JSON.stringify({ payload, savedAt: Date.now() }) @@ -208,7 +193,6 @@ export default function AdoptionSignals({ timeRange = 'all' }) { const usageAvailable = Boolean(data?.usage?.available) const usageSource = String(data?.usage?.source || '') - const githubStats = hasUsableGithubMetrics(data) ? data.github : lastGoodGithub const isAllTime = timeRange === 'all' const runsPrimary = isAllTime ? data?.usage?.agentRunsAllTime @@ -267,9 +251,9 @@ export default function AdoptionSignals({ timeRange = 'all' }) { return (
-

Adoption Signals

+

Product Telemetry

- Track OpenAdapt's all-time footprint and 90-day momentum. + Privacy-preserving usage telemetry from OpenAdapt clients, shown as all-time and 90-day totals.

{telemetryWindowLabel &&
{telemetryWindowLabel}
}
@@ -278,32 +262,20 @@ export default function AdoptionSignals({ timeRange = 'all' }) { <>
- Loading adoption metrics... + Loading telemetry metrics...
- {Array.from({ length: 5 }).map((_, index) => ( + {Array.from({ length: 3 }).map((_, index) => ( ))}
)} - {error && !data &&
Unable to load adoption metrics: {error}
} + {error && !data &&
Unable to load telemetry metrics: {error}
} {!showSkeleton && data && ( <>
- - Date: Fri, 6 Mar 2026 13:14:06 -0500 Subject: [PATCH 22/24] tweak: consolidate early-data notice and keep telemetry cards on one row --- components/AdoptionSignals.js | 12 +++++------ components/AdoptionSignals.module.css | 30 +++++++++------------------ 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index cbe934f..7bd3cab 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -80,7 +80,6 @@ function MetricCard({ secondaryValue, secondaryLabel, showSecondary = true, - chipLabel = null, }) { return (
@@ -94,7 +93,6 @@ function MetricCard({ {formatMetric(secondaryValue)} {secondaryLabel}
)} - {chipLabel &&
{chipLabel}
}
) } @@ -244,7 +242,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { }, [data, usageSource]) const telemetryCardSuffix = coverageShortLabel ? ` (since ${coverageShortLabel})` : '' const coverageAgeDays = getDaysSince(data?.usage?.telemetryCoverageStartDate) - const showEarlyDataChip = coverageAgeDays !== null && coverageAgeDays < 30 + const showEarlyDataNotice = coverageAgeDays !== null && coverageAgeDays < 30 const showSkeleton = loading && !data @@ -256,6 +254,11 @@ export default function AdoptionSignals({ timeRange = 'all' }) { 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 && ( @@ -284,7 +287,6 @@ export default function AdoptionSignals({ timeRange = 'all' }) { secondaryValue={runsSecondary} secondaryLabel={secondaryLabel} showSecondary={runsShowSecondary} - chipLabel={showEarlyDataChip ? 'Early data' : null} />

diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css index ea3ba0a..1a5ac71 100644 --- a/components/AdoptionSignals.module.css +++ b/components/AdoptionSignals.module.css @@ -40,6 +40,14 @@ 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(3, minmax(180px, 1fr)); @@ -90,20 +98,6 @@ letter-spacing: 0.3px; } -.metricChip { - margin-top: 6px; - display: inline-block; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid rgba(251, 191, 36, 0.45); - background: rgba(251, 191, 36, 0.15); - color: #fde68a; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.3px; - text-transform: uppercase; -} - .message { margin-top: 12px; text-align: center; @@ -201,7 +195,7 @@ @media (max-width: 1024px) { .metricsGrid { - grid-template-columns: repeat(2, minmax(160px, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } } @@ -210,16 +204,12 @@ padding: 16px; } - .metricsGrid { - grid-template-columns: repeat(2, minmax(140px, 1fr)); - } - .title { font-size: 18px; } } -@media (max-width: 460px) { +@media (max-width: 760px) { .metricsGrid { grid-template-columns: 1fr; } From 83c18c0bc600e1c76ce6b2c6e56c50a51d4b009d Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 23:01:39 -0500 Subject: [PATCH 23/24] feat(metrics): add total-events card and transparent telemetry breakdown gate --- METRICS_SIGNAL_GUIDE.md | 12 ++- components/AdoptionSignals.js | 103 ++++++++++++++++++------ components/AdoptionSignals.module.css | 4 +- pages/api/project-metrics.js | 109 +++++++++++++++++++++++--- 4 files changed, 188 insertions(+), 40 deletions(-) diff --git a/METRICS_SIGNAL_GUIDE.md b/METRICS_SIGNAL_GUIDE.md index 1928b33..e915ebd 100644 --- a/METRICS_SIGNAL_GUIDE.md +++ b/METRICS_SIGNAL_GUIDE.md @@ -14,7 +14,7 @@ PyPI remains useful, but should not be the lead signal by itself. Returns: - `github`: stars/forks/watchers/issues -- `usage`: demos/runs/actions (30d), source metadata, caveats +- `usage`: total events + demos/runs/actions (30d/90d/all-time when available), source metadata, caveats - `warnings`: non-fatal fetch warnings ## Usage metric sources @@ -31,8 +31,18 @@ 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` diff --git a/components/AdoptionSignals.js b/components/AdoptionSignals.js index 7bd3cab..4d393d7 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { + faBolt, faChartLine, faComputerMouse, faWindowRestore, @@ -10,6 +11,8 @@ 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 @@ -24,6 +27,9 @@ function hasUsableUsageMetrics(payload) { usage.agentRunsAllTime, usage.guiActionsAllTime, usage.demosRecordedAllTime, + usage.totalEvents30d, + usage.totalEvents90d, + usage.totalEventsAllTime, ] return candidates.some((value) => typeof value === 'number') } @@ -201,6 +207,9 @@ export default function AdoptionSignals({ timeRange = 'all' }) { 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 @@ -210,10 +219,14 @@ export default function AdoptionSignals({ timeRange = 'all' }) { 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' @@ -243,6 +256,34 @@ export default function AdoptionSignals({ timeRange = 'all' }) { 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 @@ -268,7 +309,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { Loading telemetry metrics...
- {Array.from({ length: 3 }).map((_, index) => ( + {Array.from({ length: 4 }).map((_, index) => ( ))}
@@ -281,34 +322,48 @@ export default function AdoptionSignals({ timeRange = 'all' }) {
- - + {showBreakdownCards && ( + <> + + + + + )}
{usageStatusMessage &&
{usageStatusMessage}
} + {breakdownGateMessage &&
{breakdownGateMessage}
} {sourceLabel &&
{sourceLabel}
} {refreshing &&
Refreshing latest metrics...
} diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css index 1a5ac71..7211a7a 100644 --- a/components/AdoptionSignals.module.css +++ b/components/AdoptionSignals.module.css @@ -50,7 +50,7 @@ .metricsGrid { display: grid; - grid-template-columns: repeat(3, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; align-items: stretch; } @@ -195,7 +195,7 @@ @media (max-width: 1024px) { .metricsGrid { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } } diff --git a/pages/api/project-metrics.js b/pages/api/project-metrics.js index 8da9af1..7209276 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -12,11 +12,6 @@ const DEFAULT_POSTHOG_PROJECT_ID = '68185' const MAX_EVENT_DEFINITION_PAGES = 5 const FALLBACK_PATTERN_LIMIT = 30 const GITHUB_ORG = 'OpenAdaptAI' -const CANONICAL_USAGE_EVENTS = { - demos: ['demo_recorded'], - runs: ['agent_run'], - actions: ['action_executed'], -} // Canonical event names from OpenAdapt codebases (legacy PostHog + shared telemetry conventions). const EVENT_CLASSIFICATION = { @@ -251,6 +246,54 @@ async function runHogQLCount({ host, projectId, apiKey, eventNames }) { } } +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', @@ -289,15 +332,16 @@ async function runHogQLFirstSeen({ host, projectId, apiKey, eventNames }) { } async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { - const demosNames = uniqueNames(CANONICAL_USAGE_EVENTS.demos) - const runsNames = uniqueNames(CANONICAL_USAGE_EVENTS.runs) - const actionsNames = uniqueNames(CANONICAL_USAGE_EVENTS.actions) + 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, telemetryCoverageStartDate] = await Promise.all([ + 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 }), ]) @@ -313,6 +357,9 @@ async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { demosRecordedAllTime: demos.valueAllTime, agentRunsAllTime: runs.valueAllTime, guiActionsAllTime: actions.valueAllTime, + totalEvents30d: totals.value30d, + totalEvents90d: totals.value90d, + totalEventsAllTime: totals.valueAllTime, telemetryCoverageStartDate, hasAnyVolume: demos.value30d > 0 || @@ -320,8 +367,10 @@ async function fetchPosthogQueryUsageMetrics({ host, projectId, apiKey }) { actions.value30d > 0 || demos.value90d > 0 || runs.value90d > 0 || - actions.value90d > 0, - caveats: ['Derived from PostHog query API (strict canonical events only)'], + actions.value90d > 0 || + totals.value30d > 0 || + totals.value90d > 0, + caveats: ['Derived from PostHog query API (exact event-name classification + non-ignored totals)'], } } @@ -502,6 +551,9 @@ async function fetchPosthogUsageFromEventDefinitions(config) { demosRecordedAllTime: null, agentRunsAllTime: null, guiActionsAllTime: null, + totalEvents30d: null, + totalEvents90d: null, + totalEventsAllTime: null, telemetryCoverageStartDate: null, hasAnyVolume: false, matchedEvents: { @@ -525,7 +577,12 @@ async function fetchPosthogUsageFromEventDefinitions(config) { const demos = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.demos) const runs = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.runs) const actions = buildCategoryMetrics(entries, EVENT_CLASSIFICATION.actions) - const hasAnyVolume = demos.total > 0 || runs.total > 0 || actions.total > 0 + 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 @@ -541,6 +598,9 @@ async function fetchPosthogUsageFromEventDefinitions(config) { demosRecordedAllTime: null, agentRunsAllTime: null, guiActionsAllTime: null, + totalEvents30d, + totalEvents90d: null, + totalEventsAllTime: null, telemetryCoverageStartDate: null, hasAnyVolume, matchedEvents: { @@ -571,10 +631,18 @@ function getEnvOverrideUsageMetrics() { 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, apps] + const hasAny = [ + demos, demos90d, runs, runs90d, actions, actions90d, + demosAllTime, runsAllTime, actionsAllTime, + totalEvents30d, totalEvents90d, totalEventsAllTime, + apps, + ] .some((value) => value !== null) return { available: hasAny, @@ -588,6 +656,9 @@ function getEnvOverrideUsageMetrics() { demosRecordedAllTime: demosAllTime, agentRunsAllTime: runsAllTime, guiActionsAllTime: actionsAllTime, + totalEvents30d, + totalEvents90d: totalEvents90d ?? totalEvents30d, + totalEventsAllTime, appsAutomated: apps, telemetryCoverageStartDate, caveats: hasAny @@ -616,6 +687,12 @@ function mergeUsageMetrics(primary, fallback) { 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: @@ -633,6 +710,9 @@ function mergeUsageMetrics(primary, fallback) { merged.demosRecordedAllTime, merged.agentRunsAllTime, merged.guiActionsAllTime, + merged.totalEvents30d, + merged.totalEvents90d, + merged.totalEventsAllTime, merged.appsAutomated, ].some((value) => typeof value === 'number'), source: @@ -703,6 +783,9 @@ export default async function handler(req, res) { demosRecordedAllTime: null, agentRunsAllTime: null, guiActionsAllTime: null, + totalEvents30d: null, + totalEvents90d: null, + totalEventsAllTime: null, appsAutomated: null, telemetryCoverageStartDate: null, }, From 4397d772052f624148b120440ecb4f12fa16a3e5 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 6 Mar 2026 23:08:31 -0500 Subject: [PATCH 24/24] Add telemetry transparency panel and metadata contract --- TELEMETRY_TRANSPARENCY_DESIGN.md | 113 ++++++++++++ components/AdoptionSignals.js | 241 ++++++++++++++++++++++++++ components/AdoptionSignals.module.css | 119 +++++++++++++ pages/api/project-metrics.js | 90 +++++++++- 4 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 TELEMETRY_TRANSPARENCY_DESIGN.md 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 index 4d393d7..3b17724 100644 --- a/components/AdoptionSignals.js +++ b/components/AdoptionSignals.js @@ -116,6 +116,245 @@ function MetricSkeletonCard() { ) } +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) @@ -286,6 +525,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { }, [usageAvailable, showBreakdownCards, breakdownGateEvents, coverageAgeDays]) const showSkeleton = loading && !data + const transparency = data?.usage?.transparency || null return (
@@ -377,6 +617,7 @@ export default function AdoptionSignals({ timeRange = 'all' }) { Live refresh failed: {error}
)} + )} diff --git a/components/AdoptionSignals.module.css b/components/AdoptionSignals.module.css index 7211a7a..29bcb56 100644 --- a/components/AdoptionSignals.module.css +++ b/components/AdoptionSignals.module.css @@ -214,3 +214,122 @@ 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/pages/api/project-metrics.js b/pages/api/project-metrics.js index 7209276..2dd30cf 100644 --- a/pages/api/project-metrics.js +++ b/pages/api/project-metrics.js @@ -12,6 +12,7 @@ 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 = { @@ -79,6 +80,55 @@ const IGNORED_EVENT_PATTERNS = [ /(?:^|[._:-])(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 @@ -92,6 +142,43 @@ function formatError(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) @@ -613,7 +700,7 @@ async function fetchPosthogUsageFromEventDefinitions(config) { runs: runs.strategy, actions: actions.strategy, }, - classificationVersion: '2026-03-05', + 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', @@ -722,6 +809,7 @@ function mergeUsageMetrics(primary, fallback) { caveats: [...(primary.caveats || []), ...(fallback.caveats || [])], matchedEvents: primary.matchedEvents || null, strategies: primary.strategies || null, + transparency: buildTransparencyMetadata(), ...merged, } }