NativeLink

Observability

NativeLink pushes OTLP and nothing else. Wire it to a collector, get the series into Prometheus under the names the shipped rules expect, and know which environment variables actually do something.

Who this is for: you are running NativeLink for other people and need to see queue times, cache hit rates and worker behaviour on a dashboard. What you'll have at the end: a collector receiving OTLP from every NativeLink process, Prometheus holding the series under the names the shipped recording rules query, and a Grafana dashboard drawing them. Time: 30 minutes.

Before you start

A running deployment you can set environment variables on.

The binary has no /metrics endpoint and no configuration that creates one. NativeLink builds three OTLP exporters at startup (logs, traces and metrics) and pushes to whatever endpoint the environment names. Every Prometheus-shaped thing in this pipeline happens downstream of the process.

That namespace: nativelink on the collector's Prometheus exporter is load-bearing, and it is the single most misunderstood part of this pipeline. The instruments NativeLink declares are named cache.operations, execution.queue.time and so on, with no nativelink prefix anywhere in the binary. The prefix is added by the collector. Bypass the collector and you lose it, along with every shipped recording rule and dashboard panel. See what happens if you skip the collector.

Metrics are Business Source Licensed

Individual developer cache use does not need a commercial licence. Teams using metrics in shared, production or commercial settings can use NativeLink Enterprise or a separate licence. Note that instrumentation cannot be switched off (see Open source and Enterprise), and the operative text is the licence page.

The configuration

Point every NativeLink process at a collector. Two variables carry the whole decision:

export NL_OTEL_ENDPOINT=http://otel-collector:4317
export RUST_LOG=info

NL_OTEL_ENDPOINT builds one client-side load-balanced gRPC channel shared by all three exporters. Leave it unset and each exporter falls back to the OpenTelemetry SDK's own resolution: OTEL_EXPORTER_OTLP_ENDPOINT (or the per-signal OTEL_EXPORTER_OTLP_METRICS_ENDPOINT and friends), defaulting to http://localhost:4317. Both work; the explicit channel is what you want in a cluster, because it re-resolves the collector's DNS as pods move. Either way the transport is OTLP over gRPC: the binary is built with only the gRPC exporter, so OTEL_EXPORTER_OTLP_PROTOCOL cannot switch it to HTTP.

The rest of the surface is small enough to state in full:

VariableDefaultWhat it does here
NL_OTEL_ENDPOINTunsetOne shared gRPC channel for logs, traces and metrics. Panics on a malformed value.
OTEL_EXPORTER_OTLP_ENDPOINThttp://localhost:4317Used only when NL_OTEL_ENDPOINT is unset.
OTEL_EXPORTER_OTLP_COMPRESSIONunsetgzip or zstd on the gRPC exporters; the shipped Kubernetes manifests set zstd.
OTEL_METRIC_EXPORT_INTERVAL60000 (ms)How often metrics flush. NativeLink sets no interval, so the SDK default of one minute applies. This is the first hop in the reaction-time budget; lower it to 10000 before you scale on anything.
RUST_LOGinfoApplies to the stdout layer and to all three OTLP layers. hyper, tonic, h2, reqwest and tower are forced off afterwards and cannot be re-enabled.
NL_LOGprettyStdout log format. compact and json are recognised; any other value silently falls back to pretty.

The complete list, including the variables the config file expands and the ones a worker forwards to actions, is in CLI and environment.

init_tracing

Bring up the pipeline

git clone https://github.com/TraceMachina/nativelink
cd nativelink/deployment-examples/metrics
docker compose up -d

Then point NativeLink at the collector and restart it:

export NL_OTEL_ENDPOINT=http://localhost:4317
export OTEL_METRIC_EXPORT_INTERVAL=10000
nativelink /path/to/config.json5

What comes up, and on which host port:

ServiceHost portNotes
OTel Collector, OTLP gRPC4317Where NativeLink pushes.
OTel Collector, OTLP HTTP4318Unused by NativeLink's gRPC exporters.
OTel Collector, Prometheus exporter9090This is not Prometheus. It is the collector's scrape target.
OTel Collector, own metrics8888otelcol_* series about the collector itself.
OTel Collector, health13133GET /health.
Prometheus UI9091Published as 9091:9090 to avoid colliding with the exporter above.
Grafana3000admin / admin.
Alertmanager9093Optional.
Jaeger UI16686Traces; the collector's traces pipeline forwards to it.
node-exporter9100Optional host metrics.

The shipped collector config also runs a second metrics pipeline that pushes the same stream into Prometheus's own OTLP receiver over HTTP (otlphttp/prometheus). Those series arrive without the nativelink_ prefix, so do not be surprised to find both spellings in the same Prometheus; the rules and dashboard use the prefixed one.

kubectl apply -f deployment-examples/metrics/kubernetes/prometheus.yaml
kubectl apply -f deployment-examples/metrics/kubernetes/otel-collector.yaml

Both manifests live in a nativelink namespace; prometheus.yaml is the one that creates it, so apply it first. The collector's ConfigMap carries the same metrics pipelines as the Compose file (no traces pipeline), with a 1 GiB memory limiter instead of 512 MiB.

Then add the endpoint to every NativeLink container, CAS, scheduler and worker alike:

env:
  - name: NL_OTEL_ENDPOINT
    value: "http://otel-collector:4317"
  - name: OTEL_METRIC_EXPORT_INTERVAL
    value: "10000"
  - name: OTEL_RESOURCE_ATTRIBUTES
    value: "deployment.environment=prod,k8s.cluster.name=main"

On the scrape path, the collector's Prometheus exporter has resource_to_telemetry_conversion enabled, so every resource attribute, including anything you set in OTEL_RESOURCE_ATTRIBUTES, becomes a label (deployment_environment, k8s_cluster_name, service_instance_id). The promote_resource_attributes list in the shipped Prometheus config (k8s.pod.name, k8s.namespace.name, k8s.deployment.name and a dozen more) only governs the collector's second pipeline into Prometheus's own OTLP receiver; on that path attributes not on the list do not become labels on the series (they survive only on target_info).

Prometheus can receive OTLP itself (--web.enable-otlp-receiver), but its receiver is OTLP over HTTP at /api/v1/otlp/v1/metrics, and NativeLink's exporters are built with the gRPC transport only. Setting OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf does nothing, so there is no working configuration that sends the binary's metrics straight into Prometheus. Something that speaks OTLP/gRPC has to sit in between; the shipped collector is that something, and its otlphttp/prometheus exporter is exactly this hop.

Read what happens if you skip the collector before pointing the binary at any OTLP/gRPC receiver other than the shipped collector: it renames every metric.

What happens if you skip the collector

The namespace: nativelink setting on the collector's Prometheus exporter is what produces every metric name you have ever seen in a NativeLink dashboard. Send the OTLP stream anywhere else (another collector without that setting, an OTLP-native backend, or the shipped collector's own otlphttp/prometheus pipeline into Prometheus's receiver) and the names change:

PathSeries name for the cache.operations counter
NativeLink → collector → Prometheus scrapes the prometheus exporternativelink_cache_operations_total
NativeLink → OTLP receiver that adds no namespacecache_operations_total (or cache.operations_total, depending on the receiver's name translation)

Everything shipped under deployment-examples/metrics/ (the recording rules, the Grafana dashboard, the alert expressions) is written against the first spelling. Against the second they all evaluate to an empty vector, which on a dashboard renders as a flat healthy zero rather than as a missing signal.

If you take the un-prefixed path anyway, either rewrite the rules without the prefix or add a metric_relabel_config that puts it back. The collector path is the one the repository supports.

Counters gain _total, histograms fan out

The exporter also rewrites OTLP names into Prometheus form: dots become underscores, monotonic counters gain a _total suffix, and each histogram becomes _bucket, _sum and _count series. The metrics reference records the exact result for every instrument.

Turn on cache metrics

Every family except cache metrics is emitted with no configuration. Cache metrics exist only for stores you explicitly wrap:

{
  name: "CAS_MAIN_STORE",
  cache_metrics: {
    cache_type: "cas",
    backend: {
      filesystem: {
        content_path: "/var/lib/nativelink/content_path-cas",
        temp_path: "/var/lib/nativelink/tmp_path-cas",
      },
    },
  },
}

With cache_metrics absent, NativeLink constructs the same store graph without a wrapper, a timer, an attribute allocation or an OpenTelemetry call on the hot path. The feature costs nothing when it is off, which is why it is off.

Wrap the stores you actually want to measure, usually CAS_MAIN_STORE and the AC. Wrapping every layer of a fast_slow composition gives you one series per layer and a hit-rate query that no longer means what you think it means.

Which metrics exist

The authority is the metrics reference, which is generated from nativelink-util/src/metrics.rs and its call sites. It records every declared instrument, its type and unit, its histogram buckets, and (the part no hand-written table stays honest about) whether anything in the binary ever emits it.

As of v1.6.5 the 30 declared instruments fall into eight families. Which process emits each one decides where you have to point the collector:

FamilySeries prefixEmitted byWhat it tells you
Cachenativelink_cache_*Any process with a store wrapped in cache_metricsHit rate, operation latency, bytes in and out, per cache type
Executionnativelink_execution_*The schedulerQueue time, stage durations, active and completed counts, retries, and per-action CPU time and peak memory as reported by the worker that ran it
Worker fleetnativelink_worker_*The schedulerWorkers connected, joins and departures by reason, keepalives, and how many are paused or draining
Schedulernativelink_scheduler_matching_*The schedulerHow long each matching pass takes and whether it succeeded
gRPC servernativelink_rpc_server_duration_*Every process, for every gRPC service it servesRequest latency by service, method and status code
Store tiernativelink_store_tier_*Any process with a fast_slow storeFast and slow tier hits, misses and stale reads, and bytes read from each tier
Healthnativelink_health_checks_*Every process, on each run of the health endpointHealth check outcomes per component: ok, initializing, warning, failed or timeout
Connection poolnativelink_connection_*Any process with a gRPC connection pool or a Redis storePool headroom, acquisitions that had to queue, and reconnects

Three things worth knowing before you build a dashboard:

nativelink_cache_size and nativelink_cache_entries are declared and never emitted. The shipped recording rules and the metrics README query both. Those rules have always returned nothing.

Execution and worker fleet metrics come from the scheduler process only, and they describe the fleet as that scheduler instance sees it. With several scheduler replicas, aggregate across instances before reading a fleet total.

The shipped recording rules and dashboard predate the worker, gRPC, scheduler, store tier, health and connection pool families. Nothing under deployment-examples/metrics/ queries them yet, so the queries below are the starting point.

Queries worth having

Cache hit rate, per cache type:

sum(rate(nativelink_cache_operations_total{cache_operation_result="hit"}[5m])) by (cache_type) /
sum(rate(nativelink_cache_operations_total{cache_operation_name="read"}[5m])) by (cache_type)

How long actions wait before a worker picks them up, the number that tells you whether to add workers:

histogram_quantile(0.95,
  sum(rate(nativelink_execution_queue_time_bucket[5m])) by (le, instance_name)
)

Actions currently queued, which is the closest thing to a queue depth:

sum(nativelink_execution_active_count{execution_stage="queued"})

Success rate:

sum(rate(nativelink_execution_completed_count_total{execution_result="success"}[5m])) /
sum(rate(nativelink_execution_completed_count_total[5m]))

Workers connected to each scheduler instance, and how many of them are paused or draining:

sum(nativelink_worker_connected_count) by (service_instance_id)
sum(nativelink_worker_state_count) by (worker_state)

Why workers are leaving, which separates a scaling event from a fleet that is being evicted for timeouts:

sum(rate(nativelink_worker_disconnections_total[5m])) by (worker_disconnect_reason)

Matching pass latency, which is the scheduler's own cost per pass rather than how long actions wait:

histogram_quantile(0.95, sum(rate(nativelink_scheduler_matching_duration_bucket[5m])) by (le))

gRPC error rate per method, across every process:

sum(rate(nativelink_rpc_server_duration_count{rpc_grpc_status_code!="0"}[5m])) by (rpc_service, rpc_method) /
sum(rate(nativelink_rpc_server_duration_count[5m])) by (rpc_service, rpc_method)

Fast tier hit rate of a fast_slow store, which is what tells you whether the local disk in front of a bucket is earning its keep:

sum(rate(nativelink_store_tier_operations_total{store_tier="fast",store_result="hit"}[5m])) /
sum(rate(nativelink_store_tier_operations_total{store_tier="fast"}[5m]))

Peak memory per action at the 95th percentile, which is the number to size worker pods from (the scheduler records this sample with an empty execution_instance, so there is nothing useful to group by):

histogram_quantile(0.95, sum(rate(nativelink_execution_peak_memory_bucket[5m])) by (le))

The repository ships 34 more as recording rules in prometheus-recording-rules.yml, grouped into nativelink_execution, nativelink_cache, nativelink_performance and nativelink_slo. Loading that file gives you nativelink:execution_queue_time_p95 and friends as pre-computed series. The shipped dashboard does not query them; its panels read the raw nativelink_execution_* series directly.

The Grafana dashboard

A reference dashboard lives at nativelink-overview.json. The Compose stack provisions it automatically. Elsewhere: Dashboards → New → Import, paste the JSON, pick your Prometheus data source. It draws execution throughput, success rate, active actions by stage and stage transitions from nativelink_execution_*; it has no cache, worker or gRPC panels.

If you add panels bound to nativelink_cache_size or nativelink_cache_entries, they will be empty forever. That is the metric, not your pipeline.

init_tracing metrics.rs otel-collector-config.yaml

You did it right if

curl http://localhost:13133/health returns healthy. The collector's pipeline check is on, so this also fails when its exporters are failing.

curl -s http://localhost:8888/metrics | grep otelcol_receiver_accepted_metric_points is non-zero and climbing. If it is zero, NativeLink is not reaching the collector.

curl -s http://localhost:9090/metrics | grep nativelink_execution shows series on the collector's exporter, with the nativelink_ prefix present.

In Prometheus at http://localhost:9091, nativelink_execution_queue_time_bucket returns data after one build.

If the nativelink_cache_* series are missing but nativelink_execution_* are present, no store is wrapped in cache_metrics.

Troubleshooting

SymptomCauseFix
Process panics at startup with a URL errorNL_OTEL_ENDPOINT has no scheme or no explicit portUse http://host:4317
No metrics anywhere, process healthyNothing is pushing; check otelcol_receiver_accepted_metric_points on :8888Verify the endpoint and that it is set on the process you are looking for
Metrics appear a minute lateOTEL_METRIC_EXPORT_INTERVAL defaults to 60 sSet 10000
nativelink_execution_* present, nativelink_cache_* missingNo store is wrappedTurn on cache metrics
Metrics exist under names with no nativelink_ prefixThey came through an OTLP receiver that adds no namespace (the shipped collector's otlphttp/prometheus pipeline does this alongside the scrape path)Skip the collector
No execution or worker metrics, cache metrics fineThe collector endpoint is set on the CAS but not on the scheduler processSet NL_OTEL_ENDPOINT on the scheduler; those families are emitted from it alone
nativelink_worker_* or nativelink_rpc_* series missing entirelyThe binary is older than the release these families were added inUpgrade to v1.6.5; the metrics reference is generated from the version it names
Cache-size panels flat at zeroThose instruments are never emittedDeclared but never emitted
out of order sample in Prometheus logsMultiple processes with restart-fresh service.instance.idRaise storage.tsdb.out_of_order_time_window in prometheus.yml (the shipped Kubernetes config sets 30m)
Metrics stop when a worker OOMs and returnsExpected; the new process gets a new service.instance.idAggregate away from service_instance_id in your queries

FAQ

What's next

NextThe metrics reference

Every instrument, its buckets, its attributes, and whether anything emits it, generated from the source.

SidewaysScale on these signals

The reaction-time budget, and the five gaps between a metric and a working HPA.

SidewaysWhat to do when a signal fires

The incidents that page you, and the sequence that resolves each.

On this page