diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9db574eb4..f0a456624 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -568,7 +568,7 @@ Sources: [status transitions](app/Actions/UpdateCommitStatuses.php), [timeouts]( The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page says so instead of showing the list. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy. -To count active people, /dev/null remembers only the **last day** (not the time) you were signed in and used the site or the API; background refreshes do not count. That day is never shown to other people, is included in your export, and is deleted with your account. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in and a `signup` event right after an account is created, with nothing that identifies you. +To count active people, /dev/null remembers only the **last day** (not the time) you were signed in and used the site or the API; background refreshes do not count. That day is never shown to other people, is included in your export, and is deleted with your account. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in, a `signin-click` event when you tap to sign in, and a `signup` event right after an account is created, with nothing that identifies you. Sources: [last seen tracking](app/Http/Middleware/UpdateLastSeenAt.php), [Fathom events](resources/js/lib/analytics.ts), [public page routes](routes/web.php), [About](resources/js/pages/about/show.tsx), [contributors](app/Queries/ContributorsQuery.php), [privacy policy](resources/js/pages/privacy/show.tsx), [terms](resources/js/pages/terms/show.tsx). diff --git a/PRODUCT_METRICS.md b/PRODUCT_METRICS.md index 6ac3dc2cf..e02a14b71 100644 --- a/PRODUCT_METRICS.md +++ b/PRODUCT_METRICS.md @@ -21,7 +21,7 @@ Each answers the three questions of the [data guidelines](.ai/guidelines/data.bl |---|---|---|---|---| | The last day a person was seen (a date, no time) | `users.last_seen_at`, hidden | Retention and active users can't be derived from anything else: reading alone leaves no trace. Written at most once a day, without touching `updated_at`. | Admins, only as aggregates on the Growth page; the person in their export | With the account | | The number of people seen each day | `active_user_counts` (`seen_at`, `count`), no user ids | The daily active users history: `last_seen_at` only keeps each person's latest day, so past days can't be recomputed. Incremented the first time each person is seen in a day, so the count is exact. | Admins, on the Growth page | Never: it is an anonymous count | -| Two anonymous events, `guest-visit` and `signup` | Fathom | The conversion rate, counted on both sides by the same tool. No user id is sent. | Admins, through Fathom | Per Fathom's retention | +| Three anonymous events, `guest-visit`, `signin-click` and `signup` | Fathom | The conversion rate, counted on both sides by the same tool, and where in between visitors give up. No user id is sent. | Admins, through Fathom | Per Fathom's retention | **Seen** means signed in and opening a page or doing something (starring, replying, following) on the web or the installed app, or using the API from the mobile app or the terminal. Posts automated with the GitHub Action count too: someone who publishes, even automatically, is active. What a page reads on its own (polling for notifications, partial reloads, prefetches, JSON reads) doesn't count, or a tab left open would keep its owner active forever. @@ -37,7 +37,7 @@ The funnel follows the people who **signed up in the selected range** (the last - Why both sides of the rate come from Fathom: Fathom is blocked by uBlock Origin, Brave and Firefox's tracking protection, which developers use a lot. Signups from the database over visitors from Fathom would overstate the rate by an unknown amount. With both from Fathom, ad blockers hide the same share of each, as long as people who sign up block Fathom as often as other visitors. - Why only visitors not signed in: members browsing the site are not prospects; counting them would understate the rate. - **Acquisition by day**: a chart of the signups of each day, from the database, or, toggled, of the conversion rate of each day, from Fathom (`date_grouping=day`). One at a time rather than both on one chart with two scales, so neither trend is drawn against the other's; the tooltip of a day tells both. Today's bar is lighter, as the day is still counting. A dashed line marks the average signups of the other days, or the conversion rate of the whole range. A day's Fathom counts are people on that day, so the days don't add up to the range: someone who visits on two days counts on both. -- **Conversion by device**: the conversion rate of each type of device Fathom tells (phone, desktop, tablet) over the range, with its visitors and signups (`field_grouping=device_type` on both events), next to the rate of all devices from the funnel. The overall rate can fall because more visitors come on phones, which convert less, or because one type of device converts less than before; this tells the two apart. Someone who signs up on another device than they visited from counts on the device they signed up from. +- **Conversion by device**: the conversion rate of each type of device Fathom tells (phone, desktop, tablet) over the range, with its visitors, the visitors who tapped to sign in, and signups (`field_grouping=device_type` on the three events), next to the rate of all devices from the funnel. The overall rate can fall because more visitors come on phones, which convert less, or because one type of device converts less than before; this tells the two apart. Taps tell a pitch that doesn't convince (few taps) from sign-in at GitHub that people give up on (taps without signups), like on a phone not signed in to GitHub. The browser sends `signin-click` on any tap of a link or button that leaves to sign in, with a beacon so the event survives leaving for GitHub. Each device shows its taps and the share of them that finished (signups ÷ taps), which reads above 100% while the range starts before the release that added taps. Someone who signs up on another device than they visited from counts on the device they signed up from. - Caveats: - The events only exist since the release that added them; the rate reads "–" before. - Fathom's API doesn't define `unique_conversions` precisely; check on real data that it counts people, not events. diff --git a/app/Queries/ProductFunnelQuery.php b/app/Queries/ProductFunnelQuery.php index ffe09d65d..42cd20274 100644 --- a/app/Queries/ProductFunnelQuery.php +++ b/app/Queries/ProductFunnelQuery.php @@ -12,7 +12,7 @@ * @phpstan-import-type ActivationDay from ActivationByDayQuery * * @phpstan-type AcquisitionDay array{date: string, signups: int, visitors: int|null, converted: int|null, complete: bool} - * @phpstan-type DeviceConversion array{device: string, visitors: int, converted: int} + * @phpstan-type DeviceConversion array{device: string, visitors: int, tapped: int, converted: int} * @phpstan-type FunnelRates array{conversion: float|null, activation: float|null, retention: float|null} * @phpstan-type ProductFunnel array{ * visitors: int|null, @@ -36,10 +36,13 @@ private const int RETENTION_DAY = 1; /** - * The Fathom events the browser sends for a visit by someone not signed in, and for a signup. + * The Fathom events the browser sends for a visit by someone not signed in, a tap on a link to sign in, and a + * signup. */ private const string GUEST_VISIT_EVENT = 'guest-visit'; + private const string SIGN_IN_TAP_EVENT = 'signin-click'; + private const string SIGNUP_EVENT = 'signup'; public function __construct(private FathomTrafficQuery $traffic, private ActivationByDayQuery $activationByDay) @@ -179,26 +182,28 @@ private function acquisitionByDay(CarbonImmutable $from, CarbonImmutable $to): a } /** - * Visitors not signed in and signups from Fathom for each type of device, most visitors first, so a change in the - * mix of devices can be told apart from a change in how each converts. Null when Fathom isn't set up or didn't - * answer. + * Visitors not signed in, taps to sign in and signups from Fathom for each type of device, most visitors first, so + * a change in the mix of devices can be told apart from a change in how each converts, and a pitch that doesn't + * convince from sign-in at GitHub that people give up on. Null when Fathom isn't set up or didn't answer. * * @return list|null */ private function conversionByDevice(CarbonImmutable $from, CarbonImmutable $to): ?array { $visitors = $this->traffic->eventVisitorsBy($from, $to, self::GUEST_VISIT_EVENT, 'device_type'); - $converted = $visitors === null ? null : $this->traffic->eventVisitorsBy($from, $to, self::SIGNUP_EVENT, 'device_type'); + $tapped = $visitors === null ? null : $this->traffic->eventVisitorsBy($from, $to, self::SIGN_IN_TAP_EVENT, 'device_type'); + $converted = $tapped === null ? null : $this->traffic->eventVisitorsBy($from, $to, self::SIGNUP_EVENT, 'device_type'); - if ($visitors === null || $converted === null) { + if ($visitors === null || $tapped === null || $converted === null) { return null; } return array_map(fn (string $device): array => [ 'device' => $device, 'visitors' => $visitors[$device] ?? 0, + 'tapped' => $tapped[$device] ?? 0, 'converted' => $converted[$device] ?? 0, - ], array_keys($visitors + $converted)); + ], array_keys($visitors + $tapped + $converted)); } /** diff --git a/resources/js/lib/analytics.ts b/resources/js/lib/analytics.ts index ab1d1211d..a41eab669 100644 --- a/resources/js/lib/analytics.ts +++ b/resources/js/lib/analytics.ts @@ -1,4 +1,5 @@ import { router } from '@inertiajs/react'; +import { login } from '@/routes'; declare global { interface Window { @@ -9,10 +10,11 @@ declare global { const GUEST_VISIT_TRACKED = 'devnull:guest-visit-tracked'; /** - * The two Fathom events behind the conversion rate of the Metrics tab: a visit by someone not signed in, once per - * browser session, and a signup, right after an account is created. Both come from Fathom, so ad blockers hide the - * same share of each and the rate between them holds. Fathom's deferred script runs before the app's module script, - * so it is ready by then, unless it is blocked or left out outside production, and then nothing is sent. + * The Fathom events behind the conversion rate of the Metrics tab: a visit by someone not signed in, once per browser + * session, a tap on a link to sign in, and a signup, right after an account is created. A tap without a signup is + * someone who gave up at GitHub. All come from Fathom, so ad blockers hide the same share of each and the rates + * between them hold. Fathom's deferred script runs before the app's module script, so it is ready by then, unless it + * is blocked or left out outside production, and then nothing is sent. */ export function trackAnalyticsEvents(): void { router.on('navigate', (event) => { @@ -33,9 +35,31 @@ export function trackAnalyticsEvents(): void { window.fathom?.trackEvent('guest-visit'); }); + document.addEventListener('click', (event) => { + const link = + event.target instanceof Element + ? event.target.closest('a[href]') + : null; + + if ( + link instanceof HTMLAnchorElement && + link.origin === window.location.origin && + link.pathname === login().url + ) { + trackSignInTap(); + } + }); + router.on('flash', (event) => { if (event.detail.flash.signedUp) { window.fathom?.trackEvent('signup'); } }); } + +/** + * Sent before leaving for GitHub, which Fathom survives as it sends events with a beacon. + */ +export function trackSignInTap(): void { + window.fathom?.trackEvent('signin-click'); +} diff --git a/resources/js/lib/auth.ts b/resources/js/lib/auth.ts index 864d7f1bf..acaff34e3 100644 --- a/resources/js/lib/auth.ts +++ b/resources/js/lib/auth.ts @@ -1,3 +1,4 @@ +import { trackSignInTap } from '@/lib/analytics'; import { login } from '@/routes'; /** @@ -6,5 +7,6 @@ import { login } from '@/routes'; * GitHub is on another origin. */ export function signIn(): void { + trackSignInTap(); window.location.assign(login().url); } diff --git a/resources/js/pages/operations/metrics.tsx b/resources/js/pages/operations/metrics.tsx index e791c9509..e0d782bdf 100644 --- a/resources/js/pages/operations/metrics.tsx +++ b/resources/js/pages/operations/metrics.tsx @@ -719,8 +719,9 @@ const DEVICES = ['Phone', 'Desktop', 'Tablet']; /** * The conversion rate of each type of device, with the visitors behind it, so a shift towards phones can be told - * apart from phones converting worse, next to the rate of all devices from the funnel. Until the funnel arrives, it - * holds the room of the usual three. + * apart from phones converting worse, next to the rate of all devices from the funnel. The visitors who tapped to sign + * in, and the share of them who finished, tell visitors who never tap from those who give up at GitHub. Until the + * funnel arrives, it holds the room of the usual three. */ function ConversionByDevice({ funnel }: { funnel?: Funnel }) { const devices = funnel?.conversion_by_device; @@ -740,7 +741,7 @@ function ConversionByDevice({ funnel }: { funnel?: Funnel }) { ...(devices?.map((device) => ({ label: device.device.toLowerCase() || 'unknown', value: percent(ratio(device.converted, device.visitors)), - note: `${formatCount(device.converted)} of ${formatCount(device.visitors)} visitors`, + note: `${formatCount(device.converted)} of ${formatCount(device.visitors)} visitors · ${formatCount(device.tapped)} tapped sign in, ${percent(ratio(device.converted, device.tapped))} finished`, })) ?? DEVICES.map((device) => ({ label: device.toLowerCase() }))), { diff --git a/resources/js/types/operations.ts b/resources/js/types/operations.ts index f88d4e4fe..3ca460853 100644 --- a/resources/js/types/operations.ts +++ b/resources/js/types/operations.ts @@ -97,6 +97,7 @@ export type AcquisitionDay = { export type DeviceConversion = { device: string; visitors: number; + tapped: number; converted: number; }; diff --git a/tests/Unit/Queries/ProductFunnelQueryTest.php b/tests/Unit/Queries/ProductFunnelQueryTest.php index 5b7a30dc9..fc4e4b047 100644 --- a/tests/Unit/Queries/ProductFunnelQueryTest.php +++ b/tests/Unit/Queries/ProductFunnelQueryTest.php @@ -185,6 +185,7 @@ Http::fake(fn (Request $request) => Http::response([match ($request->data()['entity_name'] ?? null) { 'guest-visit' => ['unique_conversions' => '400'], + 'signin-click' => ['unique_conversions' => '20'], 'signup' => ['unique_conversions' => '3'], }])); @@ -224,18 +225,19 @@ ]); }); -it('tells conversion for each type of device, most visitors first', function (): void { +it('tells taps to sign in and conversion for each type of device, most visitors first', function (): void { config(['services.fathom.token' => 'token', 'services.fathom.site_id' => 'SITE']); Http::fake(fn (Request $request) => Http::response(isset($request->data()['field_grouping']) ? match ($request->data()['entity_name']) { 'guest-visit' => [['unique_conversions' => '400', 'device_type' => 'Phone'], ['unique_conversions' => '100', 'device_type' => 'Desktop']], + 'signin-click' => [['unique_conversions' => '40', 'device_type' => 'Phone'], ['unique_conversions' => '12', 'device_type' => 'Desktop']], 'signup' => [['unique_conversions' => '9', 'device_type' => 'Desktop'], ['unique_conversions' => '4', 'device_type' => 'Phone'], ['unique_conversions' => '1', 'device_type' => 'Tablet']], } : [['unique_conversions' => '0']])); expect(($this->funnel)()['conversion_by_device'])->toBe([ - ['device' => 'Phone', 'visitors' => 400, 'converted' => 4], - ['device' => 'Desktop', 'visitors' => 100, 'converted' => 9], - ['device' => 'Tablet', 'visitors' => 0, 'converted' => 1], + ['device' => 'Phone', 'visitors' => 400, 'tapped' => 40, 'converted' => 4], + ['device' => 'Desktop', 'visitors' => 100, 'tapped' => 12, 'converted' => 9], + ['device' => 'Tablet', 'visitors' => 0, 'tapped' => 0, 'converted' => 1], ]); });