diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbc1de31f4..b49876f064 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1 @@ -https://char.com/docs/developers +https://char.com/docs/developers?utm_source=github&utm_medium=contributing&utm_campaign=organic diff --git a/README.md b/README.md index 0f7cb5e334..9b671c1432 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ You can also use it for taking notes for lectures or organizing your thoughts. brew install --cask fastrepl/fastrepl/char ``` -- [macOS](https://char.com/download) (public beta) +- [macOS](https://char.com/download?utm_source=github&utm_medium=readme&utm_campaign=organic) (public beta) - [Windows](https://github.com/fastrepl/char/issues/66) (q2 2026) - [Linux](https://github.com/fastrepl/char/issues/67) (q2 2026) @@ -74,7 +74,7 @@ Char plays nice with whatever stack you're running. Prefer a certain style? Choose from predefined templates like bullet points, agenda-based, or paragraph summary. Or create your own. -Check out our [template gallery](https://char.com/templates) and add your own [here](https://github.com/fastrepl/char/tree/main/apps/web/content/templates). +Check out our [template gallery](https://char.com/templates?utm_source=github&utm_medium=readme&utm_campaign=organic) and add your own [here](https://github.com/fastrepl/char/tree/main/apps/web/content/templates). ### AI Chat diff --git a/apps/desktop/src/auth/context.tsx b/apps/desktop/src/auth/context.tsx index 9868de540b..d793c6fe5b 100644 --- a/apps/desktop/src/auth/context.tsx +++ b/apps/desktop/src/auth/context.tsx @@ -54,6 +54,7 @@ type AuthTokenHandlers = { setSessionFromTokens: ( accessToken: string, refreshToken: string, + opts?: { webDistinctId?: string }, ) => Promise; }; @@ -118,6 +119,7 @@ async function initSession( let trackedIdentifySignature: string | null = null; let trackedSignedInUserId: string | null = null; +let pendingWebDistinctId: string | null = null; async function getBillingAnalytics(accessToken: string) { const result = await authPluginCommands.decodeClaims(accessToken); @@ -152,6 +154,11 @@ async function trackAuthEvent( event === "TOKEN_REFRESHED") && session ) { + if (pendingWebDistinctId) { + void analyticsCommands.alias(pendingWebDistinctId); + pendingWebDistinctId = null; + } + const appVersion = await getVersion(); const billing = await getBillingAnalytics(session.access_token); const identifySignature = JSON.stringify({ @@ -188,6 +195,7 @@ async function trackAuthEvent( if (event === "SIGNED_OUT") { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingWebDistinctId = null; } } @@ -206,18 +214,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const setSessionFromTokens = useCallback( - async (accessToken: string, refreshToken: string) => { + async ( + accessToken: string, + refreshToken: string, + opts?: { webDistinctId?: string }, + ) => { if (!supabase) { console.error("Supabase client not found"); return; } + pendingWebDistinctId = opts?.webDistinctId?.trim() || null; + const res = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken, }); if (res.error) { + pendingWebDistinctId = null; console.error(res.error); } else { setSession(res.data.session); @@ -231,13 +246,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const parsed = new URL(url); const accessToken = parsed.searchParams.get("access_token"); const refreshToken = parsed.searchParams.get("refresh_token"); + const webDistinctId = parsed.searchParams.get("web_distinct_id"); if (!accessToken || !refreshToken) { console.error("invalid_callback_url"); return; } - await setSessionFromTokens(accessToken, refreshToken); + await setSessionFromTokens(accessToken, refreshToken, { + webDistinctId: webDistinctId ?? undefined, + }); }, [setSessionFromTokens], ); @@ -338,6 +356,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ) { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingWebDistinctId = null; await clearAuthStorage(); setSession(null); return; @@ -348,6 +367,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingWebDistinctId = null; await clearAuthStorage(); setSession(null); } catch (e) { @@ -357,6 +377,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ) { trackedIdentifySignature = null; trackedSignedInUserId = null; + pendingWebDistinctId = null; await clearAuthStorage(); setSession(null); } diff --git a/apps/desktop/src/calendar/components/shared.tsx b/apps/desktop/src/calendar/components/shared.tsx index 4c197e71a4..4d640c5c2e 100644 --- a/apps/desktop/src/calendar/components/shared.tsx +++ b/apps/desktop/src/calendar/components/shared.tsx @@ -2,6 +2,7 @@ import { Icon } from "@iconify-icon/react"; import type { ReactNode } from "react"; import { OutlookIcon } from "@hypr/ui/components/icons/outlook"; +import { withCharUtm } from "@hypr/utils"; export type CalendarProvider = { disabled: boolean; @@ -28,7 +29,10 @@ const _PROVIDERS = [ /> ), platform: "macos", - docsPath: "https://char.com/docs/calendar/apple", + docsPath: withCharUtm("https://char.com/docs/calendar/apple", { + source: "app", + medium: "settings", + }), nangoIntegrationId: undefined, }, { @@ -38,7 +42,10 @@ const _PROVIDERS = [ badge: "Beta", icon: , platform: "all", - docsPath: "https://char.com/docs/calendar/gcal", + docsPath: withCharUtm("https://char.com/docs/calendar/gcal", { + source: "app", + medium: "settings", + }), nangoIntegrationId: "google-calendar", }, { @@ -48,7 +55,10 @@ const _PROVIDERS = [ badge: "Beta", icon: , platform: "all", - docsPath: "https://char.com/docs/calendar/outlook", + docsPath: withCharUtm("https://char.com/docs/calendar/outlook", { + source: "app", + medium: "settings", + }), nangoIntegrationId: "outlook", }, ] as const satisfies readonly CalendarProvider[]; diff --git a/apps/desktop/src/changelog/index.tsx b/apps/desktop/src/changelog/index.tsx index e44d3fbd41..bdf981f615 100644 --- a/apps/desktop/src/changelog/index.tsx +++ b/apps/desktop/src/changelog/index.tsx @@ -11,7 +11,7 @@ import { BreadcrumbSeparator, } from "@hypr/ui/components/ui/breadcrumb"; import { Button } from "@hypr/ui/components/ui/button"; -import { safeFormat } from "@hypr/utils"; +import { safeFormat, withCharUtm } from "@hypr/utils"; import { useChangelogContent } from "./data"; @@ -182,7 +182,10 @@ function ChangelogHeader({ const formattedDate = date ? safeFormat(date, "MMM d, yyyy") : null; const webUrl = isNightly(version) ? githubReleaseUrl(version) - : `https://char.com/changelog/${version}`; + : withCharUtm(`https://char.com/changelog/${version}`, { + source: "app", + medium: "changelog", + }); return (
diff --git a/apps/desktop/src/settings/ai/llm/shared.tsx b/apps/desktop/src/settings/ai/llm/shared.tsx index 6d83418192..b2c92a18b0 100644 --- a/apps/desktop/src/settings/ai/llm/shared.tsx +++ b/apps/desktop/src/settings/ai/llm/shared.tsx @@ -11,6 +11,8 @@ import { } from "@lobehub/icons"; import type { ReactNode } from "react"; +import { withCharUtm } from "@hypr/utils"; + import { env } from "~/env"; import { CharProviderIcon } from "~/settings/ai/shared"; import { @@ -61,7 +63,13 @@ const _PROVIDERS = [ models: { label: "Available models", url: "https://lmstudio.ai/models" }, setup: { label: "Setup guide", - url: "https://char.com/docs/faq/local-llm-setup/#lm-studio-setup", + url: withCharUtm( + "https://char.com/docs/faq/local-llm-setup/#lm-studio-setup", + { + source: "app", + medium: "settings", + }, + ), }, }, }, @@ -80,7 +88,13 @@ const _PROVIDERS = [ models: { label: "Available models", url: "https://ollama.com/library" }, setup: { label: "Setup guide", - url: "https://char.com/docs/faq/local-llm-setup/#ollama-setup", + url: withCharUtm( + "https://char.com/docs/faq/local-llm-setup/#ollama-setup", + { + source: "app", + medium: "settings", + }, + ), }, }, }, diff --git a/apps/desktop/src/settings/data/index.tsx b/apps/desktop/src/settings/data/index.tsx index 226d86178e..871eba2b68 100644 --- a/apps/desktop/src/settings/data/index.tsx +++ b/apps/desktop/src/settings/data/index.tsx @@ -9,6 +9,7 @@ import { type ImportSourceKind, type ImportStats, } from "@hypr/plugin-importer"; +import { withCharUtm } from "@hypr/utils"; import { ImportPreview } from "./import-preview"; import { SourceItem } from "./source-item"; @@ -23,6 +24,15 @@ type DryRunResult = { stats: ImportStats; }; +const importDocsUrl = withCharUtm("https://char.com/docs/data/#import", { + source: "app", + medium: "settings", +}); +const exportDocsUrl = withCharUtm("https://char.com/docs/data/#export", { + source: "app", + medium: "settings", +}); + export function Data() { const [dryRunResult, setDryRunResult] = useState(null); const [successfulSource, setSuccessfulSource] = @@ -101,9 +111,7 @@ export function Data() { return (
- { - "Import data from other apps. Read more about [import](https://char.com/docs/data/#import) and [export](https://char.com/docs/data/#export)." - } + {`Import data from other apps. Read more about [import](${importDocsUrl}) and [export](${exportDocsUrl}).`}
diff --git a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts index 619b69eaed..8434d3496d 100644 --- a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts +++ b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts @@ -35,9 +35,11 @@ export function useDeeplinkHandler() { const unlisten = deeplink2Events.deepLinkEvent.listen(({ payload }) => { if (payload.to === "/auth/callback") { - const { access_token, refresh_token } = payload.search; + const { access_token, refresh_token, web_distinct_id } = payload.search; if (access_token && refresh_token && auth) { - void auth.setSessionFromTokens(access_token, refresh_token); + void auth.setSessionFromTokens(access_token, refresh_token, { + webDistinctId: web_distinct_id ?? undefined, + }); } } else if (payload.to === "/billing/refresh") { if (auth) { diff --git a/apps/web/content/docs/developers/12.analytics.mdx b/apps/web/content/docs/developers/12.analytics.mdx index e59c34fb78..120d5ca27d 100644 --- a/apps/web/content/docs/developers/12.analytics.mdx +++ b/apps/web/content/docs/developers/12.analytics.mdx @@ -133,6 +133,37 @@ Notes: | `trial_skipped` | `reason = "not_eligible"`, `source` | `crates/api-subscription/src/trial.rs`, `crates/api-subscription/src/routes/billing.rs` | | `trial_failed` | `reason` (`stripe_error`, `customer_error`, `rpc_error`), `source` | `crates/api-subscription/src/trial.rs`, `crates/api-subscription/src/routes/billing.rs` | +## UTM parameters on owned links + +We tag selected `char.com` links with UTM parameters so PostHog can attribute visits and installs back to the place the click came from. + +### Convention + +| Parameter | Values | +|-----------|--------| +| `utm_source` | `github`, `app`, `website` | +| `utm_medium` | `contributing`, `readme`, `settings`, `changelog`, `blog`, `docs` | +| `utm_campaign` | `organic` | + +### Current coverage + +- GitHub docs links in `README.md` and `CONTRIBUTING.md` +- Desktop links opened from settings and changelog screens +- Blog article CTAs that point to the homepage or download pages +- Docs CTAs that point to tracked marketing pages such as `/founders` or `/download/...` + +### Rules + +- Add UTMs only on owned `char.com` destinations we want to attribute. +- Preserve existing query params and place UTMs before `#fragments`. +- Do not add UTMs to relative links, blog-to-blog cross-links, third-party links, API endpoints, or legacy onboarding assets in `crates/db-user`. + +### Why this matters + +- `download_clicked` can be segmented by where the session originated. +- Acquisition and install conversion are easier to break down by channel. +- `utm_source` and `utm_medium` answer which owned surfaces are actually driving visits. + ## User journey funnel The user lifecycle is divided into 8 stages. Each stage lists the analytics signals that measure it, how identity linking works at that point, and known gaps. diff --git a/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx b/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx index e70d929e8f..139954fe07 100644 --- a/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx +++ b/apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx @@ -17,9 +17,13 @@ We track the user lifecycle as an 8-stage funnel. Website acquisition uses conse [8. Paying] ``` +## UTM parameters + +Inbound traffic from GitHub, desktop CTAs, and website CTAs carries `utm_source`, `utm_medium`, and `utm_campaign=organic` so we can attribute visits and installs by surface. See the [developer analytics docs](/docs/developers/analytics#utm-parameters-on-owned-links) for the exact convention. + ## 1. Acquisition (website visits) -Standard consented website analytics tracking, plus PostHog pageview and session tracking. +Standard consented website analytics tracking, plus PostHog pageview and session tracking. PostHog also captures UTM parameters from the landing page URL for attributed sessions. ## 2. Converting visits to app installs diff --git a/apps/web/src/components/download-button.tsx b/apps/web/src/components/download-button.tsx index e5f2439b0c..50084e3305 100644 --- a/apps/web/src/components/download-button.tsx +++ b/apps/web/src/components/download-button.tsx @@ -4,6 +4,7 @@ import { cn } from "@hypr/utils"; import { usePlatform } from "@/hooks/use-platform"; import { useAnalytics } from "@/hooks/use-posthog"; +import { rememberDesktopAttributionDistinctId } from "@/lib/desktop-attribution"; export function DownloadButton({ variant = "default", @@ -11,7 +12,7 @@ export function DownloadButton({ variant?: "default" | "compact"; }) { const platform = usePlatform(); - const { track } = useAnalytics(); + const { track, getDistinctId } = useAnalytics(); const getPlatformData = () => { switch (platform) { @@ -45,9 +46,13 @@ export function DownloadButton({ const { icon, label, href } = getPlatformData(); const handleClick = () => { + const webDistinctId = getDistinctId(); + rememberDesktopAttributionDistinctId(webDistinctId); + track("download_clicked", { platform: platform, timestamp: new Date().toISOString(), + ...(webDistinctId ? { web_distinct_id: webDistinctId } : {}), }); }; diff --git a/apps/web/src/components/mdx/index.ts b/apps/web/src/components/mdx/index.ts index 20834c757e..f3fe66fe7f 100644 --- a/apps/web/src/components/mdx/index.ts +++ b/apps/web/src/components/mdx/index.ts @@ -2,7 +2,7 @@ export { Callout } from "./callout"; export { Clip } from "./clip"; export { CodeBlock } from "./code-block"; export { GithubEmbed } from "./github-embed"; -export { MDXLink } from "./link"; +export { createMDXLink, MDXLink } from "./link"; export { createMDXComponents, defaultMDXComponents } from "./mdx-components"; export { Mermaid } from "./mermaid"; export { Tweet } from "./tweet"; diff --git a/apps/web/src/components/mdx/link.tsx b/apps/web/src/components/mdx/link.tsx index d78a820828..0e0f50f297 100644 --- a/apps/web/src/components/mdx/link.tsx +++ b/apps/web/src/components/mdx/link.tsx @@ -1,62 +1,118 @@ import { Link } from "@tanstack/react-router"; -import { cn } from "@hypr/utils"; +import { cn, withCharUtm } from "@hypr/utils"; const linkClassName = "underline underline-offset-2 decoration-neutral-400 hover:decoration-neutral-600 transition-colors"; -export function MDXLink({ - href, - children, - className, - ...props -}: React.AnchorHTMLAttributes) { - if (!href) { - return {children}; +function normalizePathname(pathname: string) { + return pathname === "/" ? pathname : pathname.replace(/\/+$/, "") || "/"; +} + +function getTrackedWebsiteHref(href: string, utmMedium?: "blog" | "docs") { + if (!utmMedium) { + return href; } - const isHyprnoteUrl = href.startsWith("https://hyprnote.com"); - const isInternalPath = href.startsWith("/") || href.startsWith("."); - const isAnchor = href.startsWith("#"); + let url: URL; + try { + url = new URL(href); + } catch { + return href; + } - if (isHyprnoteUrl) { - const relativePath = href.replace("https://hyprnote.com", "") || "/"; - return ( - - {children} - - ); + if (!["char.com", "www.char.com"].includes(url.hostname)) { + return href; } - if (isAnchor) { - return ( - - {children} - - ); + const pathname = normalizePathname(url.pathname); + const shouldTrack = + utmMedium === "blog" + ? pathname === "/" || + pathname === "/download" || + pathname.startsWith("/download/") + : pathname === "/download" || + pathname.startsWith("/download/") || + pathname === "/founders"; + + if (!shouldTrack) { + return href; } - if (isInternalPath) { + return withCharUtm(href, { source: "website", medium: utmMedium }); +} + +export function createMDXLink({ + utmMedium, +}: { + utmMedium?: "blog" | "docs"; +} = {}) { + return function MDXLink({ + href, + children, + className, + ...props + }: React.AnchorHTMLAttributes) { + if (!href) { + return {children}; + } + + const resolvedHref = getTrackedWebsiteHref(href, utmMedium); + const isHyprnoteUrl = resolvedHref.startsWith("https://hyprnote.com"); + const isInternalPath = + resolvedHref.startsWith("/") || resolvedHref.startsWith("."); + const isAnchor = resolvedHref.startsWith("#"); + + if (isHyprnoteUrl) { + const relativePath = + resolvedHref.replace("https://hyprnote.com", "") || "/"; + return ( + + {children} + + ); + } + + if (isAnchor) { + return ( + + {children} + + ); + } + + if (isInternalPath) { + return ( + + {children} + + ); + } + return ( - + {children} - + ); - } - - return ( - - {children} - - ); + }; } + +export const MDXLink = createMDXLink(); diff --git a/apps/web/src/hooks/use-posthog.ts b/apps/web/src/hooks/use-posthog.ts index be6f485d9a..8117863283 100644 --- a/apps/web/src/hooks/use-posthog.ts +++ b/apps/web/src/hooks/use-posthog.ts @@ -29,6 +29,23 @@ export function useAnalytics() { [posthog], ); + const alias = useCallback( + (aliasId: string, originalId?: string) => { + if (!posthog) { + return; + } + posthog.alias(aliasId, originalId); + }, + [posthog], + ); + + const getDistinctId = useCallback(() => { + if (!posthog) { + return null; + } + return posthog.get_distinct_id(); + }, [posthog]); + const reset = useCallback(() => { if (!posthog) { return; @@ -39,6 +56,8 @@ export function useAnalytics() { return { track, identify, + alias, + getDistinctId, reset, posthog, }; diff --git a/apps/web/src/lib/desktop-attribution.ts b/apps/web/src/lib/desktop-attribution.ts new file mode 100644 index 0000000000..b0f115ca58 --- /dev/null +++ b/apps/web/src/lib/desktop-attribution.ts @@ -0,0 +1,72 @@ +const STORAGE_KEY = "char_desktop_attribution_v1"; +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +function readStoredDistinctId() { + if (typeof window === "undefined") { + return null; + } + + const rawValue = window.localStorage.getItem(STORAGE_KEY); + if (!rawValue) { + return null; + } + + try { + const parsedValue = JSON.parse(rawValue) as Partial<{ + distinctId: string; + savedAt: number; + }>; + + if ( + typeof parsedValue.distinctId !== "string" || + typeof parsedValue.savedAt !== "number" + ) { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } + + if (Date.now() - parsedValue.savedAt > MAX_AGE_MS) { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } + + return parsedValue.distinctId; + } catch { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } +} + +export function rememberDesktopAttributionDistinctId( + distinctId: string | null | undefined, +) { + if (typeof window === "undefined" || !distinctId) { + return; + } + + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + distinctId, + savedAt: Date.now(), + }), + ); +} + +export function getDesktopAttributionDistinctId( + currentDistinctId: string | null | undefined, +) { + return readStoredDistinctId() ?? currentDistinctId ?? null; +} + +export function getDesktopAttributionAliasCandidates( + currentDistinctId: string | null | undefined, +) { + return [ + ...new Set( + [readStoredDistinctId(), currentDistinctId].filter( + (distinctId): distinctId is string => Boolean(distinctId), + ), + ), + ]; +} diff --git a/apps/web/src/routes/_view/blog/$slug.tsx b/apps/web/src/routes/_view/blog/$slug.tsx index 5f1c4267e3..f9bce1d264 100644 --- a/apps/web/src/routes/_view/blog/$slug.tsx +++ b/apps/web/src/routes/_view/blog/$slug.tsx @@ -8,7 +8,7 @@ import { cn } from "@hypr/utils"; import { AcquisitionLinkGrid } from "@/components/acquisition-link-grid"; import { CTASection } from "@/components/cta-section"; -import { defaultMDXComponents } from "@/components/mdx"; +import { createMDXComponents, createMDXLink } from "@/components/mdx"; import { useBlogToc } from "@/hooks/use-blog-toc"; import { CHAR_SITE_URL, @@ -18,6 +18,10 @@ import { } from "@/lib/seo"; import { AUTHOR_AVATARS } from "@/lib/team"; +const blogMDXComponents = createMDXComponents({ + a: createMDXLink({ utmMedium: "blog" }), +}); + export const Route = createFileRoute("/_view/blog/$slug")({ component: Component, loader: async ({ params }) => { @@ -209,7 +213,7 @@ function ArticleContent({ article }: { article: any }) { return (
- +
); diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index 41d0415387..38b950494b 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -12,6 +12,10 @@ import { CharLogo } from "@/components/sidebar"; import { exchangeOAuthCode, exchangeOtpToken } from "@/functions/auth"; import { desktopSchemeSchema } from "@/functions/desktop-flow"; import { useAnalytics } from "@/hooks/use-posthog"; +import { + getDesktopAttributionAliasCandidates, + getDesktopAttributionDistinctId, +} from "@/lib/desktop-attribution"; const validateSearch = z.object({ code: z.string().optional(), @@ -31,6 +35,7 @@ const validateSearch = z.object({ redirect: z.string().optional(), access_token: z.string().optional(), refresh_token: z.string().optional(), + web_distinct_id: z.string().optional(), error: z.string().optional(), error_code: z.string().optional(), error_description: z.string().optional(), @@ -186,7 +191,11 @@ function Header({ title }: { title: string }) { function Component() { const search = Route.useSearch(); const navigate = useNavigate(); - const { identify: identifyPosthog } = useAnalytics(); + const { + alias: aliasPosthog, + identify: identifyPosthog, + getDistinctId, + } = useAnalytics(); const [copied, setCopied] = useState(false); useEffect(() => { @@ -198,6 +207,15 @@ function Component() { const userId = payload.sub; if (userId) { + const currentDistinctId = getDistinctId(); + const aliasCandidates = + getDesktopAttributionAliasCandidates(currentDistinctId); + for (const distinctId of aliasCandidates) { + if (distinctId !== userId) { + aliasPosthog(userId, distinctId); + } + } + const billing = deriveBillingInfo(payload); identifyPosthog(userId, { ...(email ? { email } : {}), @@ -208,13 +226,19 @@ function Component() { } catch (e) { console.error("Failed to decode JWT for identify:", e); } - }, [search.access_token, identifyPosthog]); + }, [aliasPosthog, getDistinctId, identifyPosthog, search.access_token]); const getDeeplink = () => { if (search.access_token && search.refresh_token) { const params = new URLSearchParams(); params.set("access_token", search.access_token); params.set("refresh_token", search.refresh_token); + const webDistinctId = + search.web_distinct_id ?? + getDesktopAttributionDistinctId(getDistinctId()); + if (webDistinctId) { + params.set("web_distinct_id", webDistinctId); + } return `${search.scheme}://auth/callback?${params.toString()}`; } return null; diff --git a/apps/web/src/routes/_view/docs/-components.tsx b/apps/web/src/routes/_view/docs/-components.tsx index d932ceb17e..fc2069451e 100644 --- a/apps/web/src/routes/_view/docs/-components.tsx +++ b/apps/web/src/routes/_view/docs/-components.tsx @@ -4,11 +4,15 @@ import { allDocs } from "content-collections"; import { useMemo } from "react"; import { AcquisitionLinkGrid } from "@/components/acquisition-link-grid"; -import { defaultMDXComponents } from "@/components/mdx"; +import { createMDXComponents, createMDXLink } from "@/components/mdx"; import { TableOfContents } from "@/components/table-of-contents"; import { docsStructure } from "./-structure"; +const docsMDXComponents = createMDXComponents({ + a: createMDXLink({ utmMedium: "docs" }), +}); + export function DocLayout({ doc, showSectionTitle = true, @@ -95,7 +99,7 @@ function ArticleHeader({ function ArticleContent({ doc }: { doc: any }) { return (
- +
); } diff --git a/apps/web/src/routes/_view/download/index.tsx b/apps/web/src/routes/_view/download/index.tsx index d812b69284..dfd2a398ad 100644 --- a/apps/web/src/routes/_view/download/index.tsx +++ b/apps/web/src/routes/_view/download/index.tsx @@ -6,6 +6,7 @@ import { cn } from "@hypr/utils"; import { Image } from "@/components/image"; import { useAnalytics } from "@/hooks/use-posthog"; +import { rememberDesktopAttributionDistinctId } from "@/lib/desktop-attribution"; export const Route = createFileRoute("/_view/download/")({ component: Component, @@ -105,14 +106,18 @@ function DownloadCard({ nightlyDownloadUrl: string; platform: string; }) { - const { track } = useAnalytics(); + const { track, getDistinctId } = useAnalytics(); const [isNightly, setIsNightly] = useState(false); const handleClick = () => { + const webDistinctId = getDistinctId(); + rememberDesktopAttributionDistinctId(webDistinctId); + track("download_clicked", { platform: isNightly ? `${platform}-nightly` : platform, spec, source: "download_page", + ...(webDistinctId ? { web_distinct_id: webDistinctId } : {}), }); }; diff --git a/crates/analytics/src/lib.rs b/crates/analytics/src/lib.rs index 9049190644..56d82b8427 100644 --- a/crates/analytics/src/lib.rs +++ b/crates/analytics/src/lib.rs @@ -275,6 +275,34 @@ impl AnalyticsClient { Ok(()) } + + pub async fn alias( + &self, + alias: impl Into, + original_distinct_id: impl Into, + ) -> Result<(), Error> { + let alias = alias.into(); + let original_distinct_id = original_distinct_id.into(); + + if alias == original_distinct_id { + return Ok(()); + } + + if let Some(lazy) = &self.posthog { + let state = lazy.get().await; + let mut event = Event::new("$create_alias", &original_distinct_id); + let _ = event.insert_prop("alias", &alias); + state.client.capture(event).await?; + } else { + tracing::info!( + "alias: alias={}, original_distinct_id={}", + alias, + original_distinct_id + ); + } + + Ok(()) + } } pub trait ToAnalyticsPayload { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c25e058549..270430d1d4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,2 +1,3 @@ export * from "./cn"; export * from "./date"; +export * from "./url"; diff --git a/packages/utils/src/url.test.ts b/packages/utils/src/url.test.ts new file mode 100644 index 0000000000..6c8c662808 --- /dev/null +++ b/packages/utils/src/url.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { withCharUtm } from "./url"; + +describe("withCharUtm", () => { + it("appends UTMs to char links", () => { + expect( + withCharUtm("https://char.com/download", { + source: "github", + medium: "readme", + }), + ).toBe( + "https://char.com/download?utm_source=github&utm_medium=readme&utm_campaign=organic", + ); + }); + + it("preserves fragments and normalizes the homepage URL", () => { + expect( + withCharUtm("https://char.com#pricing", { + source: "website", + medium: "blog", + }), + ).toBe( + "https://char.com/?utm_source=website&utm_medium=blog&utm_campaign=organic#pricing", + ); + }); + + it("keeps existing query params", () => { + expect( + withCharUtm("https://char.com/download?spec=apple-silicon", { + source: "app", + medium: "settings", + }), + ).toBe( + "https://char.com/download?spec=apple-silicon&utm_source=app&utm_medium=settings&utm_campaign=organic", + ); + }); +}); diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts new file mode 100644 index 0000000000..2212fda0ff --- /dev/null +++ b/packages/utils/src/url.ts @@ -0,0 +1,20 @@ +export function withCharUtm( + url: string, + { + source, + medium, + campaign = "organic", + }: { + source: string; + medium: string; + campaign?: string; + }, +) { + const parsed = new URL(url); + + parsed.searchParams.set("utm_source", source); + parsed.searchParams.set("utm_medium", medium); + parsed.searchParams.set("utm_campaign", campaign); + + return parsed.toString(); +} diff --git a/plugins/analytics/build.rs b/plugins/analytics/build.rs index 8854aa1b66..6660119976 100644 --- a/plugins/analytics/build.rs +++ b/plugins/analytics/build.rs @@ -4,6 +4,7 @@ const COMMANDS: &[&str] = &[ "set_disabled", "is_disabled", "identify", + "alias", ]; fn main() { diff --git a/plugins/analytics/js/bindings.gen.ts b/plugins/analytics/js/bindings.gen.ts index fa122158bd..642778c86f 100644 --- a/plugins/analytics/js/bindings.gen.ts +++ b/plugins/analytics/js/bindings.gen.ts @@ -45,6 +45,14 @@ async identify(userId: string, payload: PropertiesPayload) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:analytics|alias", { previousDistinctId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } diff --git a/plugins/analytics/permissions/autogenerated/commands/alias.toml b/plugins/analytics/permissions/autogenerated/commands/alias.toml new file mode 100644 index 0000000000..432679d890 --- /dev/null +++ b/plugins/analytics/permissions/autogenerated/commands/alias.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-alias" +description = "Enables the alias command without any pre-configured scope." +commands.allow = ["alias"] + +[[permission]] +identifier = "deny-alias" +description = "Denies the alias command without any pre-configured scope." +commands.deny = ["alias"] diff --git a/plugins/analytics/permissions/autogenerated/reference.md b/plugins/analytics/permissions/autogenerated/reference.md index 48b7bd7420..ab913ad2aa 100644 --- a/plugins/analytics/permissions/autogenerated/reference.md +++ b/plugins/analytics/permissions/autogenerated/reference.md @@ -19,6 +19,32 @@ Default permissions for the plugin + + + +`analytics:allow-alias` + + + + +Enables the alias command without any pre-configured scope. + + + + + + + +`analytics:deny-alias` + + + + +Denies the alias command without any pre-configured scope. + + + + diff --git a/plugins/analytics/permissions/schemas/schema.json b/plugins/analytics/permissions/schemas/schema.json index 3f347fbe05..0b07d82d77 100644 --- a/plugins/analytics/permissions/schemas/schema.json +++ b/plugins/analytics/permissions/schemas/schema.json @@ -294,6 +294,18 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the alias command without any pre-configured scope.", + "type": "string", + "const": "allow-alias", + "markdownDescription": "Enables the alias command without any pre-configured scope." + }, + { + "description": "Denies the alias command without any pre-configured scope.", + "type": "string", + "const": "deny-alias", + "markdownDescription": "Denies the alias command without any pre-configured scope." + }, { "description": "Enables the event command without any pre-configured scope.", "type": "string", diff --git a/plugins/analytics/src/commands.rs b/plugins/analytics/src/commands.rs index 166bf84bc9..c43c7b5a47 100644 --- a/plugins/analytics/src/commands.rs +++ b/plugins/analytics/src/commands.rs @@ -55,3 +55,15 @@ pub(crate) async fn identify( .await .map_err(|e| e.to_string()) } + +#[tauri::command] +#[specta::specta] +pub(crate) async fn alias( + app: tauri::AppHandle, + previous_distinct_id: String, +) -> Result<(), String> { + app.analytics() + .alias(previous_distinct_id) + .await + .map_err(|e| e.to_string()) +} diff --git a/plugins/analytics/src/ext.rs b/plugins/analytics/src/ext.rs index 2f77b2a976..504aa362f5 100644 --- a/plugins/analytics/src/ext.rs +++ b/plugins/analytics/src/ext.rs @@ -124,6 +124,21 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Analytics<'a, R, M> { Ok(()) } + + pub async fn alias(&self, previous_distinct_id: impl Into) -> Result<(), crate::Error> { + if !self.is_disabled()? { + let machine_id = hypr_host::fingerprint(); + let previous_distinct_id = previous_distinct_id.into(); + + let client = self.manager.state::(); + client + .alias(machine_id, previous_distinct_id) + .await + .map_err(crate::Error::HyprAnalytics)?; + } + + Ok(()) + } } pub trait AnalyticsPluginExt { diff --git a/plugins/analytics/src/lib.rs b/plugins/analytics/src/lib.rs index 2bb1df7b1d..ec584ddb57 100644 --- a/plugins/analytics/src/lib.rs +++ b/plugins/analytics/src/lib.rs @@ -24,6 +24,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::set_disabled::, commands::is_disabled::, commands::identify::, + commands::alias::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } diff --git a/plugins/deeplink2/js/bindings.gen.ts b/plugins/deeplink2/js/bindings.gen.ts index 94de41ea09..2918c040d8 100644 --- a/plugins/deeplink2/js/bindings.gen.ts +++ b/plugins/deeplink2/js/bindings.gen.ts @@ -39,7 +39,7 @@ deepLinkEvent: "plugin:deeplink2:deep-link-event" /** user-defined types **/ -export type AuthCallbackSearch = { access_token: string; refresh_token: string } +export type AuthCallbackSearch = { access_token: string; refresh_token: string; web_distinct_id: string | null } export type BillingRefreshSearch = Record export type DeepLink = { to: "/auth/callback"; search: AuthCallbackSearch } | { to: "/billing/refresh"; search: BillingRefreshSearch } | { to: "/integration/callback"; search: IntegrationCallbackSearch } export type DeepLinkEvent = DeepLink diff --git a/plugins/deeplink2/src/types/auth_callback.rs b/plugins/deeplink2/src/types/auth_callback.rs index defe5fe854..49cbb0fc5f 100644 --- a/plugins/deeplink2/src/types/auth_callback.rs +++ b/plugins/deeplink2/src/types/auth_callback.rs @@ -7,6 +7,7 @@ use specta::Type; pub struct AuthCallbackSearch { pub access_token: String, pub refresh_token: String, + pub web_distinct_id: Option, } impl fmt::Debug for AuthCallbackSearch { @@ -14,6 +15,7 @@ impl fmt::Debug for AuthCallbackSearch { f.debug_struct("AuthCallbackSearch") .field("access_token", &"[REDACTED]") .field("refresh_token", &"[REDACTED]") + .field("web_distinct_id", &self.web_distinct_id) .finish() } }