Skip to content

Publish install counts as a stats.json sidecar - #98

Merged
SawyerHood merged 1 commit into
get-bb:mainfrom
andrewkchan:install-count-stats
Aug 25, 2026
Merged

Publish install counts as a stats.json sidecar#98
SawyerHood merged 1 commit into
get-bb:mainfrom
andrewkchan:install-count-stats

Conversation

@andrewkchan

Copy link
Copy Markdown
Contributor

What was wrong

BB servers have sent a plugin_installed telemetry event since the store shipped, but nothing ever read it back. The registry had no way to tell anyone how many people use a listing, and the store card had no popularity signal at all.

What changed

  • scripts/build-stats.mjs (new, npm run build:stats) queries PostHog's HogQL API for count(DISTINCT distinct_id) of plugin_installed grouped by properties.plugin_id and writes dist/stats.json.
    • Distinct installations, not raw events: a reinstall loop must not inflate a listing.
    • Rows whose id fails the manifest's own id pattern, or whose count is not a non-negative safe integer, are dropped with a warning. Keys are emitted sorted, so an unchanged run republishes identical bytes and keeps its ETag.
    • A run that ends with no usable counts exits non-zero without writing, so an outage or a rotated key leaves the last published sidecar serving rather than zeroing every counter in the store.
    • --print writes to stdout for local inspection.
  • .github/workflows/stats.yml (new) runs it daily plus on demand and uploads to bb-marketplace/stats.json in the same bucket as the manifest publish.
  • README documents the format, the undercount caveat, and the required credentials.

Counts are a separate document rather than a field in marketplace.json on purpose: that schema is strict, so an unknown field there rejects the whole catalog on an older desktop, and the numbers move daily while the manifest sits unchanged behind a 304.

Deploy requirement: the production environment needs a new POSTHOG_API_KEY secret (a personal API key with project read access) and a POSTHOG_PROJECT_ID variable. Set POSTHOG_HOST if the project is not on US cloud. Until they exist the scheduled job fails and uploads nothing; the manifest publish is unaffected.

The consuming side is get-bb/bb: the server fetches this file on every catalog refresh and renders the number in the store, on mobile, and in bb plugin search.

How you verified

  • Ran the script against a stub PostHog endpoint: confirmed the request carries the bearer key and the HogQL body, that a bad id and a non-numeric count are both dropped with a warning, and that the output has sorted keys and a stamped generatedAt.
  • Fed that exact output through BB's own parseMarketplaceStatsJson in a scratch test in the bb checkout — it parses and reports the right count.
  • Confirmed an empty result set exits 1 without writing dist/stats.json.
  • npm run build still composes the manifest unchanged (63 entries).

Not run: the workflow itself, which needs the credentials above.

Fixes #

AGENT GENERATED

BB has collected a `plugin_installed` telemetry event since the store
shipped, but nothing ever read it back, so the marketplace had no way to
show how popular a listing is. Add a daily job that queries those counts
and publishes them beside the manifest.

`scripts/build-stats.mjs` queries PostHog's HogQL API for
`count(DISTINCT distinct_id)` of `plugin_installed` grouped by
`properties.plugin_id`, and writes `dist/stats.json`. Distinct
installations, not raw events: a reinstall loop must not inflate a
listing. Rows whose id fails the manifest's own id pattern, or whose
count is not a non-negative safe integer, are dropped with a warning;
keys are emitted sorted so an unchanged run republishes identical bytes
and keeps its ETag. A run that ends with no usable counts exits non-zero
without writing, so an outage or a rotated key leaves the last published
sidecar serving instead of zeroing every counter in the store.

The counts are a separate document rather than a field in
marketplace.json on purpose: that schema is strict, so an unknown field
there rejects the whole catalog on an older desktop, and the numbers move
daily while the manifest sits unchanged behind a 304.

`.github/workflows/stats.yml` runs it daily and uploads to the same R2
bucket as the manifest publish. It needs the POSTHOG_API_KEY secret and
the POSTHOG_PROJECT_ID variable in the production environment, alongside
the Cloudflare credentials publish.yml already uses.

Verified against a stub PostHog endpoint: the request carries the bearer
key and HogQL body, unusable rows are dropped, and the emitted document
parses with BB's own `parseMarketplaceStatsJson`. Confirmed an empty
result set fails without writing, and that `npm run build` still composes
the manifest unchanged.

> AGENT GENERATED
@SawyerHood
SawyerHood merged commit a683caa into get-bb:main Aug 25, 2026
1 check passed
SawyerHood added a commit to get-bb/bb that referenced this pull request Aug 25, 2026
## What was wrong

The plugin store had no popularity signal. Every listing looked equally
used, so nothing distinguished a widely adopted plugin from one nobody
installs. The data already existed — servers have sent a
`plugin_installed` telemetry event
(`apps/server/src/services/system/telemetry.ts`) since the store
shipped, carrying a `plugin_id` for bundled plugins and `bb-community`
entries — but nothing ever read it back.

The curated marketplace now publishes those counts as a `stats.json`
sidecar beside its manifest (get-bb/marketplace#98). This PR consumes
it.

## What changed

Six commits, each typechecking on its own and meant to be read in order.

**1. `db: add a stats_json column to plugin_marketplaces`**
The column, nullable, plus migration `0107`. `statsJson` is required on
`UpsertPluginMarketplaceInput` rather than defaulted, so a refresh that
did not re-read the sidecar keeps its counts by explicitly passing the
value it had. All three writers are updated to say which they mean, and
nothing fetches a sidecar yet — **no behavior change**. The migrate test
drops the column before replaying a rewind, matching how every other
`ALTER TABLE ADD` in that suite is handled.

**2. `db: record the drizzle snapshot for 0107`**
**Skippable** — 3,739 lines of generated JSON, split out so it does not
bury the rest. It has to be committed even though nothing reads it at
runtime: drizzle-kit generates each migration by diffing `schema.ts`
against the newest snapshot in `meta/`. Without it, the next person to
run `db:generate` diffs against 0106, does not see `stats_json`, and
re-emits the same ALTER inside their own migration — which then fails on
every database that already applied 0107. All 109 snapshots are tracked
for this reason.

**3. `server: parse and fetch the marketplace install-count sidecar`**
A self-contained module, unused until commit 4. Three decisions live
here: the fetch is unconditional rather than replaying the manifest's
ETag, because the counts move while the manifest sits unchanged behind a
304 — that is the whole reason they are a separate document rather than
a manifest field, which the strict manifest schema would reject on an
older desktop anyway. A missing sidecar answers null. And the schema is
deliberately *not* strict, unlike the manifest's: that one is a security
contract where an unknown field must reject the document, while this is
display metadata a later publisher may extend. A malformed document is
still rejected whole, because half-parsed counts are worse than none.

**4. `server: read install counts on refresh and report them in
search`**
The wiring, and the only commit that changes what a client sees. Counts
are stored in the same transaction as the catalog snapshot, so entries
and counts always publish together; a failed fetch warns and keeps the
stored counts, since a cosmetic number must never fail a catalog
refresh. **Only the curated marketplace is asked for a sidecar** — BB
measures these from its own telemetry, so a number beside a third-party
listing would be that publisher's claim wearing BB's label. Bundled
plugins do get counts, from the same document. `installs` joins the
search result as nullable with a null default, so an older server
degrades to *no count* rather than to zero: zero would be a claim, null
is the absence of one.

**5. `app, mobile, cli: show install counts`**
Store card footer (compact `4.2K installs`, exact number in the
`title`), mobile browse subtitle, and an `Installs` column in `bb plugin
search` — exact and comma-grouped there, since a terminal column is read
to be compared, and present only once some result carries a count. An
uncounted entry renders nothing at all rather than a zero, on every
surface.

**6. `docs: document the install-count sidecar`**
Plan doc gets the format and the reasoning; the guide chapter and bb-cli
skill get the user-facing half, since `bb plugin search` grew a column.

No `HOST_DAEMON_PROTOCOL_VERSION` bump: nothing here crosses the
server/daemon boundary. `apps/web` needed no change — the R2 route
already serves any key under `/marketplace/v1/` and gives `.json` the
revalidating cache-control.

**Caveat worth stating in review:** telemetry is opt-out and only
production builds report, so the number is installs BB heard about, not
a true total. The docs say so; the UI just shows the number.

## How you verified

Six new server tests, all failing before commit 4:
- counts land on both curated entries and bundled plugins
- an entry the sidecar does not name stays uncounted (not zero)
- **the sidecar is re-read while the manifest answers 304** — the core
reason for the split
- a failed sidecar fetch keeps the stored counts and the refresh still
records success
- a malformed document (negative count) is rejected whole
- a third-party marketplace's entries stay null and its `stats.json` is
never requested

Plus a store-card test (compact label, exact `title`, singular "1
install", no count for the third-party card, footer does not collapse
into a stray separator) and a CLI test (column absent without counts,
present with them).

Commands: `pnpm exec turbo run typecheck` clean across all 75 tasks **at
each of the six commits individually**. `test` green for `@bb/server`
(1928), `@bb/db` (406), `@bb/cli` (463), `@bb/mobile` (839),
`@bb/server-contract` (58), `@bb/app`.

Also fed the registry's real generated output through
`parseMarketplaceStatsJson` in a scratch test, to confirm the two repos
agree on the format.

Pre-existing failures, unrelated and present on a clean tree:
`PluginIcon.test.tsx` (a local untracked `plugins/` directory without a
`package.json`) and occasional 5s timeouts in `FilePreview` /
`update-resolver` / `timeline-in-turn-window` under concurrent
full-suite load — each passes in isolation.

Fixes #

> AGENT GENERATED

---------

Co-authored-by: Sawyer Hood <sawyerjhood@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants