mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 14:26:37 -06:00
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:
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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"];
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Telemetry module exports
|
||||
*/
|
||||
|
||||
export { telemetry } from "./client";
|
||||
export type {
|
||||
TelemetryEvent,
|
||||
PageViewEvent,
|
||||
ProjectInteractionEvent,
|
||||
TagInteractionEvent,
|
||||
ExternalLinkEvent,
|
||||
ThemeEvent,
|
||||
ErrorEvent,
|
||||
PgpInteractionEvent,
|
||||
EventProperties,
|
||||
} from "./events";
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user