This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #552 from cosmik-network/posthog-setup

infra/posthog setup for product analytics

author
Wesley Finck
committer
GitHub
date (Feb 19, 2026, 4:55 PM -0800) commit a0e74ffd parent e2dede88
+738 -41
+348
package-lock.json
··· 40 40 "devDependencies": { 41 41 "@atproto/lex-cli": "^0.8.0", 42 42 "@flydotio/dockerfile": "^0.7.10", 43 + "@posthog/react": "^1.8.0", 43 44 "@testcontainers/postgresql": "^11.0.3", 44 45 "@testcontainers/redis": "^11.4.0", 45 46 "@types/cookie-parser": "^1.4.9", ··· 67 68 "nodemon": "^3.1.10", 68 69 "pg": "^8.14.1", 69 70 "playwright": "^1.40.0", 71 + "posthog-js": "^1.348.0", 70 72 "prettier": "^3.6.2", 71 73 "supertest": "^7.1.0", 72 74 "testcontainers": "^11.0.3", ··· 4996 4998 "node": ">=12.4.0" 4997 4999 } 4998 5000 }, 5001 + "node_modules/@opentelemetry/api": { 5002 + "version": "1.9.0", 5003 + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", 5004 + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", 5005 + "devOptional": true, 5006 + "engines": { 5007 + "node": ">=8.0.0" 5008 + } 5009 + }, 5010 + "node_modules/@opentelemetry/api-logs": { 5011 + "version": "0.208.0", 5012 + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", 5013 + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", 5014 + "dev": true, 5015 + "dependencies": { 5016 + "@opentelemetry/api": "^1.3.0" 5017 + }, 5018 + "engines": { 5019 + "node": ">=8.0.0" 5020 + } 5021 + }, 5022 + "node_modules/@opentelemetry/core": { 5023 + "version": "2.2.0", 5024 + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", 5025 + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", 5026 + "dev": true, 5027 + "dependencies": { 5028 + "@opentelemetry/semantic-conventions": "^1.29.0" 5029 + }, 5030 + "engines": { 5031 + "node": "^18.19.0 || >=20.6.0" 5032 + }, 5033 + "peerDependencies": { 5034 + "@opentelemetry/api": ">=1.0.0 <1.10.0" 5035 + } 5036 + }, 5037 + "node_modules/@opentelemetry/exporter-logs-otlp-http": { 5038 + "version": "0.208.0", 5039 + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", 5040 + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", 5041 + "dev": true, 5042 + "dependencies": { 5043 + "@opentelemetry/api-logs": "0.208.0", 5044 + "@opentelemetry/core": "2.2.0", 5045 + "@opentelemetry/otlp-exporter-base": "0.208.0", 5046 + "@opentelemetry/otlp-transformer": "0.208.0", 5047 + "@opentelemetry/sdk-logs": "0.208.0" 5048 + }, 5049 + "engines": { 5050 + "node": "^18.19.0 || >=20.6.0" 5051 + }, 5052 + "peerDependencies": { 5053 + "@opentelemetry/api": "^1.3.0" 5054 + } 5055 + }, 5056 + "node_modules/@opentelemetry/otlp-exporter-base": { 5057 + "version": "0.208.0", 5058 + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", 5059 + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", 5060 + "dev": true, 5061 + "dependencies": { 5062 + "@opentelemetry/core": "2.2.0", 5063 + "@opentelemetry/otlp-transformer": "0.208.0" 5064 + }, 5065 + "engines": { 5066 + "node": "^18.19.0 || >=20.6.0" 5067 + }, 5068 + "peerDependencies": { 5069 + "@opentelemetry/api": "^1.3.0" 5070 + } 5071 + }, 5072 + "node_modules/@opentelemetry/otlp-transformer": { 5073 + "version": "0.208.0", 5074 + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", 5075 + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", 5076 + "dev": true, 5077 + "dependencies": { 5078 + "@opentelemetry/api-logs": "0.208.0", 5079 + "@opentelemetry/core": "2.2.0", 5080 + "@opentelemetry/resources": "2.2.0", 5081 + "@opentelemetry/sdk-logs": "0.208.0", 5082 + "@opentelemetry/sdk-metrics": "2.2.0", 5083 + "@opentelemetry/sdk-trace-base": "2.2.0", 5084 + "protobufjs": "^7.3.0" 5085 + }, 5086 + "engines": { 5087 + "node": "^18.19.0 || >=20.6.0" 5088 + }, 5089 + "peerDependencies": { 5090 + "@opentelemetry/api": "^1.3.0" 5091 + } 5092 + }, 5093 + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { 5094 + "version": "2.2.0", 5095 + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", 5096 + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", 5097 + "dev": true, 5098 + "dependencies": { 5099 + "@opentelemetry/core": "2.2.0", 5100 + "@opentelemetry/semantic-conventions": "^1.29.0" 5101 + }, 5102 + "engines": { 5103 + "node": "^18.19.0 || >=20.6.0" 5104 + }, 5105 + "peerDependencies": { 5106 + "@opentelemetry/api": ">=1.3.0 <1.10.0" 5107 + } 5108 + }, 5109 + "node_modules/@opentelemetry/resources": { 5110 + "version": "2.5.1", 5111 + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", 5112 + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", 5113 + "dev": true, 5114 + "dependencies": { 5115 + "@opentelemetry/core": "2.5.1", 5116 + "@opentelemetry/semantic-conventions": "^1.29.0" 5117 + }, 5118 + "engines": { 5119 + "node": "^18.19.0 || >=20.6.0" 5120 + }, 5121 + "peerDependencies": { 5122 + "@opentelemetry/api": ">=1.3.0 <1.10.0" 5123 + } 5124 + }, 5125 + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { 5126 + "version": "2.5.1", 5127 + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", 5128 + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", 5129 + "dev": true, 5130 + "dependencies": { 5131 + "@opentelemetry/semantic-conventions": "^1.29.0" 5132 + }, 5133 + "engines": { 5134 + "node": "^18.19.0 || >=20.6.0" 5135 + }, 5136 + "peerDependencies": { 5137 + "@opentelemetry/api": ">=1.0.0 <1.10.0" 5138 + } 5139 + }, 5140 + "node_modules/@opentelemetry/sdk-logs": { 5141 + "version": "0.208.0", 5142 + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", 5143 + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", 5144 + "dev": true, 5145 + "dependencies": { 5146 + "@opentelemetry/api-logs": "0.208.0", 5147 + "@opentelemetry/core": "2.2.0", 5148 + "@opentelemetry/resources": "2.2.0" 5149 + }, 5150 + "engines": { 5151 + "node": "^18.19.0 || >=20.6.0" 5152 + }, 5153 + "peerDependencies": { 5154 + "@opentelemetry/api": ">=1.4.0 <1.10.0" 5155 + } 5156 + }, 5157 + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { 5158 + "version": "2.2.0", 5159 + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", 5160 + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", 5161 + "dev": true, 5162 + "dependencies": { 5163 + "@opentelemetry/core": "2.2.0", 5164 + "@opentelemetry/semantic-conventions": "^1.29.0" 5165 + }, 5166 + "engines": { 5167 + "node": "^18.19.0 || >=20.6.0" 5168 + }, 5169 + "peerDependencies": { 5170 + "@opentelemetry/api": ">=1.3.0 <1.10.0" 5171 + } 5172 + }, 5173 + "node_modules/@opentelemetry/sdk-metrics": { 5174 + "version": "2.2.0", 5175 + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", 5176 + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", 5177 + "dev": true, 5178 + "dependencies": { 5179 + "@opentelemetry/core": "2.2.0", 5180 + "@opentelemetry/resources": "2.2.0" 5181 + }, 5182 + "engines": { 5183 + "node": "^18.19.0 || >=20.6.0" 5184 + }, 5185 + "peerDependencies": { 5186 + "@opentelemetry/api": ">=1.9.0 <1.10.0" 5187 + } 5188 + }, 5189 + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { 5190 + "version": "2.2.0", 5191 + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", 5192 + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", 5193 + "dev": true, 5194 + "dependencies": { 5195 + "@opentelemetry/core": "2.2.0", 5196 + "@opentelemetry/semantic-conventions": "^1.29.0" 5197 + }, 5198 + "engines": { 5199 + "node": "^18.19.0 || >=20.6.0" 5200 + }, 5201 + "peerDependencies": { 5202 + "@opentelemetry/api": ">=1.3.0 <1.10.0" 5203 + } 5204 + }, 5205 + "node_modules/@opentelemetry/sdk-trace-base": { 5206 + "version": "2.2.0", 5207 + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", 5208 + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", 5209 + "dev": true, 5210 + "dependencies": { 5211 + "@opentelemetry/core": "2.2.0", 5212 + "@opentelemetry/resources": "2.2.0", 5213 + "@opentelemetry/semantic-conventions": "^1.29.0" 5214 + }, 5215 + "engines": { 5216 + "node": "^18.19.0 || >=20.6.0" 5217 + }, 5218 + "peerDependencies": { 5219 + "@opentelemetry/api": ">=1.3.0 <1.10.0" 5220 + } 5221 + }, 5222 + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { 5223 + "version": "2.2.0", 5224 + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", 5225 + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", 5226 + "dev": true, 5227 + "dependencies": { 5228 + "@opentelemetry/core": "2.2.0", 5229 + "@opentelemetry/semantic-conventions": "^1.29.0" 5230 + }, 5231 + "engines": { 5232 + "node": "^18.19.0 || >=20.6.0" 5233 + }, 5234 + "peerDependencies": { 5235 + "@opentelemetry/api": ">=1.3.0 <1.10.0" 5236 + } 5237 + }, 5238 + "node_modules/@opentelemetry/semantic-conventions": { 5239 + "version": "1.39.0", 5240 + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", 5241 + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", 5242 + "dev": true, 5243 + "engines": { 5244 + "node": ">=14" 5245 + } 5246 + }, 4999 5247 "node_modules/@paralleldrive/cuid2": { 5000 5248 "version": "2.2.2", 5001 5249 "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", ··· 8919 9167 "node": ">=12" 8920 9168 } 8921 9169 }, 9170 + "node_modules/@posthog/core": { 9171 + "version": "1.23.1", 9172 + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.1.tgz", 9173 + "integrity": "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==", 9174 + "dev": true, 9175 + "dependencies": { 9176 + "cross-spawn": "^7.0.6" 9177 + } 9178 + }, 9179 + "node_modules/@posthog/react": { 9180 + "version": "1.8.0", 9181 + "resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.8.0.tgz", 9182 + "integrity": "sha512-UEX3BAIXMrzN7r/JoVotMY9BSTfJ1umiQqfsU30X1OZAW59cO5cPf50YDj4XNhHTCZpiLqenmvJdtJLsx5Pn4Q==", 9183 + "dev": true, 9184 + "peerDependencies": { 9185 + "@types/react": ">=16.8.0", 9186 + "posthog-js": ">=1.257.2", 9187 + "react": ">=16.8.0" 9188 + }, 9189 + "peerDependenciesMeta": { 9190 + "@types/react": { 9191 + "optional": true 9192 + } 9193 + } 9194 + }, 9195 + "node_modules/@posthog/types": { 9196 + "version": "1.351.4", 9197 + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.351.4.tgz", 9198 + "integrity": "sha512-PMrNdOGVIiUczixF+AkodxIigAr3OM2LMfs565uTPZaIUip7S+njoaZRoIT66VJJviWtbLtzJHR49YJtIRIiWQ==", 9199 + "dev": true 9200 + }, 8922 9201 "node_modules/@protobufjs/aspromise": { 8923 9202 "version": "1.1.2", 8924 9203 "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", ··· 13950 14229 "url": "https://github.com/sponsors/mesqueeb" 13951 14230 } 13952 14231 }, 14232 + "node_modules/core-js": { 14233 + "version": "3.48.0", 14234 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", 14235 + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", 14236 + "dev": true, 14237 + "hasInstallScript": true, 14238 + "funding": { 14239 + "type": "opencollective", 14240 + "url": "https://opencollective.com/core-js" 14241 + } 14242 + }, 13953 14243 "node_modules/core-util-is": { 13954 14244 "version": "1.0.3", 13955 14245 "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", ··· 14782 15072 }, 14783 15073 "funding": { 14784 15074 "url": "https://github.com/fb55/domhandler?sponsor=1" 15075 + } 15076 + }, 15077 + "node_modules/dompurify": { 15078 + "version": "3.3.1", 15079 + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", 15080 + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", 15081 + "dev": true, 15082 + "optionalDependencies": { 15083 + "@types/trusted-types": "^2.0.7" 14785 15084 } 14786 15085 }, 14787 15086 "node_modules/domutils": { ··· 23151 23450 "node": ">=0.10.0" 23152 23451 } 23153 23452 }, 23453 + "node_modules/posthog-js": { 23454 + "version": "1.351.4", 23455 + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.351.4.tgz", 23456 + "integrity": "sha512-vLSYHMN31FGs1cTJ/SCW0aCLiV6K6ZW63Q1K3B16iWRBVTwz/kg6sJZi6Z/oFeKOuy9I5K/LbVLDBCaTTlcESA==", 23457 + "dev": true, 23458 + "dependencies": { 23459 + "@opentelemetry/api": "^1.9.0", 23460 + "@opentelemetry/api-logs": "^0.208.0", 23461 + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", 23462 + "@opentelemetry/resources": "^2.2.0", 23463 + "@opentelemetry/sdk-logs": "^0.208.0", 23464 + "@posthog/core": "1.23.1", 23465 + "@posthog/types": "1.351.4", 23466 + "core-js": "^3.38.1", 23467 + "dompurify": "^3.3.1", 23468 + "fflate": "^0.4.8", 23469 + "preact": "^10.28.2", 23470 + "query-selector-shadow-dom": "^1.0.1", 23471 + "web-vitals": "^5.1.0" 23472 + } 23473 + }, 23474 + "node_modules/posthog-js/node_modules/fflate": { 23475 + "version": "0.4.8", 23476 + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", 23477 + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", 23478 + "dev": true 23479 + }, 23154 23480 "node_modules/posthtml": { 23155 23481 "version": "0.16.7", 23156 23482 "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", ··· 23198 23524 }, 23199 23525 "engines": { 23200 23526 "node": ">=12" 23527 + } 23528 + }, 23529 + "node_modules/preact": { 23530 + "version": "10.28.4", 23531 + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", 23532 + "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", 23533 + "dev": true, 23534 + "funding": { 23535 + "type": "opencollective", 23536 + "url": "https://opencollective.com/preact" 23201 23537 } 23202 23538 }, 23203 23539 "node_modules/prelude-ls": { ··· 23462 23798 "funding": { 23463 23799 "url": "https://github.com/sponsors/ljharb" 23464 23800 } 23801 + }, 23802 + "node_modules/query-selector-shadow-dom": { 23803 + "version": "1.0.1", 23804 + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", 23805 + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", 23806 + "dev": true 23465 23807 }, 23466 23808 "node_modules/queue-microtask": { 23467 23809 "version": "1.2.3", ··· 27306 27648 "version": "1.2.2", 27307 27649 "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", 27308 27650 "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", 27651 + "dev": true 27652 + }, 27653 + "node_modules/web-vitals": { 27654 + "version": "5.1.0", 27655 + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", 27656 + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", 27309 27657 "dev": true 27310 27658 }, 27311 27659 "node_modules/webidl-conversions": {
+3 -1
package.json
··· 125 125 "ts-jest": "^29.3.2", 126 126 "tsconfig-paths": "^4.2.0", 127 127 "tsup": "^8.4.0", 128 - "typescript": "^5.8.3" 128 + "typescript": "^5.8.3", 129 + "@posthog/react": "^1.8.0", 130 + "posthog-js": "^1.348.0" 129 131 } 130 132 }
+36
src/webapp/features/analytics/types.ts
··· 1 + export enum CardSaveSource { 2 + PROFILE = 'profile', 3 + SEMBLE_PAGE = 'semble_page', 4 + COLLECTION = 'collection', 5 + FEED = 'feed', 6 + SIMILAR_CARDS = 'similar_cards', 7 + NOTIFICATIONS = 'notifications', 8 + SEARCH_RESULTS = 'search_results', 9 + ADD_CARD_DRAWER = 'add_card_drawer', 10 + } 11 + 12 + export interface CardSaveAnalyticsContext { 13 + saveSource: CardSaveSource; 14 + activeFilters?: { 15 + urlType?: string; 16 + sort?: string; 17 + searchQuery?: string; 18 + profileFilter?: string; 19 + }; 20 + pagePath?: string; 21 + } 22 + 23 + export interface CardSaveEventProperties { 24 + save_source: CardSaveSource; 25 + is_new_card: boolean; 26 + has_note: boolean; 27 + collection_count: number; 28 + active_filters?: { 29 + url_type?: string; 30 + sort?: string; 31 + search_query?: string; 32 + profile_filter?: string; 33 + }; 34 + via_card_id?: string; 35 + page_path?: string; 36 + }
+11
src/webapp/features/analytics/utils.ts
··· 1 + import posthog from 'posthog-js'; 2 + 3 + /** 4 + * Check if analytics should be captured 5 + * Centralizes the logic for when to track events 6 + */ 7 + export function shouldCaptureAnalytics(): boolean { 8 + return ( 9 + process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' && posthog.__loaded 10 + ); 11 + }
+7 -1
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 29 29 import MarginLogo from '@/components/MarginLogo'; 30 30 import { Collection, CollectionAccessType } from '@semble/types'; 31 31 import { FaSeedling } from 'react-icons/fa6'; 32 + import { CardSaveSource } from '@/features/analytics/types'; 33 + import { usePathname } from 'next/navigation'; 32 34 33 35 interface Props { 34 36 isOpen: boolean; ··· 38 40 } 39 41 40 42 export default function AddCardDrawer(props: Props) { 43 + const pathname = usePathname(); 41 44 const [collectionSelectorOpened, { toggle: toggleCollectionSelector }] = 42 45 useDisclosure(false); 43 46 const initialCollections = props.selectedCollection ··· 50 53 const myCollections = 51 54 collections?.pages.flatMap((page) => page.collections ?? []) ?? []; 52 55 53 - const addCard = useAddCard(); 56 + const addCard = useAddCard({ 57 + saveSource: CardSaveSource.ADD_CARD_DRAWER, 58 + pagePath: pathname, 59 + }); 54 60 55 61 const form = useForm({ 56 62 initialValues: {
+3
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 5 5 import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 6 6 import AddCardToModalContent from './AddCardToModalContent'; 7 7 import CardToBeAddedPreview from '../cardToBeAddedPreview/CardToBeAddedPreview'; 8 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 8 9 9 10 interface Props { 10 11 isOpen: boolean; ··· 16 17 urlLibraryCount: number; 17 18 viaCardId?: string; 18 19 cardContent?: UrlCard['cardContent']; 20 + analyticsContext?: CardSaveAnalyticsContext; 19 21 } 20 22 21 23 export default function AddCardToModal(props: Props) { ··· 63 65 cardContent={props.cardContent} 64 66 note={props.note} 65 67 viaCardId={props.viaCardId} 68 + analyticsContext={props.analyticsContext} 66 69 /> 67 70 </Suspense> 68 71 </Stack>
+10 -2
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
··· 12 12 import useAddCard from '@/features/cards/lib/mutations/useAddCard'; 13 13 import { track } from '@vercel/analytics'; 14 14 import AddCardActions from '../addCardActions/AddCardActions'; 15 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 15 16 16 17 interface Props { 17 18 onClose: () => void; ··· 20 21 note?: string; 21 22 cardContent?: UrlCard['cardContent']; 22 23 viaCardId?: string; 24 + analyticsContext?: CardSaveAnalyticsContext; 23 25 } 24 26 25 27 export default function AddCardToModalContent(props: Props) { ··· 28 30 const [note, setNote] = useState(isMyCard ? props.note : ''); 29 31 const { data, error } = useMyCollections(); 30 32 31 - const addCard = useAddCard(); 32 - const updateCardAssociations = useUpdateCardAssociations(); 33 + const addCard = useAddCard(props.analyticsContext); 34 + const updateCardAssociations = useUpdateCardAssociations( 35 + props.analyticsContext, 36 + ); 33 37 34 38 if (error) { 35 39 return <CollectionSelectorError />; ··· 98 102 note?: string; 99 103 addToCollectionIds?: string[]; 100 104 removeFromCollectionIds?: string[]; 105 + addToLibrary?: boolean; 101 106 } = { cardId: cardStatus.data.card.id }; 102 107 103 108 if (hasNoteChanged) updatedCardPayload.note = trimmedNote; ··· 107 112 updatedCardPayload.removeFromCollectionIds = removedCollections.map( 108 113 (c) => c.id, 109 114 ); 115 + 116 + // Track as a card save if we're adding collections or a note (indicates user is saving/organizing the card) 117 + updatedCardPayload.addToLibrary = hasAdded || hasNoteChanged; 110 118 111 119 updateCardAssociations.mutate( 112 120 {
+29
src/webapp/features/cards/components/urlCard/UrlCard.tsx
··· 11 11 import { useUserSettings } from '@/features/settings/lib/queries/useUserSettings'; 12 12 import UrlCardDebugView from '../UrlCardDebugView/UrlCardDebugView'; 13 13 import Link from 'next/link'; 14 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 15 + import posthog from 'posthog-js'; 16 + import { shouldCaptureAnalytics } from '@/features/analytics/utils'; 14 17 15 18 interface Props { 16 19 id: string; ··· 25 28 cardAuthor?: User; 26 29 viaCardId?: string; 27 30 showAuthor?: boolean; 31 + analyticsContext?: CardSaveAnalyticsContext; 28 32 } 29 33 30 34 export default function UrlCard(props: Props) { ··· 35 39 e.stopPropagation(); 36 40 37 41 let targetUrl: string; 42 + const isNavigatingToSemble = 43 + !isCollectionPage(props.url) && !isProfilePage(props.url); 38 44 39 45 if (isCollectionPage(props.url) || isProfilePage(props.url)) { 40 46 targetUrl = props.url; ··· 45 51 } else { 46 52 targetUrl = `/url?id=${props.cardContent.url}`; 47 53 } 54 + } 55 + 56 + // Register super properties and capture click event when navigating to semble page 57 + if ( 58 + isNavigatingToSemble && 59 + props.analyticsContext && 60 + shouldCaptureAnalytics() 61 + ) { 62 + // Register super properties for later card_saved event 63 + posthog.register({ 64 + original_save_source: props.analyticsContext.saveSource, 65 + original_active_filters: props.analyticsContext.activeFilters, 66 + }); 67 + 68 + // Capture card clicked event 69 + posthog.capture('card_clicked', { 70 + card_id: props.id, 71 + url: props.cardContent.url, 72 + save_source: props.analyticsContext.saveSource, 73 + active_filters: props.analyticsContext.activeFilters, 74 + via_card_id: props.viaCardId, 75 + }); 48 76 } 49 77 50 78 // Open in new tab if Cmd+Click (Mac), Ctrl+Click (Windows/Linux), or middle click ··· 133 161 urlLibraryCount={props.urlLibraryCount} 134 162 urlIsInLibrary={props.urlIsInLibrary ?? false} 135 163 viaCardId={props.viaCardId} 164 + analyticsContext={props.analyticsContext} 136 165 /> 137 166 </Stack> 138 167 </Stack>
+3
src/webapp/features/cards/components/urlCardActions/UrlCardActions.tsx
··· 20 20 import { IoMdCheckmark } from 'react-icons/io'; 21 21 import { notifications } from '@mantine/notifications'; 22 22 import { BiCopy } from 'react-icons/bi'; 23 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 23 24 24 25 interface Props { 25 26 id: string; ··· 32 33 urlLibraryCount: number; 33 34 urlIsInLibrary: boolean; 34 35 viaCardId?: string; 36 + analyticsContext?: CardSaveAnalyticsContext; 35 37 } 36 38 37 39 export default function UrlCardActions(props: Props) { ··· 194 196 isInYourLibrary={props.urlIsInLibrary} 195 197 urlLibraryCount={props.urlLibraryCount} 196 198 viaCardId={props.viaCardId} 199 + analyticsContext={props.analyticsContext} 197 200 /> 198 201 199 202 <NoteCardModal
+11 -1
src/webapp/features/cards/containers/cardsContainerContent/CardsContainerContent.tsx
··· 9 9 import { useNavbarContext } from '@/providers/navbar'; 10 10 import { FaRegNoteSticky } from 'react-icons/fa6'; 11 11 import { useUserSettings } from '@/features/settings/lib/queries/useUserSettings'; 12 - import { useSearchParams } from 'next/navigation'; 12 + import { useSearchParams, usePathname } from 'next/navigation'; 13 + import { CardSaveSource } from '@/features/analytics/types'; 13 14 14 15 interface Props { 15 16 handle: string; ··· 22 23 }; 23 24 24 25 export default function CardsContainerContent(props: Props) { 26 + const pathname = usePathname(); 25 27 const { desktopOpened } = useNavbarContext(); 26 28 const { settings } = useUserSettings(); 27 29 ··· 97 99 urlLibraryCount={card.urlLibraryCount} 98 100 urlIsInLibrary={card.urlInLibrary} 99 101 viaCardId={card.id} 102 + analyticsContext={{ 103 + saveSource: CardSaveSource.PROFILE, 104 + activeFilters: { 105 + urlType: selectedUrlType, 106 + sort: sortBy, 107 + }, 108 + pagePath: pathname, 109 + }} 100 110 /> 101 111 </Grid.Col> 102 112 ))}
+35 -1
src/webapp/features/cards/lib/mutations/useAddCard.tsx
··· 5 5 import { feedKeys } from '@/features/feeds/lib/feedKeys'; 6 6 import { noteKeys } from '@/features/notes/lib/noteKeys'; 7 7 import { sembleKeys } from '@/features/semble/lib/sembleKeys'; 8 + import posthog from 'posthog-js'; 9 + import { 10 + CardSaveAnalyticsContext, 11 + CardSaveEventProperties, 12 + } from '@/features/analytics/types'; 13 + import { shouldCaptureAnalytics } from '@/features/analytics/utils'; 8 14 9 - export default function useAddCard() { 15 + export default function useAddCard( 16 + analyticsContext?: CardSaveAnalyticsContext, 17 + ) { 10 18 const queryClient = useQueryClient(); 11 19 12 20 const mutation = useMutation({ ··· 47 55 queryKey: collectionKeys.infinite(id), 48 56 }); 49 57 }); 58 + 59 + // Track card save event in PostHog 60 + if (shouldCaptureAnalytics() && analyticsContext) { 61 + const eventProperties: CardSaveEventProperties = { 62 + save_source: analyticsContext.saveSource, 63 + is_new_card: true, 64 + has_note: !!variables.note, 65 + collection_count: variables.collectionIds?.length || 0, 66 + active_filters: analyticsContext.activeFilters 67 + ? { 68 + url_type: analyticsContext.activeFilters.urlType, 69 + sort: analyticsContext.activeFilters.sort, 70 + search_query: analyticsContext.activeFilters.searchQuery, 71 + profile_filter: analyticsContext.activeFilters.profileFilter, 72 + } 73 + : undefined, 74 + via_card_id: variables.viaCardId, 75 + page_path: analyticsContext.pagePath, 76 + }; 77 + 78 + posthog.capture('card_saved', eventProperties); 79 + 80 + // Clear super properties after capture 81 + posthog.unregister('original_save_source'); 82 + posthog.unregister('original_active_filters'); 83 + } 50 84 }, 51 85 }); 52 86
+40 -1
src/webapp/features/cards/lib/mutations/useUpdateCardAssociations.tsx
··· 5 5 import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 6 import { sembleKeys } from '@/features/semble/lib/sembleKeys'; 7 7 import { feedKeys } from '@/features/feeds/lib/feedKeys'; 8 + import posthog from 'posthog-js'; 9 + import { 10 + CardSaveAnalyticsContext, 11 + CardSaveEventProperties, 12 + } from '@/features/analytics/types'; 13 + import { shouldCaptureAnalytics } from '@/features/analytics/utils'; 8 14 9 - export default function useUpdateCardAssociations() { 15 + export default function useUpdateCardAssociations( 16 + analyticsContext?: CardSaveAnalyticsContext, 17 + ) { 10 18 const client = createSembleClient(); 11 19 12 20 const queryClient = useQueryClient(); ··· 18 26 addToCollectionIds?: string[]; 19 27 removeFromCollectionIds?: string[]; 20 28 viaCardId?: string; 29 + addToLibrary?: boolean; 21 30 }) => { 22 31 return client.updateUrlCardAssociations({ 23 32 cardId: updatedCard.cardId, ··· 47 56 queryKey: collectionKeys.collection(id), 48 57 }); 49 58 }); 59 + 60 + // Track card save event in PostHog (only when adding to library) 61 + if ( 62 + shouldCaptureAnalytics() && 63 + analyticsContext && 64 + variables.addToLibrary 65 + ) { 66 + const eventProperties: CardSaveEventProperties = { 67 + save_source: analyticsContext.saveSource, 68 + is_new_card: false, 69 + has_note: !!variables.note, 70 + collection_count: variables.addToCollectionIds?.length || 0, 71 + active_filters: analyticsContext.activeFilters 72 + ? { 73 + url_type: analyticsContext.activeFilters.urlType, 74 + sort: analyticsContext.activeFilters.sort, 75 + search_query: analyticsContext.activeFilters.searchQuery, 76 + profile_filter: analyticsContext.activeFilters.profileFilter, 77 + } 78 + : undefined, 79 + via_card_id: variables.viaCardId, 80 + page_path: analyticsContext.pagePath, 81 + }; 82 + 83 + posthog.capture('card_saved', eventProperties); 84 + 85 + // Clear super properties after capture 86 + posthog.unregister('original_save_source'); 87 + posthog.unregister('original_active_filters'); 88 + } 50 89 }, 51 90 }); 52 91
+11 -1
src/webapp/features/collections/containers/collectionContainerContent/CollectionContainerContent.tsx
··· 11 11 import AddCardDrawer from '@/features/cards/components/addCardDrawer/AddCardDrawer'; 12 12 import Link from 'next/link'; 13 13 import { FiPlus } from 'react-icons/fi'; 14 - import { useSearchParams } from 'next/navigation'; 14 + import { useSearchParams, usePathname } from 'next/navigation'; 15 + import { CardSaveSource } from '@/features/analytics/types'; 15 16 16 17 interface Props { 17 18 rkey: string; ··· 27 28 }; 28 29 29 30 export default function CollectionContainerContent(props: Props) { 31 + const pathname = usePathname(); 30 32 const { desktopOpened } = useNavbarContext(); 31 33 const [showAddDrawer, setShowAddDrawer] = useState(false); 32 34 const { user } = useAuth(); ··· 84 86 currentCollection={firstPage} 85 87 viaCardId={card.id} 86 88 showAuthor={props.handle !== card.author.handle} 89 + analyticsContext={{ 90 + saveSource: CardSaveSource.COLLECTION, 91 + activeFilters: { 92 + urlType: selectedUrlType, 93 + sort: sortBy, 94 + }, 95 + pagePath: pathname, 96 + }} 87 97 /> 88 98 </Grid.Col> 89 99 ))}
+3
src/webapp/features/feeds/components/feedItem/FeedItem.tsx
··· 2 2 import { Stack } from '@mantine/core'; 3 3 import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 4 4 import FeedActivityStatus from '../feedActivityStatus/FeedActivityStatus'; 5 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 5 6 6 7 interface Props { 7 8 item: FeedItemType; 9 + analyticsContext?: CardSaveAnalyticsContext; 8 10 } 9 11 10 12 export default function FeedItem(props: Props) { ··· 27 29 urlIsInLibrary={props.item.card.urlInLibrary} 28 30 authorHandle={props.item.user.handle} 29 31 viaCardId={props.item.card.id} 32 + analyticsContext={props.analyticsContext} 30 33 /> 31 34 </Stack> 32 35 );
+14 -2
src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx
··· 9 9 import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 10 10 import RefetchButton from '@/components/navigation/refetchButton/RefetchButton'; 11 11 import { UrlType, ActivitySource } from '@semble/types'; 12 - import { useSearchParams, useRouter } from 'next/navigation'; 12 + import { useSearchParams, useRouter, usePathname } from 'next/navigation'; 13 + import { CardSaveSource } from '@/features/analytics/types'; 13 14 14 15 export default function MyFeedContainer() { 15 16 const router = useRouter(); 17 + const pathname = usePathname(); 16 18 const searchParams = useSearchParams(); 17 19 const selectedUrlType = searchParams.get('type') as UrlType; 18 20 const selectedSource = searchParams.get('source') as ActivitySource; ··· 81 83 <Stack gap={'xl'} mx={'auto'} maw={600} w={'100%'}> 82 84 <Stack gap={60}> 83 85 {allActivities.map((item) => ( 84 - <FeedItem key={item.id} item={item} /> 86 + <FeedItem 87 + key={item.id} 88 + item={item} 89 + analyticsContext={{ 90 + saveSource: CardSaveSource.FEED, 91 + activeFilters: { 92 + urlType: selectedUrlType, 93 + }, 94 + pagePath: pathname, 95 + }} 96 + /> 85 97 ))} 86 98 </Stack> 87 99 </Stack>
+3
src/webapp/features/notifications/components/notificationItem/NotificationItem.tsx
··· 5 5 import NotificationActivityStatus from '../notificationActivityStatus/NotificationActivityStatus'; 6 6 import FollowButton from '@/features/follows/components/followButton/FollowButton'; 7 7 import { useRouter } from 'next/navigation'; 8 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 8 9 9 10 interface Props { 10 11 item: NotificationItemType; 12 + analyticsContext?: CardSaveAnalyticsContext; 11 13 } 12 14 13 15 export default function NotificationItem(props: Props) { ··· 69 71 urlIsInLibrary={props.item.card.urlInLibrary} 70 72 authorHandle={props.item.user.handle} 71 73 viaCardId={props.item.card.id} 74 + analyticsContext={props.analyticsContext} 72 75 /> 73 76 )} 74 77 </Stack>
+11 -1
src/webapp/features/notifications/containers/notificationsContainer/NotificationsContainer.tsx
··· 21 21 import useMarkAllNotificationsAsRead from '../../lib/mutations/useMarkAllNotificationsAsRead'; 22 22 import { IoCheckmarkDoneSharp } from 'react-icons/io5'; 23 23 import useUnreadNotificationCount from '../../lib/queries/useUnreadNotificationCount'; 24 + import { CardSaveSource } from '@/features/analytics/types'; 25 + import { usePathname } from 'next/navigation'; 24 26 25 27 export default function NotificationsContainer() { 28 + const pathname = usePathname(); 26 29 const { 27 30 data, 28 31 error, ··· 117 120 <Stack gap={'xl'} mx={'auto'} maw={600} w={'100%'}> 118 121 <Stack gap={60}> 119 122 {allNotifications.map((item) => ( 120 - <NotificationItem key={item.id} item={item} /> 123 + <NotificationItem 124 + key={item.id} 125 + item={item} 126 + analyticsContext={{ 127 + saveSource: CardSaveSource.NOTIFICATIONS, 128 + pagePath: pathname, 129 + }} 130 + /> 121 131 ))} 122 132 </Stack> 123 133 </Stack>
+7
src/webapp/features/profile/containers/profileContainer/ProfileContainer.tsx
··· 18 18 import { BiCollection } from 'react-icons/bi'; 19 19 import { FaRegNoteSticky } from 'react-icons/fa6'; 20 20 import { useNavbarContext } from '@/providers/navbar'; 21 + import { CardSaveSource } from '@/features/analytics/types'; 22 + import { usePathname } from 'next/navigation'; 21 23 22 24 interface Props { 23 25 handle: string; 24 26 } 25 27 26 28 export default function ProfileContainer(props: Props) { 29 + const pathname = usePathname(); 27 30 const { data: collectionsData } = useCollections({ 28 31 limit: 4, 29 32 didOrHandle: props.handle, ··· 81 84 urlLibraryCount={card.urlLibraryCount} 82 85 urlIsInLibrary={card.urlInLibrary} 83 86 viaCardId={card.id} 87 + analyticsContext={{ 88 + saveSource: CardSaveSource.PROFILE, 89 + pagePath: pathname, 90 + }} 84 91 /> 85 92 </Grid.Col> 86 93 ))}
+2
src/webapp/features/search/components/searchBar/ExpandedSearchBar.tsx
··· 18 18 import { SearchFilters } from '../searchFilters/SearchFilters'; 19 19 import { TbAdjustmentsHorizontal } from 'react-icons/tb'; 20 20 import { track } from '@vercel/analytics'; 21 + import posthog from 'posthog-js'; 21 22 22 23 interface Props { 23 24 query?: string; ··· 143 144 loading={isPending} 144 145 onClick={() => { 145 146 track('Search: search button clicked'); 147 + posthog.capture('Search: search button clicked'); 146 148 }} 147 149 > 148 150 <IoSearch size={24} />
+2
src/webapp/features/search/components/searchBar/SearchBar.tsx
··· 5 5 import { useRouter, useSearchParams, usePathname } from 'next/navigation'; 6 6 import { useRef, useState, useTransition } from 'react'; 7 7 import { track } from '@vercel/analytics'; 8 + import posthog from 'posthog-js'; 8 9 9 10 interface Props { 10 11 query?: string; ··· 88 89 loading={isPending} 89 90 onClick={() => { 90 91 track('Search: search button clicked'); 92 + posthog.capture('Search: search button clicked'); 91 93 }} 92 94 > 93 95 <IoSearch size={20} />
+2
src/webapp/features/search/components/searchTabItem/SearchTabItem.tsx
··· 4 4 import { ReactNode } from 'react'; 5 5 import styles from './SearchTabItem.module.css'; 6 6 import { track } from '@vercel/analytics'; 7 + import posthog from 'posthog-js'; 7 8 8 9 interface Props { 9 10 icon: ReactNode; ··· 19 20 leftSection={props.icon} 20 21 onClick={() => { 21 22 track(`Search: ${props.value} tab clicked`); 23 + posthog.capture(`Search: ${props.value} tab clicked`); 22 24 }} 23 25 > 24 26 {props.label}
+15 -1
src/webapp/features/search/containers/cardSearchResultsContainer/CardSearchResultsContainer.tsx
··· 10 10 import SearchResultsContainerError from '../searchResultsContainer/Error.SearchResultsContainer'; 11 11 import SearchQueryAlert from '../../components/searchQueryAlert/SearchQueryAlert'; 12 12 import { SearchFilters } from '../../components/searchFilters/SearchFilters'; 13 + import { CardSaveSource } from '@/features/analytics/types'; 14 + import { usePathname } from 'next/navigation'; 13 15 14 16 interface Props { 15 17 query: string; ··· 18 20 } 19 21 20 22 export default function CardSearchResultsContainer(props: Props) { 23 + const pathname = usePathname(); 21 24 const { 22 25 data, 23 26 error, ··· 59 62 <Grid gutter="xs"> 60 63 {allUrls.map((urlView) => ( 61 64 <Grid.Col key={urlView.url} span={12}> 62 - <SimilarUrlCard urlView={urlView} /> 65 + <SimilarUrlCard 66 + urlView={urlView} 67 + analyticsContext={{ 68 + saveSource: CardSaveSource.SEARCH_RESULTS, 69 + activeFilters: { 70 + searchQuery: props.query, 71 + profileFilter: props.handle, 72 + urlType: props.urlType, 73 + }, 74 + pagePath: pathname, 75 + }} 76 + /> 63 77 </Grid.Col> 64 78 ))} 65 79 </Grid>
+7
src/webapp/features/semble/components/sembleActions/SembleActions.tsx
··· 10 10 import { MdIosShare } from 'react-icons/md'; 11 11 import useSembleLibraries from '../../lib/queries/useSembleLibraries'; 12 12 import { track } from '@vercel/analytics'; 13 + import { CardSaveSource } from '@/features/analytics/types'; 14 + import { usePathname } from 'next/navigation'; 13 15 14 16 interface Props { 15 17 url: string; ··· 17 19 } 18 20 19 21 export default function SembleActions(props: Props) { 22 + const pathname = usePathname(); 20 23 const cardStatus = useGetCardFromMyLibrary({ url: props.url }); 21 24 const isInYourLibrary = cardStatus.data.card?.urlInLibrary; 22 25 const [showAddToModal, setShowAddToModal] = useState(false); ··· 91 94 isInYourLibrary={cardStatus.data.card?.urlInLibrary} 92 95 urlLibraryCount={urlLibraryCount} 93 96 viaCardId={props.viaCardId} 97 + analyticsContext={{ 98 + saveSource: CardSaveSource.SEMBLE_PAGE, 99 + pagePath: pathname, 100 + }} 94 101 /> 95 102 </Fragment> 96 103 );
+2
src/webapp/features/semble/components/sembleTabs/TabItem.tsx
··· 1 1 import { TabsTab } from '@mantine/core'; 2 2 import classes from './TabItem.module.css'; 3 3 import { track } from '@vercel/analytics'; 4 + import posthog from 'posthog-js'; 4 5 5 6 interface Props { 6 7 value: string; ··· 15 16 fw={600} 16 17 onClick={() => { 17 18 track(`Semble: ${props.value} tab`); 19 + posthog.capture(`Semble: ${props.value} tab`); 18 20 }} 19 21 > 20 22 {props.children}
+3
src/webapp/features/semble/components/similarUrlCard/SimilarUrlCard.tsx
··· 2 2 3 3 import type { UrlView } from '@/api-client'; 4 4 import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 5 + import { CardSaveAnalyticsContext } from '@/features/analytics/types'; 5 6 6 7 interface Props { 7 8 urlView: UrlView; 9 + analyticsContext?: CardSaveAnalyticsContext; 8 10 } 9 11 10 12 export default function SimilarUrlCard(props: Props) { ··· 15 17 cardContent={props.urlView.metadata} 16 18 urlLibraryCount={props.urlView.urlLibraryCount} 17 19 urlIsInLibrary={props.urlView.urlInLibrary ?? false} 20 + analyticsContext={props.analyticsContext} 18 21 /> 19 22 ); 20 23 }
+13 -2
src/webapp/features/semble/containers/sembleSimilarCardsContainer/SembleSimilarCardsContainer.tsx
··· 8 8 import SembleEmptyTab from '../../components/sembleEmptyTab/SembleEmptyTab'; 9 9 import { BiLink } from 'react-icons/bi'; 10 10 import { CardFilters } from '@/features/cards/components/cardFilters/CardFilters'; 11 - import { useSearchParams } from 'next/navigation'; 11 + import { useSearchParams, usePathname } from 'next/navigation'; 12 12 import { UrlType } from '@semble/types'; 13 + import { CardSaveSource } from '@/features/analytics/types'; 13 14 14 15 interface Props { 15 16 url: string; 16 17 } 17 18 18 19 export default function SembleSimilarCardsContainer(props: Props) { 20 + const pathname = usePathname(); 19 21 const searchParams = useSearchParams(); 20 22 const selectedUrlType = searchParams.get('type') as UrlType; 21 23 ··· 58 60 <Grid gutter="sm" mx={'auto'} maw={600} w={'100%'}> 59 61 {allSimilarUrls.map((urlView) => ( 60 62 <Grid.Col key={urlView.url} span={12}> 61 - <SimilarUrlCard urlView={urlView} /> 63 + <SimilarUrlCard 64 + urlView={urlView} 65 + analyticsContext={{ 66 + saveSource: CardSaveSource.SIMILAR_CARDS, 67 + activeFilters: { 68 + urlType: selectedUrlType, 69 + }, 70 + pagePath: pathname, 71 + }} 72 + /> 62 73 </Grid.Col> 63 74 ))} 64 75 </Grid>
+26
src/webapp/hooks/useAuth.tsx
··· 7 7 import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; 8 8 import { verifySessionOnClient } from '@/lib/auth/dal'; 9 9 import { usePathname } from 'next/navigation'; 10 + import posthog from 'posthog-js'; 11 + import { isInternalUser, isEarlyTester } from '@/lib/userLists'; 12 + import { shouldCaptureAnalytics } from '@/features/analytics/utils'; 10 13 11 14 const ENABLE_AUTH_LOGGING = true; 12 15 ··· 33 36 if (ENABLE_AUTH_LOGGING) { 34 37 console.log('[useAuth] Initiating logout process'); 35 38 } 39 + 40 + // Reset PostHog user identity 41 + if (shouldCaptureAnalytics()) { 42 + posthog.reset(); 43 + if (ENABLE_AUTH_LOGGING) { 44 + console.log('[useAuth] PostHog user identity reset'); 45 + } 46 + } 47 + 36 48 await ClientCookieAuthService.clearTokens(); 37 49 queryClient.clear(); 38 50 router.push('/login'); ··· 53 65 // Handle other auth errors 54 66 if (query.isError && !query.isLoading && pathname !== '/') logout(); 55 67 }, [query.data, query.isError, query.isLoading, pathname, router, logout]); 68 + 69 + // Set super properties for anonymous tracking (no PII) 70 + useEffect(() => { 71 + const user = query.data; 72 + 73 + // Set super properties when analytics is enabled and user is available 74 + // These properties are included in all events automatically 75 + if (shouldCaptureAnalytics() && user) { 76 + posthog.register({ 77 + is_internal: isInternalUser(user.handle), 78 + is_early_tester: isEarlyTester(user.handle), 79 + }); 80 + } 81 + }, [query.data]); 56 82 57 83 const contextValue: AuthContextType = { 58 84 user: query.data ?? null,
+2 -18
src/webapp/lib/serverFeatureFlags.ts
··· 1 1 import { getServerAuthStatus } from './serverAuth'; 2 - 3 - const APPROVED_HANDLES = new Set([ 4 - 'wesleyfinck.org', 5 - 'ronentk.me', 6 - 'pouriade.com', 7 - 'bmann.ca', 8 - 'tynanpurdy.com', 9 - 'erlend.sh', 10 - 'tgoerke.bsky.social', 11 - 'psingletary.com', 12 - 'hilarybaumann.com', 13 - 'cosmik.network', 14 - 'semble.so', 15 - 'atproto.science', 16 - 'chrisshank.com', 17 - 'jasmine-pyz.bsky.social', 18 - ]); 2 + import { INTERNAL_HANDLES } from './userLists'; 19 3 20 4 export async function getServerFeatureFlags() { 21 5 const { user } = await getServerAuthStatus(); 22 6 23 7 const show = 24 8 process.env.VERCEL_ENV !== 'production' || 25 - (user?.handle && APPROVED_HANDLES.has(user.handle)); 9 + (user?.handle && INTERNAL_HANDLES.includes(user.handle)); 26 10 27 11 return { 28 12 cardSearch: show,
+44
src/webapp/lib/userLists.ts
··· 1 + /** 2 + * Shared user lists for feature flags and analytics 3 + */ 4 + 5 + export const INTERNAL_HANDLES = [ 6 + 'wesleyfinck.org', 7 + 'ronentk.me', 8 + 'pouriade.com', 9 + 'cosmik.network', 10 + 'semble.so', 11 + 'jasmine-pyz.bsky.social', 12 + 'cosmiktesting.bsky.social', 13 + 'sembot.bsky.social', 14 + 'alice.bsky.social', 15 + 'bob.bsky.social', 16 + ]; 17 + 18 + export const EARLY_TESTER_HANDLES = [ 19 + // Add early tester handles here 20 + // Currently using same as internal for testing 21 + ...INTERNAL_HANDLES, 22 + 'bmann.ca', 23 + 'tynanpurdy.com', 24 + 'erlend.sh', 25 + 'tgoerke.bsky.social', 26 + 'psingletary.com', 27 + 'hilarybaumann.com', 28 + 'atproto.science', 29 + 'chrisshank.com', 30 + ]; 31 + 32 + /** 33 + * Check if a handle is an internal user 34 + */ 35 + export function isInternalUser(handle?: string): boolean { 36 + return handle ? INTERNAL_HANDLES.includes(handle) : false; 37 + } 38 + 39 + /** 40 + * Check if a handle is an early tester 41 + */ 42 + export function isEarlyTester(handle?: string): boolean { 43 + return handle ? EARLY_TESTER_HANDLES.includes(handle) : false; 44 + }
+35 -9
src/webapp/providers/index.tsx
··· 1 1 'use client'; 2 2 3 3 import { AuthProvider } from '@/hooks/useAuth'; 4 + import { useEffect } from 'react'; 4 5 import MantineProvider from './mantine'; 5 6 import TanStackQueryProvider from './tanstack'; 6 7 import { NavbarProvider } from './navbar'; 7 8 import { SettingsProvider } from './settings'; 9 + import posthog from 'posthog-js'; 10 + import { PostHogProvider } from 'posthog-js/react'; 8 11 9 12 interface Props { 10 13 children: React.ReactNode; 11 14 } 12 15 13 16 export default function Providers(props: Props) { 17 + useEffect(() => { 18 + const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_API_KEY; 19 + 20 + if (!posthogKey) { 21 + console.warn('PostHog key not found in environment variables'); 22 + return; 23 + } 24 + if (process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production') { 25 + return; 26 + } 27 + 28 + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_API_KEY!, { 29 + api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST, 30 + disable_session_recording: true, 31 + autocapture: false, 32 + capture_pageview: false, 33 + capture_pageleave: false, 34 + person_profiles: 'identified_only', 35 + }); 36 + }, []); 37 + 14 38 return ( 15 - <TanStackQueryProvider> 16 - <AuthProvider> 17 - <MantineProvider> 18 - <SettingsProvider> 19 - <NavbarProvider>{props.children}</NavbarProvider> 20 - </SettingsProvider> 21 - </MantineProvider> 22 - </AuthProvider> 23 - </TanStackQueryProvider> 39 + <PostHogProvider client={posthog}> 40 + <TanStackQueryProvider> 41 + <AuthProvider> 42 + <MantineProvider> 43 + <SettingsProvider> 44 + <NavbarProvider>{props.children}</NavbarProvider> 45 + </SettingsProvider> 46 + </MantineProvider> 47 + </AuthProvider> 48 + </TanStackQueryProvider> 49 + </PostHogProvider> 24 50 ); 25 51 }