feat(hub): count file changes and headless users server-side - #164
Merged
Conversation
The frontend's PostHog tracker sees everything a person clicks, but a device syncing through /store/* never loads a page — so an agent editing files all day was invisible, and "number of file changes" and "daily active users" both undercounted by however much of the product runs headless. One event, files_changed, from every write door: sync, upload (relay and direct commit), remove, restore. Its distinct_id is the same email analytics.ts identifies with, so a person on a laptop and a browser is one user, and its puts/deletes properties sum to the change count. The count comes from ops the hub has not stored before, not from the request body: a device PUTs its WHOLE journal every cycle, so counting the body would re-report the device's entire history every ten seconds and the metric would climb while nobody edited anything. journalKeepsItsOps already parsed the stored journal for the append-only check and threw the sequence away; it returns storedMax now, so this costs no extra read. Blob PUTs are deliberately not change events — content-addressed storage skips a blob it already holds, so blob writes undercount edits while ops are exact. No SDK: posthog-go would ship a tracker inside every self-hoster's binary, which is the exact thing the frontend avoids by loading posthog-js from a CDN only when a key is configured. Capture is one JSON POST, on its own goroutine, that does nothing when Analytics.Key is empty — an OSS hub still contacts nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
files_changedevent now fires from every hub write door — sync, upload, remove, restore — withputs/deletescounts.distinct_idas the browser tracker (account email), so one person on a laptop and a browser is one user, not two.Why
frontend/src/analytics.tscovers everything a person clicks, but it only runs in a browser. A device syncing through/store/*never loads a page, and its own instrumentation table (api/http.ts) deliberately excludes/store/*as "device replication, not something a person did". That is correct for product events and wrong for these two metrics — whatever share of the product runs headless was simply missing from both.What lands
New
internal/webapp/analytics.go:capture(email, event, props)— one JSON POST toEndpoint() + "/i/v0/e/", on its own goroutine, 10s client timeout, first failure logged then silent. Returns immediately whenAnalytics.Keyis empty.captureChange(r, source, puts, deletes)— the single funnel every write path calls, so the change count is one event rather than a per-route set that silently misses whichever route someone forgets to add.countOps(ops, storedMax)— new ops only, split onjournal.KindDelete.Call sites:
store.go(sync),upload.go(relay + direct commit),remove.go(deletes),restore.go(a restore writes a put op like any other edit).Properties are
puts,deletes,source(sync|browser),project. No path, no file name.The counting
Journals are PUT whole every cycle —
journalKeepsItsOpsexists precisely because the body must be a superset of what's stored. Solen(ops)per PUT is the device's entire history, every 10 seconds, forever.journalKeepsItsOpswas already fetching and parsing the stored journal for the append-only check and discarding the sequence numbers. It returnsstoredMaxnow, so the new-op count costs no extra read. Blob PUTs are deliberately not change events: content-addressed storage skips a blob it already holds, so blob writes undercount edits while ops are exact.Why no SDK
posthog-gowould land ingo.modand ship inside every self-hoster's binary — the exact thing the frontend avoids by CDN-loading posthog-js only when a key is configured.AnalyticsConfig.Keyis already a public write-only project token served to the browser, so this needs no new credential and no new config seam.Reading the metrics
files_changed→ property sum ofputs, second series summingdeletes. Break down bysourcefor agent vs. browser.$pageviewandfiles_changed. Same email as distinct_id on both.Tests
analytics_test.go, two cases aimed at how this rots silently:puts: 2. Re-push the same journal plus one delete →puts: 0, deletes: 1, not 2 again. Re-push unchanged → no event at all. Also assertsdistinct_id, since a mismatch there splits one person into two users and nothing else would notice.Verified:
go build ./...,go vet ./..., fullinternal/webappsuite (225s), fullinternal/webappsuite under-race -timeout 40m(1429s, clean), plusjournal/syncer/store. Note the suite exceedsgo test's default 10m deadline under-race.Architecture changes
architecture/webapp-server.md: newproductAnalyticsclass (the server-side emitter,analytics.go) composed intoServer, readingAnalyticsConfigand fed byjournalDoor.journalDoor.journalKeepsItsOpsgains a second return,storedMax.flowchart TB Server["Server"] DeviceRegistry["DeviceRegistry"] journalDoor["<div style='text-align:left'><b>journalDoor</b><br/><<Server, /api/p/id/store/*>><br/>ownJournal(key) whose journal is this<br/>journalOps(key, spooled) parse + validate<br/>opsNameTheirAuthor(ops) whose name<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ journalKeepsItsOps(ctx, be, key, ops) ok + storedMax</span></div>"] AnalyticsConfig["<div style='text-align:left'><b>AnalyticsConfig</b><br/>+Key string<br/>+Host string<br/>+Endpoint() string</div>"] productAnalytics["<div style='text-align:left'><b>productAnalytics</b><br/><<Server, analytics.go>><br/>capture(email, event, props) one POST, own goroutine<br/>captureChange(r, source, puts, deletes) files_changed<br/>countOps(ops, storedMax) new ops only</div>"] Server -- "AnalyticsConfig" --> AnalyticsConfig Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ every write door emits files_changed</span>" --> productAnalytics productAnalytics -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ Key gates it, Endpoint() addresses it</span>" .-> AnalyticsConfig journalDoor -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ storedMax tells this cycle's ops from the whole history</span>" .-> productAnalytics Server -- "/store/* is the only way a device writes" --> journalDoor journalDoor -. "OwnerOf gates the journal key" .-> DeviceRegistry classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef removed fill:#ef444422,stroke:#ef4444,stroke-width:2px,stroke-dasharray:4 3 classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class productAnalytics added linkStyle 1 stroke:#22c55e,stroke-width:2px linkStyle 2 stroke:#22c55e,stroke-width:2px linkStyle 3 stroke:#22c55e,stroke-width:2pxKnown gaps
storedMaxto 0, over-counting that one push. Rare, and the alternative is reading the object twice.🤖 Generated with Claude Code