> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# Bazel ecosystem usage stats, now public

> Live adoption data for the Bazel ecosystem is now public: which Bazel versions builds actually run and how BCR module versions trend, plus exactly how the underlying usage data is collected and every way to opt out.

export const BlogPost = ({title, date, authors, tags, image, children}) => {
  const tagList = tags ? tags.split(", ").filter(Boolean) : [];
  const tagSlug = t => t.toLowerCase().replace(/&/g, "").replace(/\+/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
  const formattedDate = date ? new Date(date + "T00:00:00").toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric"
  }) : "";
  return <section className="w-full flex justify-center px-4 py-12 md:py-16">
      <div style={{
    maxWidth: "800px",
    width: "100%"
  }}>
        {image && (typeof image === "string" ? <img noZoom src={image} alt={title} className="w-full rounded-xl mb-8" style={{
    maxHeight: "400px",
    objectFit: "cover"
  }} /> : <div className="blog-post-hero-image">{image}</div>)}
        <h1 className="text-3xl md:text-4xl font-bold text-zinc-900 dark:text-white">
          {title}
        </h1>
        <div className="flex flex-wrap items-center gap-3 mt-4 text-sm text-zinc-500 dark:text-zinc-400">
          {authors && <span>{authors}</span>}
          {authors && formattedDate && <span>·</span>}
          {formattedDate && <span>{formattedDate}</span>}
        </div>
        {tagList.length > 0 && <div className="flex flex-wrap gap-2 mt-3">
            {tagList.map(tag => <a key={tag} href={"/blog/tags/" + tagSlug(tag)} className="px-2 py-0.5 rounded-full text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-blue-100 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300 transition">
                {tag}
              </a>)}
          </div>}
        <hr className="my-8 border-zinc-200 dark:border-zinc-700" />
        <div className="prose dark:prose-invert max-w-none">{children}</div>
      </div>
    </section>;
};

export const MarketingPage = () => <div className="marketing-page-marker" style={{
  display: "none"
}} />;

export const Section = ({children, className = "", gray = false, dark = false, id}) => <section id={id} className={`w-full flex justify-center px-4 py-16 md:py-24 ${gray ? "bg-gray-50 dark:bg-zinc-900" : dark ? "bg-zinc-900 dark:bg-zinc-950" : ""} ${className}`}>
    <div className="w-full" style={{
  maxWidth: "1140px"
}}>
      {children}
    </div>
  </section>;

<MarketingPage />

<BlogPost title="Bazel ecosystem usage stats, now public" date="2026-08-19" authors="Greg Magolan" tags="Bazel, Company">
  <img noZoom src="https://mintcdn.com/aspectbuild/XvXxB2QS6rqo2YSp/images/blog/stats/bazel-version-adoption.webp?fit=max&auto=format&n=XvXxB2QS6rqo2YSp&q=85&s=b7cebfdd82d5bbf55b166866bf0f7bd0" alt="Bazel version adoption chart from the stats page, showing daily usage of each Bazel release across the fleet" className="blog-post-cover blog-post-cover-full" width="1600" height="1044" data-path="images/blog/stats/bazel-version-adoption.webp" />

  The Bazel ecosystem now has public usage data. The new [Bazel ecosystem stats page](/open-source/stats), aggregated daily across the fleet of repositories running our open source rulesets, shows which Bazel versions builds actually run and how version adoption trends over time for the Bazel Central Registry modules those repositories use.

  We built it because maintaining a ruleset means making decisions that break people, on a schedule, with no data:

  * Can rules\_js drop Bazel 7 yet, or does half the install base still run it?
  * Is anyone still loading from WORKSPACE, or can that compatibility code finally go?
  * We shipped a new major release three months ago. Did anyone move?

  Until now the inputs were GitHub issues, Slack threads, and whoever complains loudest. The people who file issues are not a representative sample; the quiet majority pins a version and disappears for a year. Every deprecation was a guess about how many users we'd strand. Now there's a chart.

  ## What's on the page

  Two charts today, live on [the stats page](/open-source/stats):

  * **Bazel version adoption.** Which Bazel releases run each day across the fleet of repositories using our rulesets, split by CI and non-CI builds. You can watch a new Bazel major roll out, and watch how long the old one refuses to die.
  * **Dependency version adoption.** Pick a Bazel Central Registry module and see its version adoption over time. Not just ours: protobuf, rules\_go, bazel\_skylib, whatever shows up in reporting repos' lockfiles.

  <img noZoom src="https://mintcdn.com/aspectbuild/XvXxB2QS6rqo2YSp/images/blog/stats/dependency-version-adoption.webp?fit=max&auto=format&n=XvXxB2QS6rqo2YSp&q=85&s=76ec7f5967bae67ae7ad2249f41a4395" alt="Dependency version adoption chart for aspect_rules_lint, showing daily usage of each version and newer releases overtaking older ones" className="blog-post-figure" width="1600" height="1247" data-path="images/blog/stats/dependency-version-adoption.webp" />

  A note on the y-axis: a record is one telemetry report, and a report is sent when Bazel sets up a repository's external dependencies from scratch or re-evaluates them, not on every build. On ephemeral CI runners with a cold cache that's roughly once per job; on a developer machine with a warm Bazel server it's roughly once per dependency change. So the counts track build activity, weighted heavily toward CI, and are not unique repositories or users. It's also why the charts breathe with the work week.

  If you maintain a module, your adoption curve is probably already up there. When we showed the charts to teams at Google we work closely with, they immediately wanted them for the open source modules they maintain: which Bazel versions their users run, and how fast new module versions get picked up in the wild. And if you're a user deciding whether to move to a new ruleset major, you can see whether the rest of the world has.

  Publishing the aggregate is the normal end state for tooling telemetry: Homebrew has [published its analytics](https://formulae.brew.sh/analytics/) for years, and Go [does the same](https://telemetry.go.dev/). This data came from the community's builds, so the useful form of it should go back to the community.

  ## Where the data comes from

  If a recent build of yours printed this, you've already met the collection side:

  > Aspect Telemetry will begin collecting ruleset usage data (which Bazel and module versions are in use) on the next invocation, to help us maintain our open source rulesets. Reports carry no user or organization identifiers and are governed by the [https://aspect.build/privacy-policy](https://aspect.build/privacy-policy).

  That notice comes from [aspect\_tools\_telemetry](https://github.com/aspect-build/tools_telemetry), a small open source Bazel module that ships with our rulesets: rules\_js, rules\_ts, rules\_swc, rules\_jest, rules\_py, and rules\_lint. It reports coarse usage data back to us, and the stats page is built from the aggregate.

  Some engineers don't want to send usage metrics, and at plenty of companies that's not a preference but a policy. We understand that position completely. What we aimed for is an opt-out that's one line, honored forever, using the industry-standard [`DO_NOT_TRACK`](https://donottrack.sh/) variable:

  ```shell theme={null}
  # .bazelrc
  common --repo_env=DO_NOT_TRACK=1
  ```

  `--repo_env` is enough, no `--action_env` needed, because collection runs inside a repository rule. Set it once per repo and nothing is ever sent. It's still the first code block in this post, on purpose. And opting out doesn't gate you from anything above: the stats page is public either way.

  ## What's collected, and when

  Mechanics first. The module runs as a bzlmod extension, so it executes only when Bazel re-evaluates the module graph: adding a dependency, bumping a version, editing MODULE.bazel. An ordinary edit-compile-test loop doesn't trigger it. WORKSPACE builds never run it at all, since module extensions don't exist there.

  The first time it would report, it prints the notice above and sends nothing; reporting starts on a later graph evaluation, which leaves a window to opt out before a byte leaves your machine. (Honesty about the edges: that handshake uses Bazel's extension facts API and your `MODULE.bazel.lock` to remember the notice was shown. Repos without a lockfile get the notice and the first upload on the same invocation, and Bazel versions too old for the facts API can't tell a first evaluation from the hundredth.) When it does report, it's a single JSON POST via `curl` with a one-second timeout and silent failure. No `curl` on the `PATH`, no report.

  The payload is these fields, nothing else:

  | Field                                     | What it is                                                                                  |
  | ----------------------------------------- | ------------------------------------------------------------------------------------------- |
  | `os`, `arch`, `shell`                     | Platform basics: `mac os x`, `aarch64`, `/bin/zsh`                                          |
  | `bazel_version`                           | The Bazel release evaluating the build                                                      |
  | `bazelisk`                                | Whether bazelisk launched it                                                                |
  | `has_bazel_workspace`, `has_bazel_module` | Whether WORKSPACE and MODULE.bazel files exist                                              |
  | `has_bazel_tool`, `has_bazel_prelude`     | Whether a `tools/bazel` wrapper or `prelude_bazel` exists                                   |
  | `deps`                                    | Modules in your `MODULE.bazel.lock` that were resolved from a registry, with versions       |
  | `ci`                                      | Whether `$CI` is set                                                                        |
  | `runner`                                  | Which CI system: `github-actions`, `buildkite`, and so on                                   |
  | `counter`                                 | The CI build number                                                                         |
  | `id_day`                                  | Day-scoped repo hash: the first four lines of your README hashed together with the UTC date |

  No source code, no target names, no file paths, no repository URLs, no command lines. One caveat if you run a private registry: `deps` includes module names and versions resolved from it, not just Bazel Central Registry ones. The public page only charts BCR modules, but if internal module names shouldn't leave the building, `ASPECT_TOOLS_TELEMETRY=-deps` drops the field.

  You don't have to take a blog post's word for any of this. Bazel keeps the exact report on disk where you can read it (the repository's directory name is mangled by bzlmod and varies across Bazel versions, hence the glob):

  ```shell theme={null}
  cat $(bazel info output_base)/external/*aspect_tools_telemetry_report/report.json
  ```

  Here's a real one, from the sandbox example in the repo:

  ```json theme={null}
  {
    "tools_telemetry": {
      "arch": "aarch64",
      "bazel_version": "9.2.0",
      "bazelisk": true,
      "ci": true,
      "counter": "678",
      "deps": {
        "aspect_tools_telemetry": "0.0.0",
        "simple-example": "0.0.0"
      },
      "has_bazel_module": true,
      "has_bazel_prelude": false,
      "has_bazel_tool": false,
      "has_bazel_workspace": true,
      "id_day": "a9ffac7b8a8b9972388b7bdaca912b795ce26126",
      "os": "mac os x",
      "runner": "drone",
      "shell": "/bin/zsh"
    }
  }
  ```

  If you've opted out, the file is still written, and it contains `{}`. The [collectors](https://github.com/aspect-build/tools_telemetry/tree/main/collectors) are a few hundred lines of Starlark; auditing them is a ten-minute read.

  ## The deduplication ID

  The only correlation ID in the report is `id_day`, and it rotates daily. It exists so reports from the same repository can be deduplicated within a day: one giant monorepo running a million builds shouldn't read as a thousand adopters. It's a hash of the first four lines of your README (falling back to MODULE.bazel, and deliberately never your git remote URL, which can embed credentials) mixed with the current UTC date. The date is the point: the same repository hashes to a different value every day, so nobody holding the data, us included, can follow a repository across days. The stable input to that hash is computed on your machine and never leaves it.

  Call it pseudonymous, not anonymous. A SHA-1 of low-entropy input is a grouping key, not cryptography, and for a public repository a determined party could recompute a specific known repo's daily value from its README. Two layers blunt that. On your side, the salt dial: set `ASPECT_TOOLS_TELEMETRY_SALT` to any value and it's mixed into every hash, making your values unrecomputable by anyone who doesn't hold the salt; for a public repository, set it as a CI secret rather than committing it, or the salt is as public as the README. On our side, ingestion re-keys the ID with a server-side secret before anything is stored, so the data we keep can't be matched against recomputed values either, and neither can anything derived from it. That secret rotates daily, and the outgoing value is destroyed rather than archived: once a day closes, no key exists anywhere that could connect its stored IDs back to a repository, for anyone, us included.

  ## The dials

  `ASPECT_TOOLS_TELEMETRY` takes Bazel-style set notation over the field names above, so the choice isn't only everything-or-nothing:

  ```shell theme={null}
  # .bazelrc
  common --repo_env=DO_NOT_TRACK=1                       # industry standard: everything off
  common --repo_env=ASPECT_TOOLS_TELEMETRY=-all          # same effect, module-specific
  common --repo_env=ASPECT_TOOLS_TELEMETRY=-id_day       # everything except the day-scoped dedup ID
  common --repo_env=ASPECT_TOOLS_TELEMETRY=deps          # only module versions
  common --repo_env=ASPECT_TOOLS_TELEMETRY_SALT=chosen4u # salt the id_day hash
  ```

  ## Send it to yourself instead

  The destination is an environment variable too. Point it at your own collector and reports go there instead of to us:

  ```shell theme={null}
  common --repo_env=ASPECT_TOOLS_TELEMETRY_ENDPOINT=https://telemetry.example.internal/ingest
  ```

  For a platform team this is a free census across every Bazel repo in the company: which teams run which Bazel version, which repos still carry a WORKSPACE file, which ruleset versions are deployed where, on which CI systems. The payload is the same `report.json`; anything that accepts a JSON POST works.

  ## If you maintain a ruleset

  Your module's adoption curve is probably already on the [dependency chart](/open-source/stats#dep-versions), because `deps` covers every registry module in a reporting repo's lockfile. But read the fine print on that sample: today it only includes repositories that also pull in a ruleset carrying `aspect_tools_telemetry`. What you're seeing is the intersection of your users with ours. Adopt the module in your own ruleset and the data covers your user base directly.

  Nothing in it is specific to our rules. It's Apache-2.0, it's on the registry, and wiring it up is three lines in your MODULE.bazel:

  ```python theme={null}
  bazel_dep(name = "aspect_tools_telemetry", version = "0.4.2")

  tel = use_extension("@aspect_tools_telemetry//:extension.bzl", "telemetry")
  use_repo(tel, "aspect_tools_telemetry_report")
  ```

  Your users see the same notice ours do and get the same opt-outs, which is how it should be.

  You don't need to wait on us to build your view of the data, either. The dataset behind the charts is public JSON at [stats.aspect.build/stats.json](https://stats.aspect.build/stats.json): daily counts per Bazel version and per module version, split CI and non-CI, regenerated nightly, about 10 MB, CORS-open. Feed it to `jq` or your coding agent, script your deprecation check against it, or point your own dashboard straight at it. One caveat: this JSON is not a stable API and comes with no compatibility guarantee. Expect the format to change, likely soon, as we build richer views of the data.

  There's also more worth publishing than single-module version curves. Each report carries the Bazel version and the module list together, so the interesting cuts are already in the data: which Bazel versions repos using your ruleset actually run. That's your drop-Bazel-7 decision, answered for your ruleset specifically, and it's the kind of richer public dataset we want to build out. [Open an issue](https://github.com/aspect-build/tools_telemetry/issues) if you're adopting the module or if there's a slice like that you want on the public page; we'd rather build this with other maintainers than alone.

  ## Where this goes

  The data is on [our public stats page](/open-source/stats), refreshed daily and governed by our [privacy policy](/privacy-policy). Raw reports are deleted after at most a year; the published aggregates are what persists. The code is at [aspect-build/tools\_telemetry](https://github.com/aspect-build/tools_telemetry), and issues are welcome. And if your reaction to all of this was to set `DO_NOT_TRACK=1` anyway: that's the variable doing its job.
</BlogPost>
