Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
https://char.com/docs/developers
https://char.com/docs/developers?utm_source=github&utm_medium=contributing&utm_campaign=organic
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
25 changes: 23 additions & 2 deletions apps/desktop/src/auth/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type AuthTokenHandlers = {
setSessionFromTokens: (
accessToken: string,
refreshToken: string,
opts?: { webDistinctId?: string },
) => Promise<void>;
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -188,6 +195,7 @@ async function trackAuthEvent(
if (event === "SIGNED_OUT") {
trackedIdentifySignature = null;
trackedSignedInUserId = null;
pendingWebDistinctId = null;
}
}

Expand All @@ -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);
Expand All @@ -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],
);
Expand Down Expand Up @@ -338,6 +356,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
) {
trackedIdentifySignature = null;
trackedSignedInUserId = null;
pendingWebDistinctId = null;
await clearAuthStorage();
setSession(null);
return;
Expand All @@ -348,6 +367,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {

trackedIdentifySignature = null;
trackedSignedInUserId = null;
pendingWebDistinctId = null;
await clearAuthStorage();
setSession(null);
} catch (e) {
Expand All @@ -357,6 +377,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
) {
trackedIdentifySignature = null;
trackedSignedInUserId = null;
pendingWebDistinctId = null;
await clearAuthStorage();
setSession(null);
}
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/calendar/components/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
},
{
Expand All @@ -38,7 +42,10 @@ const _PROVIDERS = [
badge: "Beta",
icon: <Icon icon="logos:google-calendar" width={20} height={20} />,
platform: "all",
docsPath: "https://char.com/docs/calendar/gcal",
docsPath: withCharUtm("https://char.com/docs/calendar/gcal", {
source: "app",
medium: "settings",
}),
nangoIntegrationId: "google-calendar",
},
{
Expand All @@ -48,7 +55,10 @@ const _PROVIDERS = [
badge: "Beta",
icon: <OutlookIcon size={20} />,
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[];
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/changelog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<div className="w-full pt-1">
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/settings/ai/llm/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
},
),
},
},
},
Expand All @@ -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",
},
),
},
},
},
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/settings/data/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<DryRunResult | null>(null);
const [successfulSource, setSuccessfulSource] =
Expand Down Expand Up @@ -101,9 +111,7 @@ export function Data() {
return (
<div>
<StyledStreamdown className="text-neutral-500">
{
"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}).`}
</StyledStreamdown>

<div className="mt-4 flex flex-col gap-3">
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/shared/hooks/useDeeplinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions apps/web/content/docs/developers/12.analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion apps/web/content/handbook/how-we-work/8.analytics-funnel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/download-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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",
}: {
variant?: "default" | "compact";
}) {
const platform = usePlatform();
const { track } = useAnalytics();
const { track, getDistinctId } = useAnalytics();

const getPlatformData = () => {
switch (platform) {
Expand Down Expand Up @@ -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 } : {}),
});
};

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/mdx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading