Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/lib/website/analytics/analytics.remote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { query, getRequestEvent } from '$app/server';
import { env } from '$env/dynamic/private';
import type { Did } from '@atcute/lexicons';

const DATASET = 'blento_pageviews';

export type AnalyticsBreakdownEntry = { label: string; views: number };

export type AnalyticsSummary = {
/** Whether analytics are available (credentials configured + query succeeded). */
available: boolean;
day: number;
week: number;
month: number;
topPages: AnalyticsBreakdownEntry[];
topReferrers: AnalyticsBreakdownEntry[];
topCountries: AnalyticsBreakdownEntry[];
};

const EMPTY: AnalyticsSummary = {
available: false,
day: 0,
week: 0,
month: 0,
topPages: [],
topReferrers: [],
topCountries: []
};

// DIDs only ever contain these characters — reject anything else so we can
// safely interpolate the value into the SQL string.
const DID_PATTERN = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/;

type SqlRow = Record<string, string | number | null>;

async function runQuery(
accountId: string,
apiToken: string,
sql: string
): Promise<SqlRow[] | null> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'text/plain'
},
body: sql
}
);

if (!response.ok) {
console.error('Analytics Engine query failed', response.status, await response.text());
return null;
}

const result = (await response.json()) as { data?: SqlRow[] };
return result.data ?? [];
}

function num(value: string | number | null | undefined): number {
const n = typeof value === 'number' ? value : Number(value ?? 0);
return Number.isFinite(n) ? Math.round(n) : 0;
}

function toBreakdown(rows: SqlRow[] | null, labelKey: string): AnalyticsBreakdownEntry[] {
if (!rows) return [];
return rows
.map((row) => ({ label: String(row[labelKey] ?? ''), views: num(row.views) }))
.filter((entry) => entry.label !== '' && entry.views > 0);
}

/**
* Returns pageview stats for the currently logged-in user's own site.
*
* Page views are written to the `blento_pageviews` Analytics Engine dataset
* (see `src/lib/helpers/analytics.ts`), indexed by DID. We always scope the
* query to `locals.did`, so a user can only ever see their own numbers.
*/
export const getMyAnalytics = query(async (): Promise<AnalyticsSummary> => {
const { locals } = getRequestEvent();
const did = locals.did;
if (!did || !DID_PATTERN.test(did)) return EMPTY;

const accountId = env.CLOUDFLARE_ACCOUNT_ID;
const apiToken = env.CLOUDFLARE_API_TOKEN;
if (!accountId || !apiToken) return EMPTY;

const where = `index1 = '${did as Did}' AND timestamp >= NOW() - INTERVAL '30' DAY`;

const totalsSql = `
SELECT
SUM(IF(timestamp >= NOW() - INTERVAL '1' DAY, _sample_interval, 0)) AS day,
SUM(IF(timestamp >= NOW() - INTERVAL '7' DAY, _sample_interval, 0)) AS week,
SUM(_sample_interval) AS month
FROM ${DATASET}
WHERE ${where}`;

const breakdownSql = (column: string, extra = '') => `
SELECT ${column} AS label, SUM(_sample_interval) AS views
FROM ${DATASET}
WHERE ${where}${extra}
GROUP BY label
ORDER BY views DESC
LIMIT 10`;

try {
const [totals, pages, referrers, countries] = await Promise.all([
runQuery(accountId, apiToken, totalsSql),
runQuery(accountId, apiToken, breakdownSql('blob3')),
runQuery(accountId, apiToken, breakdownSql('blob5', " AND blob5 != ''")),
runQuery(accountId, apiToken, breakdownSql('blob4', " AND blob4 != ''"))
]);

// A hard failure (bad credentials / missing dataset) returns null for the
// totals query — surface that as "unavailable" rather than "0 views".
if (totals === null) return EMPTY;

const row = totals[0] ?? {};
return {
available: true,
day: num(row.day),
week: num(row.week),
month: num(row.month),
topPages: toBreakdown(pages, 'label'),
topReferrers: toBreakdown(referrers, 'label'),
topCountries: toBreakdown(countries, 'label')
};
} catch (err) {
console.error('getMyAnalytics failed', err);
return EMPTY;
}
});
4 changes: 4 additions & 0 deletions src/lib/website/settings/SettingsOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import LayoutSection from './sections/LayoutSection.svelte';
import CustomDomainSection from './sections/CustomDomainSection.svelte';
import AccountSection from './sections/AccountSection.svelte';
import AnalyticsSection from './sections/AnalyticsSection.svelte';

let { data = $bindable(), publicationUrl }: { data: WebsiteData; publicationUrl?: string } =
$props();
Expand All @@ -30,6 +31,7 @@
{ id: 'page', label: 'Page' },
{ id: 'layout', label: 'Layout' },
{ id: 'domain', label: 'Custom Domain' },
{ id: 'analytics', label: 'Analytics' },
{ id: 'account', label: 'Account' }
] as const;
</script>
Expand Down Expand Up @@ -84,6 +86,8 @@
<LayoutSection bind:data />
{:else if settingsOverlayState.activeSection === 'domain'}
<CustomDomainSection {publicationUrl} />
{:else if settingsOverlayState.activeSection === 'analytics'}
<AnalyticsSection />
{:else if settingsOverlayState.activeSection === 'account'}
<AccountSection />
{/if}
Expand Down
117 changes: 117 additions & 0 deletions src/lib/website/settings/sections/AnalyticsSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script lang="ts">
import { getMyAnalytics } from '$lib/website/analytics/analytics.remote';

const query = getMyAnalytics();

function fmt(n: number): string {
return n.toLocaleString();
}

// Breakdowns (top pages / referrers / countries) are commented out for now.
// import { type AnalyticsBreakdownEntry } from '$lib/website/analytics/analytics.remote';
//
// function pageLabel(label: string): string {
// return label === 'self' ? 'Home' : label;
// }
//
// function countryLabel(code: string): string {
// if (!/^[A-Za-z]{2}$/.test(code)) return code;
// const flag = String.fromCodePoint(
// ...code
// .toUpperCase()
// .split('')
// .map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)
// );
// return `${flag} ${code.toUpperCase()}`;
// }

const windows = [
{ key: 'day', label: 'Last 24 hours' },
{ key: 'week', label: 'Last 7 days' },
{ key: 'month', label: 'Last 30 days' }
] as const;
</script>

<h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Analytics</h3>
<p class="text-base-500 dark:text-base-400 mt-1 text-sm">
Page views across your whole site. Only you can see these numbers.
</p>

{#await query}
<div class="mt-6 grid grid-cols-3 gap-3">
{#each windows as w (w.key)}
<div
class="border-base-200 dark:border-base-800 bg-base-100/50 dark:bg-base-900/50 h-24 animate-pulse rounded-xl border"
></div>
{/each}
</div>
{:then data}
{#if !data.available}
<div
class="border-base-200 dark:border-base-800 text-base-500 dark:text-base-400 mt-6 rounded-xl border p-6 text-center text-sm"
>
Analytics aren't available right now. Check back soon.
</div>
{:else}
<div class="mt-6 grid grid-cols-3 gap-3">
{#each windows as w (w.key)}
<div class="border-base-200 dark:border-base-800 rounded-xl border p-4">
<div class="text-base-500 dark:text-base-400 text-xs font-medium">{w.label}</div>
<div class="text-base-900 dark:text-base-100 mt-1 text-2xl font-bold tabular-nums">
{fmt(data[w.key])}
</div>
<div class="text-base-400 dark:text-base-500 text-xs">views</div>
</div>
{/each}
</div>

{#if data.month === 0}
<p class="text-base-500 dark:text-base-400 mt-6 text-sm">
No visits in the last 30 days yet.
</p>
{/if}

<!--
Breakdowns (top pages / referrers / countries) are commented out for now.

{#snippet breakdown(
title: string,
entries: AnalyticsBreakdownEntry[],
format: (s: string) => string
)}
{#if entries.length > 0}
{@const max = entries[0].views}
<div class="mt-6">
<h4 class="text-base-700 dark:text-base-300 text-sm font-semibold">{title}</h4>
<ul class="mt-2 space-y-1">
{#each entries as entry (entry.label)}
<li class="relative flex items-center justify-between rounded-lg px-3 py-1.5 text-sm">
<div
class="bg-accent-500/10 dark:bg-accent-500/15 absolute inset-y-0 left-0 rounded-lg"
style="width: {max > 0 ? (entry.views / max) * 100 : 0}%"
></div>
<span class="text-base-700 dark:text-base-300 relative truncate pr-2">
{format(entry.label)}
</span>
<span class="text-base-500 dark:text-base-400 relative tabular-nums">
{fmt(entry.views)}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/snippet}

{@render breakdown('Top pages', data.topPages, pageLabel)}
{@render breakdown('Top referrers', data.topReferrers, (s) => s)}
{@render breakdown('Top countries', data.topCountries, countryLabel)}
-->
{/if}
{:catch}
<div
class="border-base-200 dark:border-base-800 text-base-500 dark:text-base-400 mt-6 rounded-xl border p-6 text-center text-sm"
>
Couldn't load analytics. Please try again later.
</div>
{/await}
Loading