diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/.env.example b/apps/basic-integration/react-native/expo-react-native-hacker-news/.env.example new file mode 100644 index 000000000..64bb8202d --- /dev/null +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/.env.example @@ -0,0 +1,2 @@ +EXPO_PUBLIC_POSTHOG_KEY=phc_your_project_token_here +EXPO_PUBLIC_POSTHOG_HOST=https://your-posthog-host diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/.gitignore b/apps/basic-integration/react-native/expo-react-native-hacker-news/.gitignore index 3b54389b9..5a5bc181c 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/.gitignore +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/.gitignore @@ -20,4 +20,5 @@ expo-env.d.ts # @end expo-cli ios/ -android/ \ No newline at end of file +android/ +.env diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/app.config.js b/apps/basic-integration/react-native/expo-react-native-hacker-news/app.config.js new file mode 100644 index 000000000..1d660a716 --- /dev/null +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/app.config.js @@ -0,0 +1,10 @@ +const appConfig = require("./app.json"); + +module.exports = () => ({ + ...appConfig.expo, + extra: { + ...appConfig.expo.extra, + posthogKey: process.env.EXPO_PUBLIC_POSTHOG_KEY, + posthogHost: process.env.EXPO_PUBLIC_POSTHOG_HOST, + }, +}); diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/app/[itemId].tsx b/apps/basic-integration/react-native/expo-react-native-hacker-news/app/[itemId].tsx index 58facb9f0..cb79ae29a 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/app/[itemId].tsx +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/app/[itemId].tsx @@ -18,6 +18,7 @@ import { parseTitle } from "@/lib/text"; import { Colors } from "@/constants/Colors"; import { Comments } from "@/components/comments/comments"; import { getItemDetailsQueryKey, getItemQueryFn } from "@/constants/item"; +import { posthog } from "@/lib/posthog"; export default function ItemDetails() { const { itemId } = useLocalSearchParams(); @@ -72,7 +73,12 @@ export default function ItemDetails() { marginBottom: typeof item.text === "string" ? 0 : 24, }} > - router.push(`/users/${item.by}`)}> + { + posthog?.capture("profile_opened", { source: "story_details" }); + router.push(`/users/${item.by}`); + }} + > { + posthog?.capture("story_link_opened", { + source: "story_details", + }); Linking.openURL(item.url); }} > @@ -194,7 +203,10 @@ export default function ItemDetails() { gap: 4, marginBottom: 24, }} - onPress={() => router.push(`../${parentItem.id}`)} + onPress={() => { + posthog?.capture("parent_thread_opened"); + router.push(`../${parentItem.id}`); + }} > ( + + ), + headerTintColor: "#f1f1f1", + headerBackButtonDisplayMode: "minimal", + headerStyle: { + backgroundColor: Colors.accent, + }, + }} + /> + ); return ( <> - ( - - ), - headerTintColor: "#f1f1f1", - headerBackButtonDisplayMode: "minimal", - headerStyle: { - backgroundColor: Colors.accent, - }, - }} - /> + {posthog ? ( + {stack} + ) : ( + stack + )} diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/Select.tsx b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/Select.tsx index e596420cd..414c01218 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/Select.tsx +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/Select.tsx @@ -13,6 +13,7 @@ import { ListFilter, LucideIcon } from "lucide-react-native"; import { Colors } from "@/constants/Colors"; import { StoryType } from "@/constants/stories"; +import { posthog } from "@/lib/posthog"; export type Option = { id: StoryType; @@ -86,6 +87,9 @@ export const StoriesSelect = ({ key={item.id} style={[styles.option, isSelected && styles.optionSelected]} onPress={() => { + posthog?.capture("story_feed_selected", { + story_type: item.id, + }); onChange(item.id); setIsOpen(false); }} diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/comments/comment.tsx b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/comments/comment.tsx index 2dedaa415..7e001965e 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/comments/comment.tsx +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/comments/comment.tsx @@ -16,6 +16,7 @@ import { MessageSquareText } from "lucide-react-native"; import type { Item } from "@/shared/types"; import { Colors } from "@/constants/Colors"; import { getItemDetailsQueryKey, getItemQueryFn } from "@/constants/item"; +import { posthog } from "@/lib/posthog"; export const Comment = (item: Item) => { const QC = useQueryClient(); @@ -33,7 +34,10 @@ export const Comment = (item: Item) => { router.push(`/users/${item.by}`)} + onPress={() => { + posthog?.capture("profile_opened", { source: "comment" }); + router.push(`/users/${item.by}`); + }} > { { + posthog?.capture("story_details_opened", { + entry_point: "comment_replies", + }); await QC.prefetchQuery({ queryKey: getItemDetailsQueryKey(item.id), queryFn: getItemQueryFn, diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Post.tsx b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Post.tsx index 6997b06af..26eb9cef9 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Post.tsx +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Post.tsx @@ -14,6 +14,7 @@ import { Link2, MessageSquareText } from "lucide-react-native"; import type { Item } from "@/shared/types"; import { getItemDetailsQueryKey, getItemQueryFn } from "@/constants/item"; +import { posthog } from "@/lib/posthog"; export const Post = ({ id, title, url, score, text, kids }: Item) => { const QC = useQueryClient(); @@ -22,7 +23,10 @@ export const Post = ({ id, title, url, score, text, kids }: Item) => { return text === undefined; }, [text]); - const navigateToDetails = async () => { + const navigateToDetails = async ( + entryPoint: "story_title" | "comments_button" + ) => { + posthog?.capture("story_details_opened", { entry_point: entryPoint }); await QC.prefetchQuery({ queryKey: getItemDetailsQueryKey(id), queryFn: getItemQueryFn, @@ -34,8 +38,12 @@ export const Post = ({ id, title, url, score, text, kids }: Item) => { { - if (isExternal) Linking.openURL(url); - else await navigateToDetails(); + if (isExternal) { + posthog?.capture("story_link_opened", { source: "story_title" }); + Linking.openURL(url); + } else { + await navigateToDetails("story_title"); + } }} > @@ -66,7 +74,7 @@ export const Post = ({ id, title, url, score, text, kids }: Item) => { { - await navigateToDetails(); + await navigateToDetails("comments_button"); }} > @@ -86,6 +94,7 @@ export const Post = ({ id, title, url, score, text, kids }: Item) => { { + posthog?.capture("story_link_opened", { source: "link_button" }); Linking.openURL(url); }} > diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Posts.tsx b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Posts.tsx index c6b7d73b5..83940faed 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Posts.tsx +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/components/posts/Posts.tsx @@ -8,6 +8,7 @@ import { Spinner } from "@/components/Spinner"; import type { Item } from "@/shared/types"; import { getItemDetails } from "@/api/endpoints"; import { ITEMS_PER_PAGE } from "@/constants/pagination"; +import { posthogLogs } from "@/lib/posthog-logs"; import { type StoryType, MAP_STORY_TYPE_TO_STORY_ENDPOINTS, @@ -29,11 +30,27 @@ export const Posts = ({ storyType }: { storyType: StoryType }) => { const storyListQuery = useQuery({ queryKey: ["storyIds", storyType], queryFn: async () => { - const getItemIds = MAP_STORY_TYPE_TO_STORY_ENDPOINTS[storyType]; - const res = await getItemIds(); - const topStories = await res.json(); + const startedAt = Date.now(); - return topStories; + try { + const getItemIds = MAP_STORY_TYPE_TO_STORY_ENDPOINTS[storyType]; + const res = await getItemIds(); + const topStories = await res.json(); + + posthogLogs.info("story feed loaded", { + story_type: storyType, + story_count: topStories.length, + duration_ms: Date.now() - startedAt, + }); + + return topStories; + } catch (error) { + posthogLogs.error("story feed load failed", { + story_type: storyType, + duration_ms: Date.now() - startedAt, + }); + throw error; + } }, }); @@ -42,18 +59,36 @@ export const Posts = ({ storyType }: { storyType: StoryType }) => { queryFn: async ({ pageParam = 0 }) => { if (!storyListQuery.data) return []; + const startedAt = Date.now(); const pageIds = storyListQuery.data.slice( pageParam, pageParam + ITEMS_PER_PAGE ); - const detailsResponses = await Promise.all( - pageIds.map((id) => getItemDetails(id)) - ); - const posts = await Promise.all( - detailsResponses.map((res) => res.json()) - ); - return posts; + try { + const detailsResponses = await Promise.all( + pageIds.map((id) => getItemDetails(id)) + ); + const posts = await Promise.all( + detailsResponses.map((res) => res.json()) + ); + + posthogLogs.info("story page loaded", { + story_type: storyType, + page_offset: pageParam, + story_count: posts.length, + duration_ms: Date.now() - startedAt, + }); + + return posts; + } catch (error) { + posthogLogs.error("story page load failed", { + story_type: storyType, + page_offset: pageParam, + duration_ms: Date.now() - startedAt, + }); + throw error; + } }, getNextPageParam: (lastPage, allPages) => { if (!storyListQuery.data) return undefined; diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/lib/posthog-logs.ts b/apps/basic-integration/react-native/expo-react-native-hacker-news/lib/posthog-logs.ts new file mode 100644 index 000000000..72e27d227 --- /dev/null +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/lib/posthog-logs.ts @@ -0,0 +1,10 @@ +import { posthog } from "@/lib/posthog"; + +type LogAttributes = Record; + +export const posthogLogs = { + info: (message: string, attributes?: LogAttributes) => + posthog?.logger.info(message, attributes), + error: (message: string, attributes?: LogAttributes) => + posthog?.logger.error(message, attributes), +}; diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/lib/posthog.ts b/apps/basic-integration/react-native/expo-react-native-hacker-news/lib/posthog.ts new file mode 100644 index 000000000..58cdfc02b --- /dev/null +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/lib/posthog.ts @@ -0,0 +1,35 @@ +import Constants from "expo-constants"; +import PostHog from "posthog-react-native"; + +const extra = Constants.expoConfig?.extra; +const projectToken = extra?.posthogKey as string | undefined; +const host = extra?.posthogHost as string | undefined; + +if (__DEV__ && !projectToken) { + console.error( + "EXPO_PUBLIC_POSTHOG_KEY variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once EXPO_PUBLIC_POSTHOG_KEY is configured", + ); +} + +if (__DEV__ && !host) { + console.error( + "EXPO_PUBLIC_POSTHOG_HOST variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once EXPO_PUBLIC_POSTHOG_HOST is configured", + ); +} + +export const posthog = + projectToken && host + ? new PostHog(projectToken, { + host, + captureAppLifecycleEvents: true, + logs: { + serviceName: "hacker-native", + }, + errorTracking: { + autocapture: { + uncaughtExceptions: true, + unhandledRejections: true, + }, + }, + }) + : undefined; diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/package-lock.json b/apps/basic-integration/react-native/expo-react-native-hacker-news/package-lock.json index 7fa6ab26a..8568a40f4 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/package-lock.json +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/package-lock.json @@ -26,6 +26,7 @@ "expo-web-browser": "~15.0.10", "html-entities": "^2.5.2", "lucide-react-native": "^0.563.0", + "posthog-react-native": "^4.84.3", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -2983,6 +2984,21 @@ "node": ">=12.4.0" } }, + "node_modules/@posthog/core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.61.1.tgz", + "integrity": "sha512-qyMTxPfjeJkHIhpcukOgBN3BWXbCyFRPz8/Voy5qZtVh9NZqj3EhC6LLeYZuJhL3WpqhQTgn2FMIwAo/ag6fmw==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.417.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.418.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.418.0.tgz", + "integrity": "sha512-WqDXUWH1R8OilBp2PfDpQIRZSCs3QyOnw7H3YK7lvZLCMIsOqSZAKJrrqYWt4f7FvnVCACRuNGCC0HKh3J/HWw==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -12580,6 +12596,79 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/posthog-react-native": { + "version": "4.84.3", + "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.84.3.tgz", + "integrity": "sha512-DDbDelmEJs7zUVfN6blo8KMazjsmo/6+Lhq0OUiUud706cxh0hhkidOUCJO7tSanyYXJpx2FpEL9N3mhIiTEsQ==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.61.1", + "@posthog/types": "^1.418.0" + }, + "bin": { + "posthog-react-native-xcode": "tooling/posthog-xcode.sh" + }, + "peerDependencies": { + "@posthog/react-native-plugin": ">= 2.12.7", + "@react-native-async-storage/async-storage": ">=1.0.0", + "@react-navigation/native": ">= 5.0.0", + "expo-application": ">= 4.0.0", + "expo-device": ">= 4.0.0", + "expo-file-system": ">= 13.0.0", + "expo-localization": ">= 11.0.0", + "expo-updates": ">= 0.25.0", + "posthog-react-native-session-replay": ">= 1.6.0", + "react-native-device-info": ">= 10.0.0", + "react-native-localize": ">= 3.0.0", + "react-native-navigation": ">= 6.0.0", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-svg": ">= 15.0.0" + }, + "peerDependenciesMeta": { + "@posthog/react-native-plugin": { + "optional": true + }, + "@react-native-async-storage/async-storage": { + "optional": true + }, + "@react-navigation/native": { + "optional": true + }, + "expo-application": { + "optional": true + }, + "expo-device": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-localization": { + "optional": true + }, + "expo-updates": { + "optional": true + }, + "posthog-react-native-session-replay": { + "optional": true + }, + "react-native-device-info": { + "optional": true + }, + "react-native-localize": { + "optional": true + }, + "react-native-navigation": { + "optional": true + }, + "react-native-safe-area-context": { + "optional": true + }, + "react-native-svg": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/apps/basic-integration/react-native/expo-react-native-hacker-news/package.json b/apps/basic-integration/react-native/expo-react-native-hacker-news/package.json index aab51bf6c..416e81274 100644 --- a/apps/basic-integration/react-native/expo-react-native-hacker-news/package.json +++ b/apps/basic-integration/react-native/expo-react-native-hacker-news/package.json @@ -35,6 +35,7 @@ "expo-web-browser": "~15.0.10", "html-entities": "^2.5.2", "lucide-react-native": "^0.563.0", + "posthog-react-native": "^4.84.3", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5",