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
+78 -1
View File
@@ -17,6 +17,7 @@
"clsx": "^2.1.1",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-svelte": "^0.5.5",
"posthog-js": "^1.321.1",
"satori": "^0.18.3",
"tailwind-merge": "^3.3.1",
},
@@ -164,12 +165,58 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="],
"@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="],
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.4.0", "", { "dependencies": { "@opentelemetry/core": "2.4.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-RWvGLj2lMDZd7M/5tjkI/2VHMpXebLgPKvBUd9LRasEWR2xAynDwEYZuLvY9P2NGG73HF07jbbgWX2C9oavcQg=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="],
"@oxc-project/types": ["@oxc-project/types@0.71.0", "", {}, "sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@posthog/core": ["@posthog/core@1.9.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw=="],
"@posthog/types": ["@posthog/types@1.321.1", "", {}, "sha512-GPVGSvP/uLk/VLQBOnLdRhCoLmce2r0YQ4j4X2aC0fRvPYepJC+O8S0ESHg5vZW4abGrxQktaCzx7jFZur5lBw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="],
"@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="],
@@ -318,6 +365,8 @@
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
@@ -396,6 +445,8 @@
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="],
@@ -420,6 +471,8 @@
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -472,7 +525,7 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="],
"fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -568,6 +621,8 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -634,16 +689,24 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"posthog-js": ["posthog-js@1.321.1", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.9.1", "@posthog/types": "1.321.1", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.0", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^4.2.4" } }, "sha512-PsRkY3LvPg3VUKROvc3HwSGUU9/JKHx/cNS4rZ5uHD5tDRACjjguGgFTp9XBLuf/+9YZcfdg1wxidk5bODvc2w=="],
"preact": ["preact@10.28.2", "", {}, "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
"query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="],
@@ -746,6 +809,8 @@
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -772,6 +837,18 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.4.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw=="],
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+1
View File
@@ -29,6 +29,7 @@
"clsx": "^2.1.1",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-svelte": "^0.5.5",
"posthog-js": "^1.321.1",
"satori": "^0.18.3",
"tailwind-merge": "^3.3.1"
},
+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";
+3
View File
@@ -14,6 +14,9 @@ const config = {
alias: {
$components: "src/lib/components",
},
paths: {
relative: false, // Required for PostHog session replay with SSR
},
prerender: {
handleHttpError: ({ path, referrer, message }) => {
console.log(