feat: add PostHog telemetry with type-safe event tracking

Track page views, project interactions, theme changes, external links, PGP actions, and errors. Console logging in dev when PostHog not configured. Requires PUBLIC_POSTHOG_KEY and PUBLIC_POSTHOG_HOST env vars.
This commit is contained in:
2026-01-14 12:25:10 -06:00
parent 08c5dcda3b
commit d360f2284e
16 changed files with 475 additions and 7 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { HandleClientError } from "@sveltejs/kit";
import { telemetry } from "$lib/telemetry";
export const handleError: HandleClientError = ({ error, status, message }) => {
telemetry.trackError(
status >= 500 ? "runtime_error" : "network_error",
message,
{
stack: error instanceof Error ? error.stack : undefined,
context: { status },
},
);
return {
message: status === 404 ? "Not Found" : "An error occurred",
};
};
+22
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { telemetry } from "$lib/telemetry";
import TagChip from "./TagChip.svelte";
import type { AdminProject } from "$lib/admin-types";
@@ -21,6 +22,25 @@
const isLink = $derived(!!projectUrl);
// Determine click action type for telemetry
const clickAction = $derived(
project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null,
);
function handleClick() {
if (clickAction && projectUrl) {
telemetry.track({
name: "project_interaction",
properties: {
action: clickAction,
projectSlug: project.slug,
projectName: project.name,
targetUrl: projectUrl,
},
});
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
@@ -44,6 +64,8 @@
href={isLink ? projectUrl : undefined}
target={isLink ? "_blank" : undefined}
rel={isLink ? "noopener noreferrer" : undefined}
onclick={handleClick}
role={isLink ? undefined : "article"}
class={cn(
"flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3",
isLink &&
+11 -1
View File
@@ -1,12 +1,22 @@
<script lang="ts">
import { themeStore } from "$lib/stores/theme.svelte";
import { telemetry } from "$lib/telemetry";
import IconSun from "~icons/lucide/sun";
import IconMoon from "~icons/lucide/moon";
function handleToggle() {
const newTheme = themeStore.isDark ? "light" : "dark";
themeStore.toggle();
telemetry.track({
name: "theme_change",
properties: { theme: newTheme },
});
}
</script>
<button
type="button"
onclick={() => themeStore.toggle()}
onclick={handleToggle}
aria-label={themeStore.isDark
? "Switch to light mode"
: "Switch to dark mode"}
+3 -1
View File
@@ -32,6 +32,8 @@ export class ApiError extends Error {
* Check if an error is a server error (5xx)
*/
static isServerError(error: unknown): boolean {
return error instanceof ApiError && error.status >= 500 && error.status < 600;
return (
error instanceof ApiError && error.status >= 500 && error.status < 600
);
}
}
+4
View File
@@ -1,3 +1,5 @@
import { telemetry } from "$lib/telemetry";
class AuthStore {
isAuthenticated = $state(false);
username = $state<string | null>(null);
@@ -17,6 +19,7 @@ class AuthStore {
const data = await response.json();
this.isAuthenticated = true;
this.username = data.username;
telemetry.identifyAdmin(data.username);
return true;
}
@@ -38,6 +41,7 @@ class AuthStore {
} finally {
this.isAuthenticated = false;
this.username = null;
telemetry.reset();
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Telemetry client wrapper for PostHog with type-safe event tracking.
* Provides console logging in development when PostHog is not configured.
*/
import posthog from "posthog-js";
import { browser, dev } from "$app/environment";
import { env } from "$env/dynamic/public";
import type { TelemetryEvent, ExternalLinkEvent, ErrorEvent } from "./events";
// Environment variables for PostHog configuration
// Set PUBLIC_POSTHOG_KEY and PUBLIC_POSTHOG_HOST in your .env file
// Using dynamic/public so they can be set at runtime (not just build time)
const POSTHOG_KEY = env.PUBLIC_POSTHOG_KEY;
const POSTHOG_HOST = env.PUBLIC_POSTHOG_HOST;
class TelemetryClient {
private initialized = false;
private enabled = false;
/**
* Centralized logging method that only logs in development mode
*/
private log(message: string, data?: unknown): void {
if (dev) {
if (data !== undefined) {
console.log(`[Telemetry] ${message}`, data);
} else {
console.log(`[Telemetry] ${message}`);
}
}
}
/**
* Initialize the PostHog client if keys are available
*/
init(): void {
if (this.initialized || !browser) return;
// Only enable PostHog if both key and host are configured
if (POSTHOG_KEY && POSTHOG_HOST) {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
ui_host: "https://us.posthog.com", // For toolbar links
capture_pageview: false, // We handle page views manually
capture_pageleave: true,
autocapture: true,
persistence: "localStorage",
// Session replay config
session_recording: {
recordCrossOriginIframes: true,
},
});
this.enabled = true;
this.log("PostHog initialized");
if (dev) {
posthog.debug();
}
} else {
this.enabled = false;
this.log(
"PostHog not configured (missing PUBLIC_POSTHOG_KEY or PUBLIC_POSTHOG_HOST)",
);
}
this.initialized = true;
}
/**
* Track a telemetry event with type safety
*/
track<E extends TelemetryEvent>(event: E): void {
if (!browser) return;
this.log(event.name, event.properties);
if (this.enabled) {
posthog.capture(event.name, event.properties);
}
}
/**
* Convenience method for tracking page views
*/
trackPageView(route: string): void {
this.track({
name: "page_view",
properties: {
route,
referrer: browser ? document.referrer : undefined,
},
});
}
/**
* Identify a user with properties
*/
identify(userId: string, properties?: Record<string, unknown>): void {
if (!browser) return;
this.log("identify", { userId, properties });
if (this.enabled) {
posthog.identify(userId, properties);
}
}
/**
* Reset user identification (e.g., on logout)
*/
reset(): void {
if (!this.initialized || !browser) return;
this.log("reset");
if (this.enabled) {
posthog.reset();
}
}
/**
* Check if telemetry is enabled
*/
isEnabled(): boolean {
return this.enabled;
}
/**
* Identify an admin user with admin flag
*/
identifyAdmin(username: string): void {
this.identify(username, {
is_admin: true,
admin_username: username,
});
}
/**
* Track an external link click
*/
trackExternalLink(
url: string,
context: ExternalLinkEvent["properties"]["context"],
): void {
this.track({
name: "external_link_click",
properties: { url, context },
});
}
/**
* Track an error event
*/
trackError(
errorType: ErrorEvent["properties"]["errorType"],
message: string,
options?: { stack?: string; context?: Record<string, unknown> },
): void {
this.track({
name: "error",
properties: {
errorType,
message,
stack: options?.stack,
context: options?.context,
},
});
}
}
/**
* Singleton telemetry client instance
*/
export const telemetry = new TelemetryClient();
+104
View File
@@ -0,0 +1,104 @@
/**
* Type-safe telemetry event system using discriminated unions.
* All events must have a 'name' discriminator property.
*/
/**
* Page view tracking event
*/
export type PageViewEvent = {
name: "page_view";
properties: {
route: string;
referrer?: string;
};
};
/**
* Project card interaction event
*/
export type ProjectInteractionEvent = {
name: "project_interaction";
properties: {
action: "card_view" | "github_click" | "demo_click";
projectSlug: string;
projectName: string;
targetUrl?: string;
};
};
/**
* Tag interaction event (for fuzzy discovery feature)
*/
export type TagInteractionEvent = {
name: "tag_interaction";
properties: {
action: "select" | "deselect" | "reset";
tagSlug?: string;
selectedTags: string[];
};
};
/**
* External link click event
*/
export type ExternalLinkEvent = {
name: "external_link_click";
properties: {
url: string;
context: "social" | "project" | "footer" | "pgp" | "resume";
};
};
/**
* Theme preference change event
*/
export type ThemeEvent = {
name: "theme_change";
properties: {
theme: "light" | "dark";
};
};
/**
* Error tracking event
*/
export type ErrorEvent = {
name: "error";
properties: {
errorType: "network_error" | "validation_error" | "runtime_error" | string;
message: string;
stack?: string;
context?: Record<string, unknown>;
};
};
/**
* PGP page interaction event
*/
export type PgpInteractionEvent = {
name: "pgp_interaction";
properties: {
action: "copy_key" | "download_key" | "copy_command";
};
};
/**
* Discriminated union of all possible events
*/
export type TelemetryEvent =
| PageViewEvent
| ProjectInteractionEvent
| TagInteractionEvent
| ExternalLinkEvent
| ThemeEvent
| ErrorEvent
| PgpInteractionEvent;
/**
* Helper type to extract event properties by event name
*/
export type EventProperties<T extends TelemetryEvent["name"]> = Extract<
TelemetryEvent,
{ name: T }
>["properties"];
+16
View File
@@ -0,0 +1,16 @@
/**
* Telemetry module exports
*/
export { telemetry } from "./client";
export type {
TelemetryEvent,
PageViewEvent,
ProjectInteractionEvent,
TagInteractionEvent,
ExternalLinkEvent,
ThemeEvent,
ErrorEvent,
PgpInteractionEvent,
EventProperties,
} from "./events";
+12 -1
View File
@@ -8,7 +8,8 @@
import { onMount } from "svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { page } from "$app/stores";
import { onNavigate } from "$app/navigation";
import { afterNavigate, onNavigate } from "$app/navigation";
import { telemetry } from "$lib/telemetry";
import Clouds from "$lib/components/Clouds.svelte";
import Dots from "$lib/components/Dots.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
@@ -60,6 +61,13 @@
});
});
// Track page views on navigation (SPA navigations)
afterNavigate(({ to }) => {
if (to?.url.pathname) {
telemetry.trackPageView(to.url.pathname);
}
});
onMount(() => {
// Detect if this is a page reload (F5 or CTRL+F5) vs initial load or SPA navigation
const navigation = performance.getEntriesByType(
@@ -76,6 +84,9 @@
// Initialize theme store
themeStore.init();
// Initialize PostHog telemetry (page views tracked via afterNavigate)
telemetry.init();
// Initialize overlay scrollbars on the body element
const osInstance = OverlayScrollbars(document.body, {
scrollbars: {
+12 -2
View File
@@ -3,6 +3,7 @@
import { page } from "$app/state";
import ProjectCard from "$lib/components/ProjectCard.svelte";
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
import { telemetry } from "$lib/telemetry";
import type { PageData } from "./$types";
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
@@ -31,6 +32,10 @@
function openDiscordModal(username: string) {
pushState("", { discordModal: { open: true, username } });
}
function trackSocialClick(url: string) {
telemetry.trackExternalLink(url, "social");
}
</script>
<main class="page-main overflow-x-hidden font-schibsted">
@@ -64,6 +69,7 @@
<!-- Simple link platforms -->
<a
href={link.value}
onclick={() => trackSocialClick(link.value)}
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
>
<span class="size-4 text-zinc-600 dark:text-zinc-300">
@@ -80,7 +86,10 @@
<button
type="button"
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
onclick={() => openDiscordModal(link.value)}
onclick={() => {
trackSocialClick(`discord:${link.value}`);
openDiscordModal(link.value);
}}
>
<span class="size-4 text-zinc-600 dark:text-zinc-300">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
@@ -95,6 +104,7 @@
<!-- Email - mailto link -->
<a
href="mailto:{link.value}"
onclick={() => trackSocialClick(`mailto:${link.value}`)}
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
>
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
@@ -108,7 +118,7 @@
</a>
{/if}
{/each}
<!-- PGP Key - links to dedicated page -->
<!-- PGP Key - links to dedicated page (tracked via page view) -->
<a
href="/pgp"
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
+2
View File
@@ -5,6 +5,7 @@
import Sidebar from "$lib/components/admin/Sidebar.svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { getAdminStats } from "$lib/api";
import { telemetry } from "$lib/telemetry";
import type { AdminStats } from "$lib/admin-types";
let { children, data } = $props();
@@ -33,6 +34,7 @@
!authStore.isAuthenticated
) {
authStore.setSession(data.session.username);
telemetry.identifyAdmin(data.session.username);
}
});
@@ -40,7 +40,7 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
`/api/tags/${slug}/related`,
{ fetch },
);
} catch (err) {
} catch {
// Non-fatal - just show empty related tags
}
+13
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { OverlayScrollbarsComponent } from "overlayscrollbars-svelte";
import { telemetry } from "$lib/telemetry";
import IconDownload from "~icons/material-symbols/download-rounded";
import IconCopy from "~icons/material-symbols/content-copy-rounded";
import IconCheck from "~icons/material-symbols/check-rounded";
@@ -11,6 +12,10 @@
let copyCommandSuccess = $state(false);
async function copyToClipboard() {
telemetry.track({
name: "pgp_interaction",
properties: { action: "copy_key" },
});
try {
await navigator.clipboard.writeText(data.key.content);
copySuccess = true;
@@ -23,6 +28,10 @@
}
async function copyCommand() {
telemetry.track({
name: "pgp_interaction",
properties: { action: "copy_command" },
});
try {
await navigator.clipboard.writeText(
"curl https://xevion.dev/pgp | gpg --import",
@@ -37,6 +46,10 @@
}
function downloadKey() {
telemetry.track({
name: "pgp_interaction",
properties: { action: "download_key" },
});
const a = document.createElement("a");
a.href = "/publickey.asc";
a.download = "publickey.asc";