From 5fde7d249f4659e0615ccf87c8a078acd0b71a3b Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 23 Oct 2025 18:00:24 -0500 Subject: [PATCH] feat: add PostHog telemetry with privacy-focused tracking Integrate optional PostHog telemetry to track page views, RDAP queries, user interactions, and errors while maintaining user privacy. Key features: - Type-safe event tracking with discriminated unions - Automatic source map upload for production error tracking - Privacy protections (query targets excluded from successful lookups) - Do Not Track (DNT) header support - Optional telemetry (disabled by default without environment variables) - Error boundary with automatic error tracking - Context-based telemetry integration throughout UI components Environment variables required for telemetry: - NEXT_PUBLIC_POSTHOG_KEY: PostHog project API key (client-side) - NEXT_PUBLIC_POSTHOG_HOST: PostHog API endpoint (client-side) - POSTHOG_PERSONAL_API_KEY: Source map upload key (server-side) --- README.md | 8 + next.config.mjs | 22 +- package.json | 2 + pnpm-lock.yaml | 316 +++++++++++++++++++++++++++++ src/components/CopyButton.tsx | 14 +- src/components/ErrorBoundary.tsx | 92 +++++++++ src/components/ThemeToggle.tsx | 12 ++ src/contexts/DateFormatContext.tsx | 21 +- src/contexts/TelemetryContext.tsx | 76 +++++++ src/env/schema.mjs | 7 + src/pages/_app.tsx | 30 +-- src/rdap/hooks/useLookup.tsx | 56 +++++ src/telemetry/client.ts | 114 +++++++++++ src/telemetry/events.ts | 91 +++++++++ src/telemetry/index.ts | 14 ++ 15 files changed, 858 insertions(+), 17 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/contexts/TelemetryContext.tsx create mode 100644 src/telemetry/client.ts create mode 100644 src/telemetry/events.ts create mode 100644 src/telemetry/index.ts diff --git a/README.md b/README.md index ca058b8..9fac6b6 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,14 @@ To self-host: pnpm build ``` +## Privacy & Telemetry + +The hosted demo at [rdap.xevion.dev][live-demo] collects optional telemetry to improve the service. Self-hosted deployments have no telemetry by default. + +**What's tracked:** Page views, query metadata (type, success/failure, timing), user interactions, and errors. + +**Privacy protections:** Successful query targets are never logged—only the query type and timing. Failed queries may include targets for debugging. Copy actions track text length only. + ## Contributing Issues and pull requests are welcome! This project uses: diff --git a/next.config.mjs b/next.config.mjs index e6835fd..a5a11a4 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,6 @@ // @ts-check +import { withPostHogConfig } from "@posthog/nextjs-config"; + /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. * This is especially useful for Docker builds. @@ -6,11 +8,27 @@ !process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs")); /** @type {import("next").NextConfig} */ -const config = { +const nextConfig = { reactStrictMode: true, i18n: { locales: ["en"], defaultLocale: "en", }, }; -export default config; + +// PostHog source map configuration +// Only uploads source maps in production builds when POSTHOG_PERSONAL_API_KEY is set +const postHogConfig = { + personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY || "", + envId: "238067", // PostHog project ID for source map correlation + host: "https://us.i.posthog.com", + sourcemaps: { + // Only enable on production builds with API key + enabled: process.env.NODE_ENV === "production" && !!process.env.POSTHOG_PERSONAL_API_KEY, + project: "rdap", + version: process.env.VERCEL_GIT_COMMIT_SHA || "local", + deleteAfterUpload: true, + }, +}; + +export default withPostHogConfig(nextConfig, postHogConfig); diff --git a/package.json b/package.json index f7e601d..e0bea8c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "next-themes": "^0.4.6", "overlayscrollbars": "^2.12.0", "overlayscrollbars-react": "^0.5.6", + "posthog-js": "^1.280.1", "react": "19.2.0", "react-dom": "19.2.0", "react-hook-form": "^7.42.1", @@ -43,6 +44,7 @@ "@codecov/vite-plugin": "^1.9.1", "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@posthog/nextjs-config": "^1.3.6", "@tailwindcss/postcss": "^4.1.15", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd98a1..8212fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: overlayscrollbars-react: specifier: ^0.5.6 version: 0.5.6(overlayscrollbars@2.12.0)(react@19.2.0) + posthog-js: + specifier: ^1.280.1 + version: 1.280.1 react: specifier: 19.2.0 version: 19.2.0 @@ -87,6 +90,9 @@ importers: '@commitlint/config-conventional': specifier: ^19.0.0 version: 19.8.1 + '@posthog/nextjs-config': + specifier: ^1.3.6 + version: 1.3.6(next@15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.93.2)) '@tailwindcss/postcss': specifier: ^4.1.15 version: 4.1.15 @@ -653,6 +659,18 @@ packages: cpu: [x64] os: [win32] + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -880,6 +898,20 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@posthog/cli@0.5.5': + resolution: {integrity: sha512-Cs0xA4W6piyrBUQbCahbSvHUIE1qU9xca4TfVMKhHjbfEJBICV+iLi6TxrQf4REijT+4ln6NL7KF4wbVy0DJUQ==} + engines: {node: '>=14', npm: '>=6'} + hasBin: true + + '@posthog/core@1.3.1': + resolution: {integrity: sha512-sGKVHituJ8L/bJxVV4KamMFp+IBWAZyCiYunFawJZ4cc59PCtLnKFIMEV6kn7A4eZQcQ6EKV5Via4sF3Z7qMLQ==} + + '@posthog/nextjs-config@1.3.6': + resolution: {integrity: sha512-Xs6s+ol0Rkmf1cSMfEIhegeMiHIlqb8JVOe/yoIet9BKWywzoYFa5xKwIjsLe0tmnrcitONiGRl/yyHULKNFtw==} + engines: {node: '>=20'} + peerDependencies: + next: '>12.1.0' + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -2172,6 +2204,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2180,6 +2215,12 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} + axios-proxy-builder@0.1.2: + resolution: {integrity: sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA==} + + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2253,6 +2294,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2267,6 +2312,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -2277,6 +2326,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + console.table@0.10.0: + resolution: {integrity: sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==} + engines: {node: '> 0.10'} + conventional-changelog-angular@7.0.0: resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} engines: {node: '>=16'} @@ -2290,6 +2343,9 @@ packages: engines: {node: '>=16'} hasBin: true + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + cosmiconfig-typescript-loader@6.2.0: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} @@ -2364,6 +2420,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2372,6 +2431,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} @@ -2409,6 +2472,12 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + easy-table@1.1.0: + resolution: {integrity: sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2643,6 +2712,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -2669,10 +2741,27 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2736,6 +2825,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -2991,6 +3085,10 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3178,6 +3276,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -3211,6 +3313,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -3223,6 +3333,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3233,6 +3347,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3363,6 +3481,9 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3390,6 +3511,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3421,6 +3546,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.280.1: + resolution: {integrity: sha512-ZfXaExn42aZBQg88pBzGo71sy4hmei3ubBPb7DBbsfYC3ipZ6F6xmNr3ScNdOyF8cBTZbG24SlIvntp83jTlUA==} + + preact@10.27.2: + resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3492,6 +3623,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3623,6 +3757,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + rollup@4.52.5: resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3748,6 +3887,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -4043,6 +4186,12 @@ packages: jsdom: optional: true + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -4084,6 +4233,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -4565,6 +4718,21 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4760,6 +4928,27 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@posthog/cli@0.5.5': + dependencies: + axios: 1.12.2 + axios-proxy-builder: 0.1.2 + console.table: 0.10.0 + detect-libc: 2.1.2 + rimraf: 6.0.1 + transitivePeerDependencies: + - debug + + '@posthog/core@1.3.1': {} + + '@posthog/nextjs-config@1.3.6(next@15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.93.2))': + dependencies: + '@posthog/cli': 0.5.5 + '@posthog/core': 1.3.1 + next: 15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.93.2) + semver: 7.7.3 + transitivePeerDependencies: + - debug + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -6099,12 +6288,26 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.11.0: {} + axios-proxy-builder@0.1.2: + dependencies: + tunnel: 0.0.6 + + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -6177,6 +6380,9 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone@1.0.4: + optional: true + clsx@2.1.1: {} color-convert@2.0.1: @@ -6187,6 +6393,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@13.1.0: {} compare-func@2.0.0: @@ -6196,6 +6406,10 @@ snapshots: concat-map@0.0.1: {} + console.table@0.10.0: + dependencies: + easy-table: 1.1.0 + conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -6211,6 +6425,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + core-js@3.46.0: {} + cosmiconfig-typescript-loader@6.2.0(@types/node@24.9.1)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: '@types/node': 24.9.1 @@ -6275,6 +6491,11 @@ snapshots: deep-is@0.1.4: {} + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -6287,6 +6508,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + deprecation@2.3.1: {} dequal@2.0.3: {} @@ -6316,6 +6539,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + + easy-table@1.1.0: + optionalDependencies: + wcwidth: 1.0.1 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -6719,6 +6948,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -6747,10 +6978,25 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true @@ -6819,6 +7065,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.3 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -7059,6 +7314,10 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -7224,6 +7483,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.2.2: {} + lz-string@1.5.0: {} magic-string@0.30.19: @@ -7253,12 +7514,22 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} min-indent@1.0.1: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -7269,6 +7540,8 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.2: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -7407,6 +7680,8 @@ snapshots: dependencies: p-limit: 4.0.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7428,6 +7703,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.0: + dependencies: + lru-cache: 11.2.2 + minipass: 7.1.2 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -7452,6 +7732,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.280.1: + dependencies: + '@posthog/core': 1.3.1 + core-js: 3.46.0 + fflate: 0.4.8 + preact: 10.27.2 + web-vitals: 4.2.4 + + preact@10.27.2: {} + prelude-ls@1.2.1: {} prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2): @@ -7472,6 +7762,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -7643,6 +7935,11 @@ snapshots: rfdc@1.4.1: {} + rimraf@6.0.1: + dependencies: + glob: 11.0.3 + package-json-from-dist: 1.0.1 + rollup@4.52.5: dependencies: '@types/estree': 1.0.8 @@ -7837,6 +8134,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -8139,6 +8442,13 @@ snapshots: - tsx - yaml + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true + + web-vitals@4.2.4: {} + webpack-virtual-modules@0.6.2: {} whatwg-mimetype@3.0.0: {} @@ -8201,6 +8511,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 308afdf..6843489 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { CheckIcon, ClipboardIcon } from "@radix-ui/react-icons"; import type { IconButtonProps } from "@radix-ui/themes"; import { IconButton, Tooltip } from "@radix-ui/themes"; +import { useTelemetry } from "@/contexts/TelemetryContext"; /** * Duration in milliseconds for how long the "copied" state persists @@ -64,6 +65,7 @@ const CopyButton: FunctionComponent = ({ const [copied, setCopied] = useState(false); const [tooltipOpen, setTooltipOpen] = useState(false); const forceOpenRef = useRef(false); + const { track } = useTelemetry(); // Consolidated timer effect: Reset copied state, tooltip, and force-open flag useEffect(() => { @@ -85,12 +87,22 @@ const CopyButton: FunctionComponent = ({ navigator.clipboard.writeText(value).then( () => { setCopied(true); + + // Track copy action + track({ + name: "user_interaction", + properties: { + action: "copy_button", + component: "CopyButton", + value: value.length, // Track length instead of actual value for privacy + }, + }); }, (err) => { console.error("Failed to copy to clipboard:", err); } ); - }, [value]); + }, [value, track]); const handleTooltipOpenChange = useCallback((open: boolean) => { // Don't allow the tooltip to close if we're in the forced-open period diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b1679e5 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,92 @@ +/** + * Error boundary component that catches React errors and tracks them via telemetry. + */ + +import type { ReactNode, ErrorInfo } from "react"; +import { Component } from "react"; +import { Box, Flex, Heading, Text, Button } from "@radix-ui/themes"; +import { telemetry } from "@/telemetry/client"; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error) => ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Track the error via telemetry + telemetry.track({ + name: "error", + properties: { + errorType: "runtime_error", + message: error.message, + stack: error.stack, + context: { + componentStack: errorInfo.componentStack, + route: typeof window !== "undefined" ? window.location.pathname : "unknown", + }, + }, + }); + + // Log to console for debugging + console.error("ErrorBoundary caught an error:", error, errorInfo); + } + + render() { + if (this.state.hasError && this.state.error) { + // If custom fallback is provided, use it + if (this.props.fallback) { + return this.props.fallback(this.state.error); + } + + // Default fallback UI using Radix components + return ( + + + + Something went wrong + + + {this.state.error.message} + + + + + + + + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index d7b9f2e..961c8cc 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -4,6 +4,7 @@ import { useTheme } from "next-themes"; import { IconButton } from "@radix-ui/themes"; import { MoonIcon, SunIcon, DesktopIcon } from "@radix-ui/react-icons"; import { useEffect, useState, type ReactElement } from "react"; +import { useTelemetry } from "@/contexts/TelemetryContext"; type Theme = "light" | "dark" | "system"; @@ -21,6 +22,7 @@ const isTheme = (value: string | undefined): value is Theme => { export const ThemeToggle = () => { const { theme, setTheme } = useTheme(); + const { track } = useTelemetry(); const [mounted, setMounted] = useState(false); // Avoid hydration mismatch by only rendering after mount @@ -36,6 +38,16 @@ export const ThemeToggle = () => { const { icon, next } = THEME_CONFIG[currentTheme]; const toggleTheme = () => { + // Track theme change + track({ + name: "user_interaction", + properties: { + action: "theme_toggle", + component: "ThemeToggle", + value: next, + }, + }); + setTheme(next); }; diff --git a/src/contexts/DateFormatContext.tsx b/src/contexts/DateFormatContext.tsx index 5dd3fcd..93e9358 100644 --- a/src/contexts/DateFormatContext.tsx +++ b/src/contexts/DateFormatContext.tsx @@ -1,5 +1,6 @@ import type { FunctionComponent, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { useTelemetry } from "@/telemetry"; type DateFormat = "relative" | "absolute"; @@ -13,6 +14,8 @@ const DateFormatContext = createContext(undef const STORAGE_KEY = "global-date-format-preference"; export const DateFormatProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + const { track } = useTelemetry(); + const [format, setFormat] = useState(() => { // Initialize from localStorage on client side if (typeof window !== "undefined") { @@ -30,8 +33,22 @@ export const DateFormatProvider: FunctionComponent<{ children: ReactNode }> = ({ }, [format]); const toggleFormat = useCallback(() => { - setFormat((current) => (current === "relative" ? "absolute" : "relative")); - }, []); + setFormat((current) => { + const newFormat = current === "relative" ? "absolute" : "relative"; + + // Track date format change + track({ + name: "user_interaction", + properties: { + action: "date_format_change", + component: "DateFormatProvider", + value: newFormat, + }, + }); + + return newFormat; + }); + }, [track]); return ( diff --git a/src/contexts/TelemetryContext.tsx b/src/contexts/TelemetryContext.tsx new file mode 100644 index 0000000..ad90ee8 --- /dev/null +++ b/src/contexts/TelemetryContext.tsx @@ -0,0 +1,76 @@ +/** + * Telemetry context provider for PostHog integration. + * Provides useTelemetry hook and automatic page view tracking. + */ + +import { createContext, useContext, useEffect, type ReactNode } from "react"; +import { useRouter } from "next/router"; +import { telemetry } from "@/telemetry/client"; +import type { TelemetryEvent } from "@/telemetry/events"; + +interface TelemetryContextValue { + track: (event: E) => void; + identify: (userId: string, properties?: Record) => void; + reset: () => void; + isEnabled: () => boolean; +} + +const TelemetryContext = createContext(null); + +interface TelemetryProviderProps { + children: ReactNode; +} + +export function TelemetryProvider({ children }: TelemetryProviderProps) { + const router = useRouter(); + + // Initialize telemetry on mount + useEffect(() => { + telemetry.init(); + }, []); + + // Track page views on route change + useEffect(() => { + const handleRouteChange = (url: string) => { + telemetry.track({ + name: "page_view", + properties: { + route: url, + referrer: document.referrer, + }, + }); + }; + + // Track initial page view + handleRouteChange(router.asPath); + + // Subscribe to route changes + // Note: routeChangeComplete only fires for subsequent navigations, not the initial load, + // so the initial page view tracked above won't be duplicated + router.events.on("routeChangeComplete", handleRouteChange); + + return () => { + router.events.off("routeChangeComplete", handleRouteChange); + }; + }, [router.asPath, router.events]); + + const value: TelemetryContextValue = { + track: telemetry.track.bind(telemetry), + identify: telemetry.identify.bind(telemetry), + reset: telemetry.reset.bind(telemetry), + isEnabled: telemetry.isEnabled.bind(telemetry), + }; + + return {children}; +} + +/** + * Hook to access telemetry client from any component + */ +export function useTelemetry(): TelemetryContextValue { + const context = useContext(TelemetryContext); + if (!context) { + throw new Error("useTelemetry must be used within a TelemetryProvider"); + } + return context; +} diff --git a/src/env/schema.mjs b/src/env/schema.mjs index 40dc4ed..6493fcc 100644 --- a/src/env/schema.mjs +++ b/src/env/schema.mjs @@ -7,6 +7,8 @@ import { z } from "zod"; */ export const serverSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), + // PostHog source map upload configuration (optional, only needed for production builds) + POSTHOG_PERSONAL_API_KEY: z.string().optional(), }); /** @@ -16,6 +18,7 @@ export const serverSchema = z.object({ */ export const serverEnv = { NODE_ENV: process.env.NODE_ENV, + POSTHOG_PERSONAL_API_KEY: process.env.POSTHOG_PERSONAL_API_KEY, }; /** @@ -25,6 +28,8 @@ export const serverEnv = { */ export const clientSchema = z.object({ // NEXT_PUBLIC_CLIENTVAR: z.string(), + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), }); /** @@ -35,4 +40,6 @@ export const clientSchema = z.object({ */ export const clientEnv = { // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 7458623..c67cb24 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,21 +9,27 @@ import "overlayscrollbars/overlayscrollbars.css"; import "@/styles/globals.css"; import { DateFormatProvider } from "@/contexts/DateFormatContext"; +import { TelemetryProvider } from "@/contexts/TelemetryContext"; +import ErrorBoundary from "@/components/ErrorBoundary"; const MyApp: AppType = ({ Component, pageProps }) => { return ( - - - - - - - + + + + + + + + + + + ); }; diff --git a/src/rdap/hooks/useLookup.tsx b/src/rdap/hooks/useLookup.tsx index 533d4cb..3c61d1a 100644 --- a/src/rdap/hooks/useLookup.tsx +++ b/src/rdap/hooks/useLookup.tsx @@ -12,6 +12,7 @@ import { generateBootstrapWarning, } from "@/rdap/services/type-detection"; import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query"; +import { useTelemetry } from "@/contexts/TelemetryContext"; export type WarningHandler = (warning: { message: string }) => void; export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void; @@ -33,6 +34,8 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate // Used to allow repeatable lookups when weird errors happen. const repeatableRef = useRef(""); + const { track } = useTelemetry(); + const getTypeEasy = useCallback(async (target: string): Promise> => { return detectTargetType(target, getRegistry); }, []); @@ -112,6 +115,9 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate targetType = detectedType.value; } + // Track query start + const startTime = performance.now(); + // Execute the RDAP query using the extracted service const result = await executeRdapQuery(target, targetType, { requestJSContact, @@ -119,6 +125,56 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate repeatableUrl: repeatableRef.current, }); + // Calculate duration + const duration = performance.now() - startTime; + + // Track query result + if (result.isOk) { + track({ + name: "rdap_query", + properties: { + targetType, + success: true, + duration, + }, + }); + } else { + // Determine error type + let errorType = "unknown_error"; + if (result.error instanceof HttpSecurityError) { + errorType = "http_security_error"; + } else if (result.error.message.includes("network")) { + errorType = "network_error"; + } else if (result.error.message.includes("validation")) { + errorType = "validation_error"; + } + + track({ + name: "rdap_query", + properties: { + targetType, + target, + success: false, + errorType, + duration, + }, + }); + + // Also track detailed error + track({ + name: "error", + properties: { + errorType: "rdap_query_error", + message: result.error.message, + stack: result.error.stack, + context: { + target, + targetType, + }, + }, + }); + } + // Update repeatable ref if we got an HTTP security error for domain lookups if (result.isErr && result.error instanceof HttpSecurityError) { repeatableRef.current = result.error.url; diff --git a/src/telemetry/client.ts b/src/telemetry/client.ts new file mode 100644 index 0000000..d9264df --- /dev/null +++ b/src/telemetry/client.ts @@ -0,0 +1,114 @@ +/** + * Telemetry client wrapper for PostHog with type-safe event tracking. + * Provides console logging in development/CI environments when PostHog keys are not configured. + */ + +import posthog from "posthog-js"; +import { env } from "@/env/client.mjs"; +import type { TelemetryEvent } from "@/telemetry/events"; + +class TelemetryClient { + private initialized = false; + private enabled = false; + + /** + * Centralized logging method that only logs in development or when PostHog is disabled + */ + private log(message: string, data?: unknown): void { + if (process.env.NODE_ENV !== "production" || !this.enabled) { + if (data !== undefined) { + console.log(`[Telemetry] ${message}`, data); + } else { + console.log(`[Telemetry] ${message}`); + } + } + } + + /** + * Ensure the client is initialized before use + */ + private ensureInitialized(): void { + if (!this.initialized) { + this.init(); + } + } + + /** + * Initialize the PostHog client if keys are available + */ + init(): void { + if (this.initialized) return; + + // Only enable PostHog if both key and host are configured + if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) { + posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: env.NEXT_PUBLIC_POSTHOG_HOST, + loaded: (ph) => { + // Disable in development for debugging purposes + if (process.env.NODE_ENV === "development") { + ph.debug(); + } + }, + capture_pageview: false, // We'll handle page views manually + capture_pageleave: true, + persistence: "localStorage", + }); + this.enabled = true; + this.log("PostHog initialized"); + } else { + this.enabled = false; + this.log("PostHog not configured, console logging enabled"); + } + + this.initialized = true; + } + + /** + * Track a telemetry event with type safety + */ + track(event: E): void { + this.ensureInitialized(); + this.log(event.name, event.properties); + + if (this.enabled) { + posthog.capture(event.name, event.properties); + } + } + + /** + * Identify a user with properties + */ + identify(userId: string, properties?: Record): void { + this.ensureInitialized(); + this.log("identify", { userId, properties }); + + if (this.enabled) { + posthog.identify(userId, properties); + } + } + + /** + * Reset user identification (e.g., on logout) + */ + reset(): void { + if (!this.initialized) return; + this.log("reset"); + + if (this.enabled) { + posthog.reset(); + } + } + + /** + * Check if telemetry is enabled + */ + isEnabled(): boolean { + this.ensureInitialized(); + return this.enabled; + } +} + +/** + * Singleton telemetry client instance + */ +export const telemetry = new TelemetryClient(); diff --git a/src/telemetry/events.ts b/src/telemetry/events.ts new file mode 100644 index 0000000..3ec097e --- /dev/null +++ b/src/telemetry/events.ts @@ -0,0 +1,91 @@ +/** + * Type-safe telemetry event system using discriminated unions. + * All events must have a 'name' discriminator property. + */ + +import type { TargetType } from "@/rdap/schemas"; + +/** + * Page view tracking event + */ +export type PageViewEvent = { + name: "page_view"; + properties: { + route: string; + referrer?: string; + duration?: number; + }; +}; + +/** + * RDAP query tracking event + */ +export type RdapQueryEvent = { + name: "rdap_query"; + properties: { + targetType: TargetType; + target?: string; + success: boolean; + errorType?: string; + duration?: number; + }; +}; + +/** + * User interaction tracking event + */ +export type UserInteractionEvent = { + name: "user_interaction"; + properties: { + action: + | "theme_toggle" + | "date_format_change" + | "copy_button" + | "link_click" + | "expand_section" + | "collapse_section" + | string; + component?: string; + value?: string | number | boolean; + }; +}; + +/** + * Error tracking event + */ +export type ErrorEvent = { + name: "error"; + properties: { + errorType: + | "rdap_query_error" + | "network_error" + | "validation_error" + | "runtime_error" + | string; + message: string; + stack?: string; + context?: Record; + }; +}; + +/** + * Discriminated union of all possible events + */ +export type TelemetryEvent = PageViewEvent | RdapQueryEvent | UserInteractionEvent | ErrorEvent; + +/** + * Helper type to extract event properties by event name + */ +export type EventProperties = Extract< + TelemetryEvent, + { name: T } +>["properties"]; + +/** + * Type guard to ensure an event conforms to the TelemetryEvent schema + */ +export function isValidEvent(event: unknown): event is TelemetryEvent { + if (!event || typeof event !== "object") return false; + const e = event as Partial; + return typeof e.name === "string" && typeof e.properties === "object" && e.properties !== null; +} diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts new file mode 100644 index 0000000..77fdfc0 --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,14 @@ +/** + * Telemetry module exports + */ + +export { telemetry } from "@/telemetry/client"; +export { useTelemetry } from "@/contexts/TelemetryContext"; +export type { + TelemetryEvent, + PageViewEvent, + RdapQueryEvent, + UserInteractionEvent, + ErrorEvent, + EventProperties, +} from "@/telemetry/events";