← Back to Blog

TL;DR: The Build Event Protocol (BEP) is a structured protobuf event stream that Bazel emits during every build. It contains everything meaningful that happened: which targets were built, which actions ran, what hit or missed the cache, how tests performed, and how long each phase took. BEP is the primary interface for getting structured data out of Bazel, and it's the foundation for any serious build observability effort. If you're parsing Bazel's stdout for metrics, you're doing it the hard way.

Every time you run bazel build or bazel test, Bazel does a lot of work beneath the surface. It resolves the dependency graph, schedules actions, fetches cached results, executes what needs executing, and reports results. Most engineers interact with this process through terminal output — scrolling past lines of green and red text until they find the thing that broke.

But Bazel has a far richer interface for reporting what happened during a build. It's called the Build Event Protocol, or BEP. It's been part of Bazel since the early days, and it's the mechanism that powers every build dashboard, every cache analytics tool, and every CI integration that understands Bazel at a deeper level than "did the exit code equal zero."

If you're running Bazel at any meaningful scale and you haven't engaged with BEP directly, this guide will walk you through what it is, what's in it, how to consume it, and what you can build on top of it.

What BEP Is

BEP is a stream of structured, protobuf-encoded events that Bazel emits over the course of a build invocation. Every significant thing that happens during a build — the build starting, a target being configured, an action executing, a test producing results, the build finishing — is represented as a discrete event in the stream.

Each event has a type, an event ID that identifies what it describes, and payload fields specific to that event type. Events reference each other through a parent-child relationship: a BuildStarted event announces child events like TargetConfigured and Progress, which in turn announce their own children. The result is a directed acyclic graph (DAG) of events that fully describes the build.

The protocol is defined in a set of protobuf schema files in the Bazel repository. The top-level message is BuildEvent, and the event type is determined by a oneof field covering all possible event kinds.

Why BEP Matters

The core reason BEP matters is simple: it's the structured interface to everything Bazel knows about a build. Without BEP, you're left parsing unstructured log output — text that can change format between Bazel versions, that mixes progress indicators with actual results, and that was designed for humans to skim, not for machines to process.

BEP gives you machine-readable access to:

  • The exact command that was run, including all flags and options
  • Every target that was configured and whether it succeeded or failed
  • Every action that executed, including its inputs, outputs, exit code, and timing
  • Cache hit/miss status for remote caching
  • Test results, including per-shard and per-run outcomes
  • The critical path — the longest chain of sequential actions
  • Output file locations and their digests
  • Timing data for the entire build lifecycle

This is how you get from "the build was slow" to "this specific action took 47 seconds because it missed the remote cache due to a changed input." BEP is the difference between guessing and knowing.

Key Event Types

The BEP stream contains dozens of event types, but a handful carry the most analytical value. Here are the ones you'll interact with most.

BuildStarted

Emitted once at the beginning of every invocation. Contains the Bazel version, the command that was run (build, test, run, etc.), the workspace directory, and a start timestamp. This is your anchor event — it gives you the metadata you need to identify and correlate the invocation.

OptionsParsed

Contains the full set of startup options and command options that Bazel resolved for this invocation. This includes explicit flags from the command line, options from .bazelrc files, and defaults. Invaluable for understanding why a build behaved the way it did — especially when debugging differences between local and CI builds.

TargetConfigured and TargetComplete

TargetConfigured fires when Bazel has resolved a target's configuration (platform, toolchain, transitions). TargetComplete fires when the target has finished building, and includes the success/failure status and references to output files. Together, these let you track every target through its lifecycle.

ActionExecuted

One of the most information-dense events. Emitted for each action that Bazel runs (or retrieves from cache). Contains the action type (mnemonic like CppCompile, Javac, GoCompile), the target it belongs to, stdout/stderr references, exit code, and — critically — whether the result came from remote cache, remote execution, or local execution. This is the event that powers action-level performance analysis.

TestResult and TestSummary

TestResult is emitted per test shard per test run, with the pass/fail status, execution duration, and log file references. TestSummary aggregates across shards and runs for a given test target, including the overall status and total execution time. If you're tracking flaky tests or test performance trends, these are the events you need.

NamedSetOfFiles

Represents a set of output files, referenced by other events. This is how BEP handles the common case where multiple targets produce overlapping sets of outputs without duplicating the file metadata across events. It uses a tree structure — sets can reference other sets — which makes it memory-efficient but requires some care when traversing.

BuildFinished

The terminal event. Contains the overall exit code and a finish timestamp. Subtracting the BuildStarted timestamp from this gives you wall-clock build duration. Also signals to any consumer that the stream is complete.

BuildMetrics

Emitted at the end of the build with aggregated statistics: total action count, action cache statistics (hits vs. misses), memory usage, CPU time, and other summary metrics. This is the fastest way to get a high-level picture of a build's efficiency.

How to Consume BEP

Bazel provides two primary mechanisms for consuming BEP events: writing to a local file, or streaming to a remote service.

Local file output

The simplest approach. Add the --build_event_binary_file flag to write the entire BEP stream to a local file in binary protobuf format:

bazel build //src:my_target \
  --build_event_binary_file=/tmp/bep.pb

You can also use --build_event_json_file for a JSON representation, which is easier to inspect manually but significantly larger:

bazel build //src:my_target \
  --build_event_json_file=/tmp/bep.json

The binary format is what you want for any programmatic consumption. It's compact, fast to parse, and faithfully represents the protobuf schema. The JSON format is useful for quick debugging — pipe it through jq and you can inspect individual events — but it shouldn't be your production pipeline format.

Remote streaming via BES

For production use, you typically don't want BEP written to local files. You want it streamed in real-time to a remote service that can ingest, store, and analyze it. This is what the Build Event Service (BES) protocol enables.

BES is a gRPC service that Bazel connects to at the start of a build. Bazel streams events to the BES backend as they're produced, and the backend acknowledges receipt. The relevant flags:

bazel build //src:my_target \
  --bes_backend=grpcs://bep.example.com:443 \
  --bes_results_url=https://dashboard.example.com/invocation/

The --bes_backend flag tells Bazel where to stream events. The --bes_results_url flag is optional — it tells Bazel to print a URL at the start of the build where the user can view results in a dashboard. This is how build result UIs like Hermetiq provide a direct link to the invocation as soon as the build starts.

There are additional flags that control BES behavior:

# Upload BEP asynchronously (don't block build completion)
--bes_upload_mode=fully_async

# Set a timeout for BES uploads
--bes_timeout=60s

# Control what happens if the BES backend is unreachable
--bes_check_preceding_lifecycle_events

The --bes_upload_mode=fully_async flag is worth calling out. By default, Bazel waits for the BES backend to acknowledge all events before reporting the build as complete. In async mode, Bazel fires and forgets — the build completes immediately, and event upload continues in the background. This avoids adding latency to every build, but means events could be lost if the process is killed before upload finishes.

BEP vs. Logs: Structured vs. Unstructured

It's worth being explicit about why BEP is fundamentally different from parsing Bazel's console output — because many teams start with log parsing and wonder why it breaks.

Bazel's terminal output is designed for human readability. It uses colors, progress bars, and dynamic line rewriting. It truncates long output. It changes format between versions. It interleaves progress updates with errors in ways that make regex-based parsing fragile.

BEP, by contrast, is a stable, versioned, machine-readable protocol. It has a defined schema. Events have typed fields. Relationships between events are explicit. You don't need to guess whether a line of output is an error or a progress message — the event type tells you exactly what it is.

The practical difference is reliability. A log-parsing pipeline will break when Bazel changes its output format, when a new flag alters the verbosity, or when a build produces output that doesn't match the expected pattern. A BEP-based pipeline works across Bazel versions (with appropriate schema handling) because the protocol is the contract.

If you're currently extracting build metrics from Bazel's stdout or stderr, consider this a strong signal to switch to BEP. The investment pays for itself the first time a Bazel upgrade doesn't break your dashboard.

What You Can Derive from BEP

The raw event stream is useful, but the real value comes from what you build on top of it. Here are the most common and valuable derivations.

Build duration and phase breakdown

By correlating BuildStarted, BuildFinished, and the timestamps on intermediate events, you can break down a build into its phases: loading, analysis, execution, and BES upload. This tells you where the build spends its time — a question that terminal output doesn't answer clearly.

Cache hit rates and miss analysis

ActionExecuted events and the BuildMetrics summary tell you how many actions hit the remote cache versus how many were executed. More importantly, by comparing the action inputs (via the action key) across builds, you can determine why a particular action missed — was it an input file change, a command-line flag change, a platform property change, or a cache eviction?

This is the foundation of cache miss debugging, and it's nearly impossible to do without BEP data.

Test performance and flakiness

TestResult events across multiple invocations let you build a test performance history: which tests are getting slower, which tests fail intermittently, and which tests dominate your test execution time. Flaky test detection — where a test passes and fails on the same commit — requires exactly this kind of longitudinal BEP analysis.

Critical path analysis

The critical path is the longest sequence of dependent actions in the build graph. It represents the theoretical minimum build time — even with infinite parallelism, you can't finish faster than the critical path. BEP provides the action timing and dependency data needed to reconstruct it, which tells you exactly which actions to optimize for maximum impact on build duration.

Remote execution performance

When using remote build execution (RBE), BEP events include detailed phase timing for each remote action: how long it waited in the scheduler queue, how long input fetching took, how long execution took, and how long output upload took. This data is essential for diagnosing whether slow builds are caused by your code, your network, or your execution cluster.

Build cost tracking

If you're running remote execution on cloud infrastructure, the combination of action count, execution duration, and worker resource consumption from BEP lets you attribute infrastructure costs back to specific targets, teams, or repositories. This turns "our build cluster costs too much" into "these 12 targets account for 60% of our compute spend."

Practical Example: Inspecting a BEP Stream

To see what BEP events look like in practice, generate a JSON BEP file and inspect it:

bazel test //src/... \
  --build_event_json_file=/tmp/bep.json

# Find the BuildStarted event
jq 'select(.id.started)' /tmp/bep.json

# Find all ActionExecuted events for a specific target
jq 'select(.id.actionCompleted.label == "//src:mylib")' /tmp/bep.json

# Count cache hits vs. misses
jq 'select(.id.actionCompleted) | .action.strategy' /tmp/bep.json | sort | uniq -c

# Get test results
jq 'select(.id.testResult)' /tmp/bep.json

A simplified ActionExecuted event in JSON looks something like this:

{
  "id": {
    "actionCompleted": {
      "primaryOutput": "bazel-out/k8-fastbuild/bin/src/libmylib.a",
      "label": "//src:mylib",
      "configuration": { "id": "a1b2c3..." }
    }
  },
  "action": {
    "type": "CppCompile",
    "strategy": "remote",
    "exitCode": 0,
    "startTime": "2026-02-21T14:30:01.123Z",
    "endTime": "2026-02-21T14:30:04.567Z",
    "stdout": { "uri": "bytestream://..." },
    "stderr": { "uri": "bytestream://..." }
  }
}

The strategy field tells you how the action was executed: "remote" for remote execution, "remote cache hit" for a cache hit, "local" for local execution, and so on. This single field is the basis for cache hit rate calculations.

Building a BEP Pipeline

If you're building a BEP ingestion pipeline from scratch, the typical architecture looks like this:

  1. Ingest. Run a gRPC server implementing the BES protocol. Bazel connects to it via --bes_backend and streams events. Your server acknowledges each batch and writes the raw events to a durable store (object storage, Kafka, etc.).
  2. Parse. Deserialize the protobuf events. Resolve the event DAG — follow NamedSetOfFiles references, correlate TargetComplete events with their ActionExecuted children, and flatten the tree into queryable records.
  3. Store. Write the parsed data to an analytical store — a time-series database for metrics, a search index for event lookup, or a data warehouse for long-term trend analysis.
  4. Query. Build dashboards, alerts, and APIs on top of the stored data. This is where you surface cache hit rate trends, identify slow actions, detect flaky tests, and calculate build costs.

This is non-trivial to build and maintain. The BEP schema has subtleties — NamedSetOfFiles traversal is recursive, event ordering isn't strictly guaranteed, some events are only emitted for certain build commands, and the schema evolves across Bazel versions. Most teams that try to build this in-house end up with something that works for their specific use case but requires ongoing maintenance as Bazel evolves.

This is, not coincidentally, the core problem Hermetiq solves. We run the BES backend, handle the parsing and storage, and expose the derived analytics through dashboards and an MCP server that AI assistants can query directly.

Common Pitfalls

A few things to watch out for when working with BEP:

  • Event ordering is not strictly sequential. Events are emitted as they become available, not in a guaranteed order. Your consumer must handle out-of-order delivery and use event IDs for correlation, not arrival order.
  • NamedSetOfFiles is a tree, not a flat list. A common mistake is to treat file sets as simple lists. They can reference other file sets, and you need to traverse the tree to get the full set of files.
  • Not all events are emitted in all modes. Some events are only produced during bazel test, others only during bazel build. The ActionExecuted event is only emitted for failed actions by default — you need --build_event_publish_all_actions to get events for successful actions too.
  • BEP streams can be large. A build with thousands of targets and tens of thousands of actions produces a substantial event stream. Your BES backend needs to handle this volume without back-pressuring Bazel and slowing down the build.
  • The JSON format loses type information. If you're using --build_event_json_file for analysis, be aware that some protobuf fields (especially enums and bytes) are represented differently than in the binary format. Use the binary format for anything production-grade.

Getting Started with BEP

If you haven't worked with BEP before, start here:

  1. Generate a JSON BEP file for one of your builds with --build_event_json_file. Open it in your editor and read through the events. Get a feel for the structure.
  2. Look at the protobuf schema. The build_event_stream.proto file is the authoritative reference. It's well-commented and more readable than most protobuf schemas.
  3. Try the binary format with a simple protobuf reader in your language of choice. Parse the events, print the types, and get comfortable with the event DAG structure.
  4. Consider a managed BES backend. If you want the analytics without building the pipeline, Hermetiq ingests BEP natively — point --bes_backend at our endpoint and get dashboards, cache analytics, cost tracking, and MCP-powered AI insights out of the box.

BEP is one of Bazel's most powerful and most underutilized features. It transforms Bazel from a build tool that tells you pass/fail into a build tool that tells you everything. The teams that treat BEP as a first-class data source build faster, debug faster, and understand their builds at a level that log-parsing teams never reach. Whether you build the pipeline yourself or use a managed solution, the first step is the same: start reading your build events.

Hermetiq ingests BEP natively. Point your --bes_backend at Hermetiq and get instant build observability: cache analytics, cost tracking, critical path breakdowns, and AI-powered debugging via MCP. See the product.


Related Articles