Skip to content
Merged
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 KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions PRODUCT_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
21 changes: 13 additions & 8 deletions app/Queries/ProductFunnelQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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<DeviceConversion>|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));
}

/**
Expand Down
32 changes: 28 additions & 4 deletions resources/js/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { router } from '@inertiajs/react';
import { login } from '@/routes';

declare global {
interface Window {
Expand All @@ -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) => {
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
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');
}
2 changes: 2 additions & 0 deletions resources/js/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { trackSignInTap } from '@/lib/analytics';
import { login } from '@/routes';

/**
Expand All @@ -6,5 +7,6 @@ import { login } from '@/routes';
* GitHub is on another origin.
*/
export function signIn(): void {
trackSignInTap();
window.location.assign(login().url);
}
7 changes: 4 additions & 3 deletions resources/js/pages/operations/metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() }))),
{
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export type AcquisitionDay = {
export type DeviceConversion = {
device: string;
visitors: number;
tapped: number;
converted: number;
};

Expand Down
10 changes: 6 additions & 4 deletions tests/Unit/Queries/ProductFunnelQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}]));

Expand Down Expand Up @@ -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],
]);
});

Expand Down
Loading