feat(hub): optional analytics seam so a managed deployment can measure the app (#80)

Adds Server.Analytics (webapp.AnalyticsConfig) and emits it as /api/config
`analytics` when a key is set. The frontend loads posthog-js from the CDN at
runtime rather than as a dependency, so an unconfigured hub ships no tracker
and makes no third-party request — the OSS bundle grows 1.1KB (the loader),
not 230KB.

Product events come from one table in api/http.ts keyed on method+path.
Every mutating call in the app already goes through api()/postJSON(), so a
new write is measured or it isn't, instead of depending on someone
remembering a capture() call. Share creation is the one raw fetch and fires
its own.

Session replay masks every text node: in this product nearly all of it is
customer file names and document bodies. Replays are layout, clicks and
navigation only.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-30 10:30:02 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 511b838da0
commit 2f68bbe92e
11 changed files with 239 additions and 16 deletions
+11 -1
View File
@@ -47,9 +47,16 @@ classDiagram
class api {
+getJSON / postJSON / api
+getResponse (raw bytes)
+PRODUCT_EVENTS method+path → event
types.ts server contracts
}
note for api "api/http.ts — all URLs root-absolute so deep paths never break relative resolution"
note for api "api/http.ts — all URLs root-absolute so deep paths never break relative resolution. Every mutating call goes through api()/postJSON(), so one table there is the whole product-event surface: a new write is measured or it isn't, instead of depending on someone remembering a capture() call"
class analytics {
+initAnalytics(config)
+track(event, props)
}
note for analytics "analytics.ts — posthog-js is fetched from the CDN at runtime, never installed: with no `analytics` in /api/config this module makes no request and the OSS bundle carries no tracker. capture_pageview history_change because the router is History-API. Replay masks every text node (maskTextSelector *) — in this product nearly all of it is customer file names and document bodies"
class hooks {
+useConfig
@@ -88,4 +95,7 @@ classDiagram
hooks --> api
Browser --> hooks
HubApp --> hooks
hooks --> analytics : initAnalytics + identify on config
api --> analytics : track(product event)
Browser --> analytics : share_created (the one raw fetch)
```
+9
View File
@@ -25,6 +25,7 @@ classDiagram
+Dir Directory
+Quota QuotaProvider
+Billing func(email) (plan, url, ok)
+Analytics AnalyticsConfig
+ShareRPM int
-vols per-project volume cache
+Handler() http.Handler
@@ -184,6 +185,13 @@ classDiagram
}
class UnlimitedQuota
class AnalyticsConfig {
+Key string
+Host string
+Endpoint() string
}
note for AnalyticsConfig "Third managed-deployment seam beside Quota and Billing, but a value rather than an interface — there is nothing to implement, only a project to name. Emitted as /api/config `analytics` when Key is set; empty means the frontend loads no tracker and contacts nobody, which is what a self-hosted hub gets. Endpoint() is exported because the cloud module renders its own loader from the same value."
Server o-- "0..1" Source : single-volume mode
Server o-- "0..1" Backend : Root (hub mode)
Server o-- ProjectDB
@@ -193,6 +201,7 @@ classDiagram
Server o-- ShareDB
Server o-- ReadLedger
Server o-- QuotaProvider
Server *-- AnalyticsConfig
Server *-- volume : per project, cached
volume o-- Source
+36
View File
@@ -510,3 +510,39 @@ func TestConfigBillingSeam(t *testing.T) {
t.Fatal("billing shown to a user the hook declined")
}
}
// TestConfigAnalyticsSeam: an unconfigured hub says nothing about analytics —
// that silence is what keeps a self-hosted frontend from loading a tracker —
// and a configured one hands over the key with a default host.
func TestConfigAnalyticsSeam(t *testing.T) {
config := func(srv *Server) map[string]json.RawMessage {
t.Helper()
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/config", nil))
if rec.Code != 200 {
t.Fatalf("config: %d %s", rec.Code, rec.Body)
}
var out map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
srv, _, _ := authHub(t, true)
if _, ok := config(srv)["analytics"]; ok {
t.Fatal("an unconfigured hub advertised analytics")
}
// Signed out on purpose: the block must not depend on a session, or a
// hub with auth off would never be measurable.
srv.Analytics = AnalyticsConfig{Key: "phc_test"}
if got := string(config(srv)["analytics"]); got != `{"host":"`+DefaultAnalyticsHost+`","key":"phc_test"}` {
t.Fatalf("analytics block = %s", got)
}
srv.Analytics.Host = "https://eu.i.posthog.com"
if got := string(config(srv)["analytics"]); got != `{"host":"https://eu.i.posthog.com","key":"phc_test"}` {
t.Fatalf("analytics host override = %s", got)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Product analytics, loaded only when the server asks for it.
//
// The hub sends `analytics` in /api/config exactly when a managed
// deployment configured one (webapp.AnalyticsConfig). A self-hosted hub sends
// nothing, so this module makes no third-party request and posthog-js never
// enters the bundle — that is why the library is fetched from PostHog's CDN
// at runtime instead of installed as a dependency: an OSS install must not
// carry a tracker it never runs.
//
// There is no call queue. The loader resolves in a few hundred milliseconds,
// long before anyone can click a thing worth recording, and the one event that
// races it — identify — is fired from onload.
import type { ServerConfig } from "./api/types";
type PostHog = {
init(key: string, opts: Record<string, unknown>): void;
identify(id: string, props?: Record<string, unknown>): void;
capture(event: string, props?: Record<string, unknown>): void;
};
declare global {
interface Window {
posthog?: PostHog;
}
}
let started = false;
export function initAnalytics(cfg: ServerConfig) {
const a = cfg.analytics;
if (!a?.key || started) return;
started = true;
const s = document.createElement("script");
// PostHog serves the library from an assets subdomain beside the ingestion
// host (us.i → us-assets.i). A self-hosted PostHog has no such split, and
// the replace is a no-op there, which is the right answer for it too.
s.src = a.host.replace(".i.posthog.com", "-assets.i.posthog.com") + "/static/array.js";
s.async = true;
s.onload = () => {
const ph = window.posthog;
if (!ph) return;
ph.init(a.key, {
api_host: a.host,
// Pin the library's defaults: this loads whatever posthog-js is current
// on the CDN, so an unpinned behavior change would arrive unannounced.
defaults: "2026-05-30",
// The app is a history-API SPA (nav.ts) — without this only the first
// route of a session would count as a pageview.
capture_pageview: "history_change",
session_recording: {
maskAllInputs: true,
// ponytail: mask every text node, because in this product nearly all
// of it is customer data — file names, folder names, document bodies,
// project names. Replays show layout, clicks and navigation only. If
// that proves too opaque to debug with, unmask specific chrome
// (topbar, dialogs, empty states) rather than lowering this globally.
maskTextSelector: "*",
},
});
if (cfg.me) {
ph.identify(cfg.me.email, {
email: cfg.me.email,
name: cfg.me.name,
// Present only on a hub with billing; lets funnels split by plan
// without a second source of truth.
...(cfg.billing ? { plan: cfg.billing.plan } : {}),
});
}
};
document.head.appendChild(s);
}
// A no-op until (and unless) analytics loaded. Every caller can fire blind.
export function track(event: string, props?: Record<string, unknown>) {
window.posthog?.capture(event, props);
}
+38
View File
@@ -2,6 +2,42 @@
// deep path like /<project>/<dir>/<file> must never break relative
// resolution.
import { track } from "../analytics";
// Product events, derived from the write that just succeeded rather than
// hand-fired at each button. Every mutating call in the app goes through
// api() or postJSON(), so this table is the whole instrumentation surface —
// a new write shows up here or nowhere, instead of silently going unmeasured
// because someone forgot a capture() call. The one write that bypasses these
// helpers (share creation, a raw fetch in Browser.tsx) fires its own.
//
// Nothing here carries a path, project name, email or token: the event says
// what kind of thing happened, never to which customer object.
//
// Two writes are deliberately absent. POST .../reads is read telemetry, not a
// user action — counting it would mean the analytics of our analytics. The
// /store/* routes are device replication, which is a sync cycle rather than
// anything a person did.
const PRODUCT_EVENTS: [RegExp, string][] = [
[/^POST \/api\/projects$/, "project_created"],
[/^DELETE \/api\/projects\//, "project_deleted"],
[/^POST \/api\/p\/[^/]+\/restore$/, "file_restored"],
[/^DELETE \/api\/shares\//, "share_revoked"],
[/^PATCH \/api\/shares\//, "share_expiry_changed"],
[/^POST \/api\/orgs\/[^/]+\/invites$/, "invite_created"],
[/^DELETE \/api\/orgs\/[^/]+\/invites\//, "invite_revoked"],
[/^POST \/api\/invites\//, "invite_accepted"],
[/^PUT \/api\/p\/[^/]+\/permissions\/./, "project_access_granted"],
[/^DELETE \/api\/p\/[^/]+\/permissions\/./, "project_access_revoked"],
];
function trackWrite(method: string, url: string) {
// The query string can hold a path; match on the path alone.
const key = method + " " + url.split("?")[0];
const hit = PRODUCT_EVENTS.find(([re]) => re.test(key));
if (hit) track(hit[1]);
}
function toLogin(): never {
// Auth required: sign in, then come back to the current route.
location.href =
@@ -68,6 +104,7 @@ export async function api<T = unknown>(method: string, url: string, body?: unkno
}
const r = await fetch(url, opt);
if (!r.ok) await fail(r);
trackWrite(method, url);
return r.status === 204 ? ({} as T) : r.json();
}
@@ -79,5 +116,6 @@ export async function postJSON<T>(url: string, body?: unknown): Promise<T> {
});
if (r.status === 401) toLogin();
if (!r.ok) await fail(r);
trackWrite("POST", url);
return r.json();
}
@@ -19,6 +19,9 @@ export interface ServerConfig {
me?: { email: string; name: string };
// Managed deployments only: where billing lives + the user's current plan.
billing?: { plan: string; url: string };
// Managed deployments only: PostHog project key + ingestion host. Absent
// on a self-hosted hub, and absence is what keeps analytics.ts inert.
analytics?: { key: string; host: string };
}
// GET <config.billing.url> with Accept: application/json (managed hubs).
@@ -17,6 +17,7 @@ import { currentNavType, navigate, useLocationPath } from "../nav";
import { HTML_EXT, copyText } from "../util";
import { toast } from "../toast";
import { onSearchRequest } from "../search";
import { track } from "../analytics";
import { AppShell, Icon, Page, Topbar, closeSidebarOnMobile, type PageWidth } from "../components/shell";
import { FileTree, ancestorsOf } from "../components/FileTree";
import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -191,6 +192,9 @@ export default function Browser(props: {
});
if (!r.ok) throw new Error(await r.text());
const s = await r.json();
// Fired here rather than by the table in api/http.ts, because this is
// the one write in the app that goes out as a raw fetch.
track("share_created");
const copied = await copyText(s.url);
setShare({ url: s.url, copied });
refreshShares(); // the banner appears (or stays) without a reload
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import { initAnalytics } from "../analytics";
import type { ServerConfig } from "../api/types";
// The first request the app makes; everything else keys off its answer.
@@ -17,6 +18,10 @@ export function useConfig() {
encodeURIComponent(location.pathname + location.search);
await new Promise(() => {}); // never resolve; we're navigating away
}
// The one place the config lands, and it lands once (staleTime
// Infinity) — no effect needed, and identify() gets the user in the
// same breath. A no-op unless the server configured analytics.
initAnalytics(cfg);
return cfg;
},
staleTime: Infinity,
+40
View File
@@ -91,6 +91,13 @@ type Server struct {
// hides the entry. The mirror of the Quota seam: Quota enforces the
// plan, Billing displays it.
Billing func(email string) (plan, url string, ok bool)
// Analytics, when its Key is set, tells the frontend to load PostHog
// (/api/config `analytics`). The third managed-deployment seam beside
// Quota and Billing, and deliberately server-supplied rather than
// bundled: with no key the OSS frontend ships no analytics code and
// makes no third-party request, so a self-hosted hub cannot phone home
// even by accident.
Analytics AnalyticsConfig
// ShareRPM is the per-IP request rate on public share links (/s/*);
// 0 means DefaultShareRPM.
ShareRPM int
@@ -114,6 +121,27 @@ type UploadConfig struct {
TTL time.Duration
}
// AnalyticsConfig points the frontend at a PostHog project. The key is a
// public write-only project token, not a credential — it is served to signed-
// out visitors too, because the app shell loads before login.
type AnalyticsConfig struct {
Key string // PostHog project key; empty disables analytics entirely
Host string // PostHog API host; empty means DefaultAnalyticsHost
}
// DefaultAnalyticsHost is PostHog's US cloud ingestion host.
const DefaultAnalyticsHost = "https://us.i.posthog.com"
// Endpoint is Host with the default applied. Exported because the same
// config drives more than the app shell in a managed deployment (the cloud
// module's marketing pages render their own loader from it).
func (a AnalyticsConfig) Endpoint() string {
if a.Host != "" {
return a.Host
}
return DefaultAnalyticsHost
}
// DefaultUploadTTL is used when UploadConfig.TTL is unset: long enough for a
// slow upload, short enough that a leaked URL goes stale quickly.
const DefaultUploadTTL = 15 * time.Minute
@@ -486,6 +514,18 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
"auth": auth,
"reads": map[string]any{"enabled": s.Reads != nil},
}
// Outside a managed deployment this block is absent and the frontend
// never loads a tracker. Outside the `me` check on purpose: a hub with
// auth off has no signed-in user and should still be measurable.
// Note the funnel gap this leaves — /auth/* is server-rendered HTML
// (authlocal.go authPage) with no analytics, so a visitor is counted on
// the marketing page and again once the app boots, but the signup page
// itself reports nothing. Same origin means the anonymous id survives
// the round trip, so attribution holds; only signup-page drop-off is
// invisible. Wire authPage up if that becomes the question.
if s.Analytics.Key != "" {
out["analytics"] = map[string]string{"key": s.Analytics.Key, "host": s.Analytics.Endpoint()}
}
if me.Email != "" {
out["me"] = map[string]string{"email": me.Email, "name": me.Name}
if s.Billing != nil {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-CBxy_alH.js"></script>
<script type="module" crossorigin src="/assets/index-CuzT86iF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ozNOEdCg.css">
</head>
<body>