Skip to content

Commit e449b00

Browse files
committed
Analytics dashboard: surface in-app feedback and error reports
Adds a "Feedback & errors" section to the private dashboard (and its agent-readable report) so user feedback and error-report trends are visible alongside the acquisition metrics, instead of living only in Discord channels a human has to skim. - Two new read-only worker admin endpoints, same `Bearer ADMIN_API_TOKEN` pattern as the others: `GET /admin/feedback` (D1 `feedback` rows, full text + reply-to email, newest first) and `GET /admin/error-reports` (per-bundle metadata from the `cmdr-error-reports` R2 prod prefix via `bucket.list` with `customMetadata`, windowed by the key's date segment). The worker already holds the `TELEMETRY_DB` and `ERROR_REPORTS_BUCKET` bindings, so this needs no new token or service. Discord can't serve this: those channels are private and denied to the community bot. - New dashboard source `feedback-and-errors.ts` plus a client-safe domain module `$lib/feedback-and-errors.ts` (row types + pure aggregation helpers) shared by the page, the report endpoint, and tests. The page can't import `$lib/server` at runtime, so the helpers live outside it. - The section shows feedback volume, an explicit "awaiting reply" count (messages with a reply-to email), recent messages with mailto reply links, and error-report clusters by kind/version with a per-day timeline. The `/api/report` plain-text export gets the same breakdowns for agents. - Extracted the shared `fetchWorkerEndpoint(token, path)` helper so `cloudflare.ts` and the new source don't duplicate it. - Bumped the api-server `@cloudflare/workers-types` entrypoint to the `2023-07-01` snapshot (was the bare package = 2021-11-03), which is where `R2ListOptions.include` and `R2Object.customMetadata` live and is closer to the worker's 2025-01-01 compatibility date. Whole api-server still typechecks clean. Tests: new coverage for both worker endpoints (auth, range, mapping, prod-prefix listing, date-window filter) and the dashboard source + aggregation helpers. api-server 220 pass, dashboard 65 pass, dashboard builds.
1 parent a2cbfce commit e449b00

13 files changed

Lines changed: 813 additions & 90 deletions

File tree

apps/analytics-dashboard/CLAUDE.md

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@ Deployed to Cloudflare Pages at `analdash.getcmdr.com`. Auth via Cloudflare Acce
1212

1313
## Key files
1414

15-
| File | Purpose |
16-
| ---------------------------------- | --------------------------------------------------------------- |
17-
| `src/app.css` | Tailwind v4 theme (dark palette matching getcmdr.com) |
18-
| `src/app.d.ts` | Platform env type declarations for CF Pages |
19-
| `src/routes/+page.svelte` | Single-page dashboard with 6 acquisition stage sections |
20-
| `src/routes/+page.server.ts` | Server load: reads `?range=` param, delegates to `fetch-all.ts` |
21-
| `src/routes/api/report/+server.ts` | Agent-readable plain-text report with all breakdowns |
22-
| `src/lib/server/fetch-all.ts` | Shared data-fetching logic used by both the page and report API |
23-
| `src/lib/components/Chart.svelte` | Reusable uPlot chart with ResizeObserver and dark theme |
24-
| `src/lib/server/types.ts` | Shared types: `TimeRange`, `SourceResult`, time window helpers |
25-
| `src/lib/server/cache.ts` | CF Cache API wrapper with in-memory Map fallback for local dev |
26-
| `src/lib/server/sources/` | Data source modules (one per external API) |
27-
| `svelte.config.js` | Adapter-cloudflare config |
28-
| `vitest.config.ts` | Vitest config for unit tests |
15+
| File | Purpose |
16+
| ---------------------------------- | ------------------------------------------------------------------ |
17+
| `src/app.css` | Tailwind v4 theme (dark palette matching getcmdr.com) |
18+
| `src/app.d.ts` | Platform env type declarations for CF Pages |
19+
| `src/routes/+page.svelte` | Single-page dashboard: 6 acquisition stages plus feedback & errors |
20+
| `src/routes/+page.server.ts` | Server load: reads `?range=` param, delegates to `fetch-all.ts` |
21+
| `src/routes/api/report/+server.ts` | Agent-readable plain-text report with all breakdowns |
22+
| `src/lib/server/fetch-all.ts` | Shared data-fetching logic used by both the page and report API |
23+
| `src/lib/components/Chart.svelte` | Reusable uPlot chart with ResizeObserver and dark theme |
24+
| `src/lib/server/types.ts` | Shared types: `TimeRange`, `SourceResult`, time window helpers |
25+
| `src/lib/server/cache.ts` | CF Cache API wrapper with in-memory Map fallback for local dev |
26+
| `src/lib/server/sources/` | Data source modules (one per external API) |
27+
| `svelte.config.js` | Adapter-cloudflare config |
28+
| `vitest.config.ts` | Vitest config for unit tests |
2929

3030
## Running locally
3131

@@ -38,14 +38,16 @@ Deployed to Cloudflare Pages at `analdash.getcmdr.com`. Auth via Cloudflare Acce
3838

3939
Each source gets its own module under `src/lib/server/sources/`:
4040

41-
| Module | Auth | Data |
42-
| --------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43-
| `umami.ts` | JWT (username/password login) | Page views, visitors, referrers, countries, download events for veszelovszki.com, getcmdr.com, and getprvw.com |
44-
| `cloudflare.ts` | Bearer token (via `LICENSE_SERVER_ADMIN_TOKEN`) | Download counts (by version/arch/country) and true per-day DAU + beats from the heartbeat, fetched from worker endpoints (`/admin/downloads`, `/admin/heartbeat-dau`) |
45-
| `paddle.ts` | Bearer token, cursor pagination | Completed transactions, subscriptions by status |
46-
| `github.ts` | Optional Bearer token | Release download counts per asset; star history (daily + cumulative) for cmdr and mtp-rs via stargazers API with pagination |
47-
| `posthog.ts` | Bearer personal API key | Pageview trends via Trends API (EU endpoint) |
48-
| `license.ts` | Bearer admin token | Activation count + active devices from `/admin/stats` |
41+
| Module | Auth | Data |
42+
| ------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43+
| `umami.ts` | JWT (username/password login) | Page views, visitors, referrers, countries, download events for veszelovszki.com, getcmdr.com, and getprvw.com |
44+
| `cloudflare.ts` | Bearer token (via `LICENSE_SERVER_ADMIN_TOKEN`) | Download counts (by version/arch/country) and true per-day DAU + beats from the heartbeat, fetched from worker endpoints (`/admin/downloads`, `/admin/heartbeat-dau`) |
45+
| `paddle.ts` | Bearer token, cursor pagination | Completed transactions, subscriptions by status |
46+
| `github.ts` | Optional Bearer token | Release download counts per asset; star history (daily + cumulative) for cmdr and mtp-rs via stargazers API with pagination |
47+
| `posthog.ts` | Bearer personal API key | Pageview trends via Trends API (EU endpoint) |
48+
| `license.ts` | Bearer admin token | Activation count + active devices from `/admin/stats` |
49+
| `feedback-and-errors.ts` | Bearer token (via `LICENSE_SERVER_ADMIN_TOKEN`) | In-app feedback messages and error-report bundle metadata from worker endpoints (`/admin/feedback`, `/admin/error-reports`). Pure aggregation helpers + row types live in `$lib/feedback-and-errors.ts` (client-safe, shared with the page) |
50+
| `worker-endpoint.ts` | (shared helper) | `fetchWorkerEndpoint(token, path)`: GETs a worker admin endpoint with the bearer token, used by `cloudflare.ts` and `feedback-and-errors.ts` |
4951

5052
Each module exports a typed fetch function returning `SourceResult<T>` (ok + data, or error string). Results are cached
5153
via `cache.ts` (5 min TTL for 24h/7d, 1 hour for 30d). The page server calls all sources in parallel, each capped at 20s
@@ -102,7 +104,19 @@ aggregate numbers. A true funnel would require cross-site user identity tracking
102104

103105
These colors are used in metric dots, chart strokes, and chart fills. Keep them consistent when adding new UI.
104106

105-
**Decision**: Single page, not multi-page. **Why**: Only six sections. Scroll is simpler than navigation.
107+
**Decision**: Single page, not multi-page. **Why**: A handful of sections. Scroll is simpler than navigation.
108+
109+
**Decision**: A "Feedback & errors" section reads the app's own stores via two worker admin endpoints (`/admin/feedback`
110+
from D1, `/admin/error-reports` from the R2 bucket's `list` with `customMetadata`), not Discord. **Why**: the
111+
`#feedback` and `#error-reports` Discord channels are private and denied to the community bot, and the worker already
112+
holds the `TELEMETRY_DB` and `ERROR_REPORTS_BUCKET` bindings, so it can serve this with no extra token or service. The
113+
agent-facing local digest (`/feedback-and-error-digest-from-app`) reads the same stores directly; see
114+
`docs/tooling/feedback-and-error-digest.md`.
115+
116+
**Decision**: The feedback/error-report row types and pure aggregation helpers live in `$lib/feedback-and-errors.ts`,
117+
outside `$lib/server`. **Why**: `+page.svelte` reaches the client bundle, and SvelteKit forbids runtime imports from
118+
`$lib/server` there. Keeping the helpers client-safe lets the page, the report endpoint, and tests share one copy; the
119+
server-only fetching stays in `$lib/server/sources/feedback-and-errors.ts`.
106120

107121
**Decision**: uPlot for charts. **Why**: ~45 KB, fast canvas rendering, simple API. No wrapper needed.
108122

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Domain types and pure aggregation helpers for in-app feedback and error reports. Lives outside
3+
* `$lib/server` so both the page (client bundle) and the report/server code can import the helpers;
4+
* the server-only fetching lives in `$lib/server/sources/feedback-and-errors.ts`.
5+
*/
6+
7+
/** One in-app "Send feedback" submission. `email` is the optional reply-to the sender attached. */
8+
export interface FeedbackRow {
9+
id: number
10+
createdAt: string
11+
feedback: string
12+
email: string | null
13+
appVersion: string
14+
osVersion: string
15+
buildMode: string | null
16+
}
17+
18+
/** One error-report bundle's metadata (the zip itself stays in R2). `kind` is "auto" or "user". */
19+
export interface ErrorReportRow {
20+
id: string
21+
kind: string
22+
appVersion: string
23+
osVersion: string
24+
arch: string
25+
date: string
26+
generatedAt: string
27+
}
28+
29+
/** Number of feedback messages that carry a reply-to email (people awaiting a response). */
30+
export function countFeedbackWithReplyTo(rows: FeedbackRow[]): number {
31+
return rows.filter((row) => row.email != null && row.email !== '').length
32+
}
33+
34+
/** Tallies error reports by a field (kind/version/arch), highest count first. */
35+
export function tallyErrorReportsByField(
36+
rows: ErrorReportRow[],
37+
field: 'kind' | 'appVersion' | 'arch',
38+
): Array<{ key: string; count: number }> {
39+
const counts = new Map<string, number>()
40+
for (const row of rows) {
41+
const key = row[field] || '(unknown)'
42+
counts.set(key, (counts.get(key) ?? 0) + 1)
43+
}
44+
return [...counts.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count)
45+
}
46+
47+
/** Error reports grouped by day, oldest first (for the timeline chart). */
48+
export function errorReportsByDay(rows: ErrorReportRow[]): Array<{ date: string; count: number }> {
49+
const counts = new Map<string, number>()
50+
for (const row of rows) {
51+
counts.set(row.date, (counts.get(row.date) ?? 0) + 1)
52+
}
53+
return [...counts.entries()].map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date))
54+
}

apps/analytics-dashboard/src/lib/server/fetch-all.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import type { PaddleData } from './sources/paddle.js'
55
import type { GitHubData, GitHubStarsData } from './sources/github.js'
66
import type { PostHogData } from './sources/posthog.js'
77
import type { LicenseData } from './sources/license.js'
8+
import type { FeedbackAndErrorsData } from './sources/feedback-and-errors.js'
89
import { fetchUmamiData } from './sources/umami.js'
910
import { fetchCloudflareData } from './sources/cloudflare.js'
1011
import { fetchPaddleData } from './sources/paddle.js'
1112
import { fetchGitHubData, fetchGitHubStarsData } from './sources/github.js'
1213
import { fetchPostHogData } from './sources/posthog.js'
1314
import { fetchLicenseData } from './sources/license.js'
15+
import { fetchFeedbackAndErrorsData } from './sources/feedback-and-errors.js'
1416

1517
export interface DashboardData {
1618
range: TimeRange
@@ -22,6 +24,7 @@ export interface DashboardData {
2224
githubStars: SourceResult<GitHubStarsData>
2325
posthog: SourceResult<PostHogData>
2426
license: SourceResult<LicenseData>
27+
feedbackAndErrors: SourceResult<FeedbackAndErrorsData>
2528
}
2629

2730
const sourceTimeoutMs = 20_000
@@ -85,7 +88,7 @@ export async function fetchDashboardData(
8588
const range: TimeRange = validRanges.has(rangeParam as TimeRange) ? (rangeParam as TimeRange) : '7d'
8689
const env = await resolveEnv(platform)
8790

88-
const [umami, cloudflare, paddle, github, githubStars, posthog, license] = await Promise.all([
91+
const [umami, cloudflare, paddle, github, githubStars, posthog, license, feedbackAndErrors] = await Promise.all([
8992
guardedFetch(env?.UMAMI_API_URL, 'Umami', () =>
9093
fetchUmamiData(
9194
{
@@ -120,6 +123,9 @@ export async function fetchDashboardData(
120123
guardedFetch(env?.LICENSE_SERVER_ADMIN_TOKEN, 'License server', () =>
121124
fetchLicenseData({ LICENSE_SERVER_ADMIN_TOKEN: env.LICENSE_SERVER_ADMIN_TOKEN }),
122125
),
126+
guardedFetch(env?.LICENSE_SERVER_ADMIN_TOKEN, 'Feedback & errors', () =>
127+
fetchFeedbackAndErrorsData({ LICENSE_SERVER_ADMIN_TOKEN: env.LICENSE_SERVER_ADMIN_TOKEN }, range),
128+
),
123129
])
124130

125131
return {
@@ -132,5 +138,6 @@ export async function fetchDashboardData(
132138
githubStars,
133139
posthog,
134140
license,
141+
feedbackAndErrors,
135142
}
136143
}

apps/analytics-dashboard/src/lib/server/sources/cloudflare.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TimeRange, SourceResult } from '../types.js'
22
import { cacheGet, cacheSet } from '../cache.js'
3+
import { fetchWorkerEndpoint } from './worker-endpoint.js'
34

45
export interface DownloadRow {
56
version: string
@@ -39,19 +40,6 @@ const heartbeatDauRangeMap: Record<TimeRange, string> = {
3940
'30d': '30d',
4041
}
4142

42-
const workerBaseUrl = 'https://api.getcmdr.com'
43-
44-
async function fetchWorkerEndpoint<T>(env: CloudflareEnv, path: string): Promise<T> {
45-
const response = await fetch(`${workerBaseUrl}${path}`, {
46-
headers: { Authorization: `Bearer ${env.LICENSE_SERVER_ADMIN_TOKEN}` },
47-
})
48-
if (!response.ok) {
49-
const text = await response.text()
50-
throw new Error(`Worker ${path} returned ${String(response.status)}: ${text}`)
51-
}
52-
return (await response.json()) as T
53-
}
54-
5543
interface WorkerDownloadRow {
5644
date: string
5745
version: string
@@ -83,8 +71,14 @@ export async function fetchCloudflareData(env: CloudflareEnv, range: TimeRange):
8371

8472
try {
8573
const [downloadsRaw, heartbeatDauRaw] = await Promise.all([
86-
fetchWorkerEndpoint<WorkerDownloadRow[]>(env, `/admin/downloads?range=${downloadRangeMap[range]}`),
87-
fetchWorkerEndpoint<HeartbeatDauRow[]>(env, `/admin/heartbeat-dau?range=${heartbeatDauRangeMap[range]}`),
74+
fetchWorkerEndpoint<WorkerDownloadRow[]>(
75+
env.LICENSE_SERVER_ADMIN_TOKEN,
76+
`/admin/downloads?range=${downloadRangeMap[range]}`,
77+
),
78+
fetchWorkerEndpoint<HeartbeatDauRow[]>(
79+
env.LICENSE_SERVER_ADMIN_TOKEN,
80+
`/admin/heartbeat-dau?range=${heartbeatDauRangeMap[range]}`,
81+
),
8882
])
8983

9084
const data: CloudflareData = {

0 commit comments

Comments
 (0)