> ## 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://mintlify.s3.us-west-1.amazonaws.com/aspectbuild/images/blog/stats/bazel-version-adoption.webp" 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" />

  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://mintlify.s3.us-west-1.amazonaws.com/aspectbuild/images/blog/stats/dependency-version-adoption.webp" 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" />

  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 on the next invocation, pursuant to 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`                                      | SHA-1 of the first four lines of your README                                                                    |
  | `user`                                    | Salted hash of who ran the build: the CI actor (`GITHUB_ACTOR` and equivalents) or the local username (`$USER`) |
  | `org`                                     | Repository owner slug from CI environment variables, unhashed                                                   |

  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": "8.3.1",
      "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": false,
      "id": "ccc935dd186ed92c3322efb755e8f70ede47c243",
      "org": null,
      "os": "mac os x",
      "runner": "drone",
      "user": "94fb5cf79f8322bd3f999a10eb713f478470979c"
    }
  }
  ```

  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 identity fields

  `id` exists so reports from the same repository aggregate: 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), chosen because README headers are stable. It's deliberately not derived from your git remote URL, because remote URLs can embed credentials, so the module never reads them.

  `user` starts from your CI system's actor variable (`GITHUB_ACTOR`, `BUILDKITE_BUILD_AUTHOR_EMAIL`, and equivalents), falling back to the operating system username for local builds. It's hashed, and salted with the repo id on purpose, so the same person can't be correlated across repositories.

  Call both of these pseudonymous, not anonymous. A SHA-1 of a low-entropy input is a grouping key, not cryptography. That's why the salt dial exists: set `ASPECT_TOOLS_TELEMETRY_SALT` to any value and it's mixed into every hash.

  `org` is the field to know about, because there's no hash on it at all. In CI it's the repository owner slug straight from your CI system's environment (`GITHUB_REPOSITORY_OWNER`, `BUILDKITE_ORGANIZATION_SLUG`, and equivalents); in local builds it's absent. We use it to understand which organizations run our open source. That shapes where maintenance effort goes, and it's also useful signal for the commercial side of Aspect; we'd rather tell you that plainly than have you find it in the source. It never appears in the public stats.

  ## 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=-org          # everything except the org name
  common --repo_env=ASPECT_TOOLS_TELEMETRY=deps          # only module versions
  common --repo_env=ASPECT_TOOLS_TELEMETRY_SALT=chosen4u # salt the id and user hashes
  ```

  ## 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). 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>
