Skip to content

Commit b97028f

Browse files
committed
Analytics dashboard: Agent-readable report
- Add `/api/report` endpoint that returns a plain-text report with all dashboard metrics, breakdowns, and cross-tabulations - Extract shared data-fetching into `fetch-all.ts`, used by both the page and the report API - Simplify `+page.server.ts` to a thin wrapper around `fetchDashboardData()`
1 parent 016ee3a commit b97028f

File tree

4 files changed

+431
-107
lines changed

4 files changed

+431
-107
lines changed

apps/analytics-dashboard/CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ Deployed to Cloudflare Pages at `analdash.getcmdr.com`. Auth via Cloudflare Acce
1717
| `src/app.css` | Tailwind v4 theme (dark palette matching getcmdr.com) |
1818
| `src/app.d.ts` | Platform env type declarations for CF Pages |
1919
| `src/routes/+page.svelte` | Single-page dashboard with 6 acquisition stage sections |
20-
| `src/routes/+page.server.ts` | Server load: reads `?range=` param, calls all 6 sources in parallel |
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 |
2123
| `src/lib/components/Chart.svelte` | Reusable uPlot chart with ResizeObserver and dark theme |
2224
| `src/lib/server/types.ts` | Shared types: `TimeRange`, `SourceResult`, time window helpers |
2325
| `src/lib/server/cache.ts` | CF Cache API wrapper with in-memory Map fallback for local dev |
@@ -39,7 +41,7 @@ Each source gets its own module under `src/lib/server/sources/`:
3941
| Module | Auth | Data |
4042
| --- | --- | --- |
4143
| `umami.ts` | JWT (username/password login) | Page views, visitors, referrers, countries, download events for blog + getcmdr.com |
42-
| `cloudflare.ts` | Bearer token | Analytics Engine SQL: download counts, update check counts by version/arch/country |
44+
| `cloudflare.ts` | Bearer token | Analytics Engine SQL: download counts, update check counts by version/arch/country. Note: `cmdr_crash_reports` dataset is also available for crash data (not yet integrated). |
4345
| `paddle.ts` | Bearer token, cursor pagination | Completed transactions, subscriptions by status |
4446
| `github.ts` | Optional Bearer token | Release download counts per asset |
4547
| `posthog.ts` | Bearer personal API key | Pageview trends via Trends API (EU endpoint) |
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { TimeRange, SourceResult } from './types.js'
2+
import type { UmamiData } from './sources/umami.js'
3+
import type { CloudflareData } from './sources/cloudflare.js'
4+
import type { PaddleData } from './sources/paddle.js'
5+
import type { GitHubData } from './sources/github.js'
6+
import type { PostHogData } from './sources/posthog.js'
7+
import type { LicenseData } from './sources/license.js'
8+
import { fetchUmamiData } from './sources/umami.js'
9+
import { fetchCloudflareData } from './sources/cloudflare.js'
10+
import { fetchPaddleData } from './sources/paddle.js'
11+
import { fetchGitHubData } from './sources/github.js'
12+
import { fetchPostHogData } from './sources/posthog.js'
13+
import { fetchLicenseData } from './sources/license.js'
14+
15+
export interface DashboardData {
16+
range: TimeRange
17+
updatedAt: string
18+
umami: SourceResult<UmamiData>
19+
cloudflare: SourceResult<CloudflareData>
20+
paddle: SourceResult<PaddleData>
21+
github: SourceResult<GitHubData>
22+
posthog: SourceResult<PostHogData>
23+
license: SourceResult<LicenseData>
24+
}
25+
26+
function missingEnv(name: string): SourceResult<never> {
27+
return { ok: false, error: `${name}: not configured (missing env vars)` }
28+
}
29+
30+
/** Returns the env object from CF Pages platform, falling back to $env/dynamic/private for local dev. */
31+
async function resolveEnv(platform: App.Platform | undefined): Promise<App.Platform['env']> {
32+
if (platform?.env) return platform.env
33+
const { env } = await import('$env/dynamic/private')
34+
return {
35+
UMAMI_API_URL: env.UMAMI_API_URL ?? '',
36+
UMAMI_USERNAME: env.UMAMI_USERNAME ?? '',
37+
UMAMI_PASSWORD: env.UMAMI_PASSWORD ?? '',
38+
UMAMI_WEBSITE_ID: env.UMAMI_WEBSITE_ID ?? '',
39+
UMAMI_BLOG_WEBSITE_ID: env.UMAMI_BLOG_WEBSITE_ID ?? '',
40+
CLOUDFLARE_API_TOKEN: env.CLOUDFLARE_API_TOKEN ?? '',
41+
CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID ?? '',
42+
PADDLE_API_KEY_LIVE: env.PADDLE_API_KEY_LIVE ?? '',
43+
POSTHOG_API_KEY: env.POSTHOG_API_KEY ?? '',
44+
POSTHOG_PROJECT_ID: env.POSTHOG_PROJECT_ID ?? '',
45+
POSTHOG_API_URL: env.POSTHOG_API_URL ?? '',
46+
GITHUB_TOKEN: env.GITHUB_TOKEN || undefined,
47+
LICENSE_SERVER_ADMIN_TOKEN: env.LICENSE_SERVER_ADMIN_TOKEN ?? '',
48+
}
49+
}
50+
51+
const validRanges = new Set<TimeRange>(['24h', '7d', '30d'])
52+
53+
/** Fetches all dashboard data sources in parallel. Used by both the page and the report API. */
54+
export async function fetchDashboardData(
55+
platform: App.Platform | undefined,
56+
rangeParam: string
57+
): Promise<DashboardData> {
58+
const range: TimeRange = validRanges.has(rangeParam as TimeRange) ? (rangeParam as TimeRange) : '7d'
59+
const env = await resolveEnv(platform)
60+
61+
const [umami, cloudflare, paddle, github, posthog, license] = await Promise.all([
62+
env?.UMAMI_API_URL
63+
? fetchUmamiData(
64+
{
65+
UMAMI_API_URL: env.UMAMI_API_URL,
66+
UMAMI_USERNAME: env.UMAMI_USERNAME,
67+
UMAMI_PASSWORD: env.UMAMI_PASSWORD,
68+
UMAMI_WEBSITE_ID: env.UMAMI_WEBSITE_ID,
69+
UMAMI_BLOG_WEBSITE_ID: env.UMAMI_BLOG_WEBSITE_ID,
70+
},
71+
range
72+
)
73+
: Promise.resolve(missingEnv('Umami')),
74+
env?.CLOUDFLARE_API_TOKEN
75+
? fetchCloudflareData(
76+
{ CLOUDFLARE_API_TOKEN: env.CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID },
77+
range
78+
)
79+
: Promise.resolve(missingEnv('Cloudflare')),
80+
env?.PADDLE_API_KEY_LIVE
81+
? fetchPaddleData({ PADDLE_API_KEY_LIVE: env.PADDLE_API_KEY_LIVE }, range)
82+
: Promise.resolve(missingEnv('Paddle')),
83+
fetchGitHubData({ GITHUB_TOKEN: env?.GITHUB_TOKEN }),
84+
env?.POSTHOG_API_KEY
85+
? fetchPostHogData(
86+
{
87+
POSTHOG_API_KEY: env.POSTHOG_API_KEY,
88+
POSTHOG_PROJECT_ID: env.POSTHOG_PROJECT_ID,
89+
POSTHOG_API_URL: env.POSTHOG_API_URL,
90+
},
91+
range
92+
)
93+
: Promise.resolve(missingEnv('PostHog')),
94+
env?.LICENSE_SERVER_ADMIN_TOKEN
95+
? fetchLicenseData({ LICENSE_SERVER_ADMIN_TOKEN: env.LICENSE_SERVER_ADMIN_TOKEN })
96+
: Promise.resolve(missingEnv('License server')),
97+
])
98+
99+
return { range, updatedAt: new Date().toISOString(), umami, cloudflare, paddle, github, posthog, license }
100+
}
Lines changed: 3 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,8 @@
11
import type { PageServerLoad } from './$types'
2-
import type { TimeRange, SourceResult } from '$lib/server/types.js'
3-
import type { UmamiData } from '$lib/server/sources/umami.js'
4-
import type { CloudflareData } from '$lib/server/sources/cloudflare.js'
5-
import type { PaddleData } from '$lib/server/sources/paddle.js'
6-
import type { GitHubData } from '$lib/server/sources/github.js'
7-
import type { PostHogData } from '$lib/server/sources/posthog.js'
8-
import type { LicenseData } from '$lib/server/sources/license.js'
9-
import { fetchUmamiData } from '$lib/server/sources/umami.js'
10-
import { fetchCloudflareData } from '$lib/server/sources/cloudflare.js'
11-
import { fetchPaddleData } from '$lib/server/sources/paddle.js'
12-
import { fetchGitHubData } from '$lib/server/sources/github.js'
13-
import { fetchPostHogData } from '$lib/server/sources/posthog.js'
14-
import { fetchLicenseData } from '$lib/server/sources/license.js'
2+
import { fetchDashboardData } from '$lib/server/fetch-all.js'
153

16-
const validRanges = new Set<TimeRange>(['24h', '7d', '30d'])
17-
18-
export interface DashboardData {
19-
range: TimeRange
20-
updatedAt: string
21-
umami: SourceResult<UmamiData>
22-
cloudflare: SourceResult<CloudflareData>
23-
paddle: SourceResult<PaddleData>
24-
github: SourceResult<GitHubData>
25-
posthog: SourceResult<PostHogData>
26-
license: SourceResult<LicenseData>
27-
}
28-
29-
function missingEnv(name: string): SourceResult<never> {
30-
return { ok: false, error: `${name}: not configured (missing env vars)` }
31-
}
32-
33-
/** Returns the env object from CF Pages platform, falling back to $env/dynamic/private for local dev. */
34-
async function resolveEnv(platform: App.Platform | undefined): Promise<App.Platform['env']> {
35-
if (platform?.env) return platform.env
36-
// In local dev (vite dev), platform.env is undefined. Use SvelteKit's $env/dynamic/private
37-
// which properly loads .env files (handling quoting, escaping, etc.).
38-
const { env } = await import('$env/dynamic/private')
39-
return {
40-
UMAMI_API_URL: env.UMAMI_API_URL ?? '',
41-
UMAMI_USERNAME: env.UMAMI_USERNAME ?? '',
42-
UMAMI_PASSWORD: env.UMAMI_PASSWORD ?? '',
43-
UMAMI_WEBSITE_ID: env.UMAMI_WEBSITE_ID ?? '',
44-
UMAMI_BLOG_WEBSITE_ID: env.UMAMI_BLOG_WEBSITE_ID ?? '',
45-
CLOUDFLARE_API_TOKEN: env.CLOUDFLARE_API_TOKEN ?? '',
46-
CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID ?? '',
47-
PADDLE_API_KEY_LIVE: env.PADDLE_API_KEY_LIVE ?? '',
48-
POSTHOG_API_KEY: env.POSTHOG_API_KEY ?? '',
49-
POSTHOG_PROJECT_ID: env.POSTHOG_PROJECT_ID ?? '',
50-
POSTHOG_API_URL: env.POSTHOG_API_URL ?? '',
51-
GITHUB_TOKEN: env.GITHUB_TOKEN || undefined,
52-
LICENSE_SERVER_ADMIN_TOKEN: env.LICENSE_SERVER_ADMIN_TOKEN ?? '',
53-
}
54-
}
4+
export type { DashboardData } from '$lib/server/fetch-all.js'
555

566
export const load: PageServerLoad = async ({ url, platform }) => {
57-
const rangeParam = url.searchParams.get('range') ?? '7d'
58-
const range: TimeRange = validRanges.has(rangeParam as TimeRange) ? (rangeParam as TimeRange) : '7d'
59-
60-
const env = await resolveEnv(platform)
61-
62-
const [umami, cloudflare, paddle, github, posthog, license] = await Promise.all([
63-
env?.UMAMI_API_URL
64-
? fetchUmamiData(
65-
{
66-
UMAMI_API_URL: env.UMAMI_API_URL,
67-
UMAMI_USERNAME: env.UMAMI_USERNAME,
68-
UMAMI_PASSWORD: env.UMAMI_PASSWORD,
69-
UMAMI_WEBSITE_ID: env.UMAMI_WEBSITE_ID,
70-
UMAMI_BLOG_WEBSITE_ID: env.UMAMI_BLOG_WEBSITE_ID,
71-
},
72-
range
73-
)
74-
: Promise.resolve(missingEnv('Umami')),
75-
env?.CLOUDFLARE_API_TOKEN
76-
? fetchCloudflareData(
77-
{ CLOUDFLARE_API_TOKEN: env.CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID },
78-
range
79-
)
80-
: Promise.resolve(missingEnv('Cloudflare')),
81-
env?.PADDLE_API_KEY_LIVE
82-
? fetchPaddleData({ PADDLE_API_KEY_LIVE: env.PADDLE_API_KEY_LIVE }, range)
83-
: Promise.resolve(missingEnv('Paddle')),
84-
fetchGitHubData({ GITHUB_TOKEN: env?.GITHUB_TOKEN }),
85-
env?.POSTHOG_API_KEY
86-
? fetchPostHogData(
87-
{
88-
POSTHOG_API_KEY: env.POSTHOG_API_KEY,
89-
POSTHOG_PROJECT_ID: env.POSTHOG_PROJECT_ID,
90-
POSTHOG_API_URL: env.POSTHOG_API_URL,
91-
},
92-
range
93-
)
94-
: Promise.resolve(missingEnv('PostHog')),
95-
env?.LICENSE_SERVER_ADMIN_TOKEN
96-
? fetchLicenseData({ LICENSE_SERVER_ADMIN_TOKEN: env.LICENSE_SERVER_ADMIN_TOKEN })
97-
: Promise.resolve(missingEnv('License server')),
98-
])
99-
100-
return {
101-
range,
102-
updatedAt: new Date().toISOString(),
103-
umami,
104-
cloudflare,
105-
paddle,
106-
github,
107-
posthog,
108-
license,
109-
} satisfies DashboardData
7+
return fetchDashboardData(platform, url.searchParams.get('range') ?? '7d')
1108
}

0 commit comments

Comments
 (0)