mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
511b838da0
commit
2f68bbe92e
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user