Extending Instrumentation#
To add new spans or metrics to NeMo-RL code, use the instrumentation primitives from nemo-lens (managed_span, trace_fn, span_cm). The primitives themselves are documented in lens: instrumentation; this page covers NeMo-RL conventions.
The import pattern#
Every algorithm instrumentation import should go through
nemo_rl.telemetry.instrumentation, which re-exports the lens primitives with
rl.bucket tagging applied to leaf spans:
from nemo_rl.telemetry.instrumentation import (
Bucket,
bucket_scope,
managed_span,
trace_fn,
umbrella_span,
umbrella_trace_fn,
)
from nemo_rl.telemetry.setup import get_telemetry_handle
from nemo_rl.telemetry.span_groups import RLSpanGroup
Never import from nemo.lens.* directly in algorithm code — that is how a span
ends up with no bucket and invisible to the goodput rollup.
Goodput tagging#
managed_span / trace_fn from instrumentation attach rl.bucket ∈
{productive, overhead, idle, wasted} for leaf groups (see
nemo_rl/telemetry/instrumentation.py). Umbrella groups (job, step, rollout,
…) are not tagged. Apps do not emit rolled-up rl.goodput /
rl.bucket.* metrics — the offline monitor SUMs tagged phase / span
durations by bucket.
Umbrella spans say so at the call site#
Whether a span carries a bucket is decided by its group, which used to make the
two cases indistinguishable where it matters most: GENERATION and ROLLOUT
are both plausible for a generation span, and only one of them enters the
rollup. So umbrella spans are opened through their own helper, with the group
spelled using its U_ alias:
from nemo_rl.telemetry.instrumentation import umbrella_span, umbrella_trace_fn
with umbrella_span(
RLSpanGroup.U_PER_PROMPT, "rl.sc.generate_and_push", tracer=tracer
):
...
The U_ names are aliases — RLSpanGroup.U_ROLLOUT == RLSpanGroup.ROLLOUT == "rollout" — so presets, the span_groups spec and every config are unchanged.
The seven are U_JOB, U_STEP, U_ROLLOUT, U_MODEL_INIT, U_EVALUATE,
U_SETUP and U_PER_PROMPT.
Reach for an umbrella whenever a span can overlap another instance of itself: concurrent spans sum past the wall clock they happened in, so a bucket on them multiplies rather than measures. The work is still counted by the leaf spans nested inside.
Neither mistake raises — telemetry does not end a training run. Passing a leaf
group to umbrella_span warns once and emits the span as a leaf, so the phase
keeps the bucket it actually has; the reverse, managed_span on an umbrella
group, is already correct output. Both are rejected by a drift test, which
catches them at review time rather than partway through a run.
To override classification for one site, pass the attribute explicitly:
with managed_span(
RLSpanGroup.GENERATION,
"rl.vllm.generate",
**{"rl.bucket": "productive"},
):
...
To reclassify spans opened below you — where the callee cannot tell why it was
called — wrap the region in bucket_scope instead. Validation uses this so its
generation counts as overhead rather than goodput:
from nemo_rl.telemetry.instrumentation import Bucket, bucket_scope
with bucket_scope(Bucket.OVERHEAD):
... # every leaf span in here is tagged overhead
Umbrellas stay unbucketed inside a scope, and an explicit rl.bucket= still
wins, so a scope cannot make a parent double-count its children. See
span groups.
When adding a new span group, update _DEFAULT_GROUP_BUCKET /
UMBRELLA_GROUPS in instrumentation.py and extend test_instrumentation.py.
Instrumenting inside a Ray actor#
Two things that are easy to get wrong, because both fail silently as no-ops rather than as errors.
The actor needs its own providers: call init_telemetry_worker() in its
__init__ (not post_init, which some fan-outs run on one rank per group), and
flush before it dies. An actor reaped with ray.kill runs no atexit handler,
so expose a method that calls shutdown_telemetry() and have the driver call it
first — AsyncTrajectoryCollector.flush_telemetry is the worked example.
Ray does not propagate OTel context, so the actor’s spans form their own trace
unless you carry the parent across. For a per-call actor method, use the
dispatch/receive pair rather than moving the carrier by hand — decorate the
method with @accepts_trace_context and dispatch it with one of:
# A direct remote() call.
dispatch_with_trace_context(actor.run_rollouts.options(num_returns="streaming"), batch)
# RayWorkerGroup, which names the method by string and forwards **kwargs.
self.worker_group.run_all_workers_sharded_data(
"train_presharded", meta=metas, common_kwargs={**common_kwargs, **trace_context_kwargs()}
)
Both halves are required and only the decorator is visible at the definition, so
a method decorated but dispatched without a carrier still emits root spans while
looking wired. test_every_context_accepting_method_is_dispatched_with_a_carrier
fails the build in that case; when you add a new decorated method, expect that
test to tell you if you forgot the sender.
Reach for current_trace_carrier() / remote_trace_context() directly only
when the context has to outlive a single call — the async trajectory collector
takes its carrier once at construction and reattaches it in every thread it
spawns, because OTel context is a ContextVar and threads inherit none. The
carrier is empty (a harmless no-op) whenever the driver’s enclosing group is
disabled. See
span groups.
Adding a span#
Decorator — trace_fn / umbrella_trace_fn#
For a whole function. rl.vllm.generate is a leaf:
@trace_fn(RLSpanGroup.GENERATION, "rl.vllm.generate")
def generate(self, ...):
...
The rl.<algo>.job spans are umbrellas, so they take the other one:
@umbrella_trace_fn(RLSpanGroup.U_JOB, "rl.grpo.job")
def grpo_train(...):
...
Group-gated block — managed_span / umbrella_span#
For a hot path where you want minimal cost when the group is disabled:
with umbrella_span(RLSpanGroup.U_ROLLOUT, "rl.grpo.generation",
**{"rl.num_prompt_groups": n_groups}) as span:
result = collect()
if span is not None:
span.set_attribute("rl.num_generations_per_prompt", n)
Both yield None when the group is disabled; the body still runs, so guard attribute-setting with if span is not None.
Do not pass rl.iteration here. The training loops set it once per step with
iteration_scope, so any span opened inside a step already carries it — see
rl.iteration is set once per step.
Always-on block — span_cm#
span_cm always creates a span when telemetry is active (no group gate) — for cold, top-level paths only:
telemetry = get_telemetry_handle()
if telemetry is not None:
with span_cm("rl.grpo.job", tracer=telemetry.tracer):
...
Naming conventions#
Kind |
Convention |
Example |
|---|---|---|
Span name |
|
|
Span tag |
|
|
Resource attribute |
|
|
Metric name |
|
|
Metric names use the application scope (rl.*) — never dl.*. This is not a style preference: lens registers a dl.* series for the trainer that owns the step, so an RL job publishing dl.grad_norm would be writing into a co-resident Megatron trainer’s series. rl.grad_norm and rl.learning_rate are deliberate spelling matches for exactly that reason, so query the two by their own names rather than by swapping the prefix. Lens’s own semconv file says the same: RL’s metric series are declared through register_metric_group in this tree and are deliberately not defined there.
Attributes split by who owns the name. RL names every rl.* attribute in nemo_rl/telemetry/, so a rename lands where the producers are. That holds even for the few rl.* keys lens also declares in its semconv (rl.algorithm, rl.reward, rl.generation.backend, rl.num_rollouts): lens declares those and reads none of them, so importing the constant would buy a dependency with no contract behind it while RL remains the only producer. Import from nemo.lens.semconv only for names lens actually acts on — the shared nv.dl.* resource surface — where a typo should fail as an ImportError rather than become silent data loss.
Mind the scope when picking a shared name: nv.dl.topology.size.tp is the resource-scoped parallelism size, while dl.tensor_parallel.size is the span and metric spelling of the same quantity.
Choosing a span group#
Pick from RLSpanGroup before inventing a new one:
Before the first step (Ray init, worker builds, dataloaders)? →
setup, viastartup_span/setup_spanOnce per run, covering the training loop? →
jobOnce per training step? →
stepRollout collection? →
rollout; generation? →generationLog-probs? →
logprob, the reference model’s includedReward / advantage / policy update? →
reward/advantage/policy_updateCheckpoint / eval? →
checkpoint/evaluateTransfer-queue op? →
data_plane
One question cuts across all of those: how many of these spans will a run
emit? If the answer scales with the prompt or sample count rather than with
the step count, the span belongs in per_prompt regardless of which phase it
measures, so that a user picking per_step does not get dataset-sized volume
they did not ask for. per_prompt is an umbrella, so such spans carry no
rl.bucket — which is usually right anyway, since per-prompt work on the async
paths overlaps itself.
If the span is opened somewhere that cannot see whether its caller is
per-prompt — as MetricsDataPlaneClient cannot — wrap the caller’s region in
per_prompt_scope() and consult in_per_prompt_scope() at the span site,
rather than threading a flag through the signatures.
Adding a new span group#
If nothing fits, add a group to RLSpanGroup in nemo_rl/telemetry/span_groups.py:
Add the constant to
RLSpanGroup. TheALL_GROUPSset is computed from the class, so there is nothing to add it to.ALL_GROUPSis whatregister_span_groups()declares to lens, so a group missing from it is never selectable — not even underall.Slot it into the right preset(s) in
_PRESETS. Decide per preset:defaultis coarse (rarely add here);per_stepfor per-step spans. There is noallto edit — lens resolvesallas a wildcard over whatever is registered, so a group inALL_GROUPSis reachable that way automatically. A group whose span count scales with dataset size rather than step count belongs inallonly — seeper_prompt, and add it toPER_PROMPT_GROUPSrather thanEMITTED_GROUPSin the test below.Document the new group in Span Groups, and add it to
EMITTED_GROUPSintests/unit/telemetry/test_span_groups.pyso the preset-reachability test covers it.
Group names live in one flat namespace shared with every other library in the process, so a name Megatron also registers is shared: enabling it enables both libraries’ spans. That is intended for the phases both genuinely have (step, optimizer), and worth avoiding otherwise — lens logs a warning naming the other claimant.
Adding a metric#
NeMo-RL records its rl.* metrics from the driver rather than scattering record calls through the algorithm code (see Metrics). NeMo-RL owns every rl.* metric name: they are declared with lens’s metric registry, so adding one needs no lens change.
If the value already flows through Logger.log_metrics — the common case — declare it next to the code that produces it, not in nemo_rl/telemetry/. Add a TeedMetric row in the module that logs the key and register it there:
from nemo_rl.telemetry.vocabulary import TeedMetric, register_teed_metrics
MY_KEY = "my_logger_key"
MY_TEED_METRICS = (TeedMetric(MY_KEY, "rl.my.metric", kind="gauge", description="..."),)
register_teed_metrics(MY_TEED_METRICS)
Use MY_KEY at the site that writes the dict too. That is the point of declaring it here: one name serves both the logging and the export, so there is no second copy to drift, and nothing in telemetry/ has to be edited to add an algorithm. The registry key is derived from the series name, so it is not typed a second time either.
Existing homes to append to rather than invent a new one: nemo_rl/algorithms/metric_utils.py for keys every training loop logs, the loss that computes a loss metric, and nemo_rl/models/generation/vllm/metric_names.py for the engine series. Rows are collected process-wide and handed to lens once, on the first tee, so an owner that a given run never imports simply declares nothing.
If the series is keyed by a growing label set — e.g. one value per efficiency category — declare one dimensioned series and pass the label through attributes, rather than one series per label. rl.efficiency.seconds is the worked example; rl.setup.duration is the same shape for a label set that is not even knowable at declaration time, since SetupTimingMetrics.extras is filled in at runtime.
If the dict arrives under a prefix other than train, add the dispatch in _tee_rl_metrics_to_otel. Prefixes are matched rather than merged so neither family is scanned for the other’s keys: timing/setup arrives once at step 0 and shares no key with the per-step dicts.
If the value does not go through the Logger at all, declare the row the same way and call record_metrics(meter, RL_METRIC_GROUP, {...}) from wherever it is produced.
Prefer an attribute over a name whenever the label set can grow: rl.efficiency.seconds{rl.efficiency.category="idle/refit_bubble"} stays stable as categories come and go, while rl.efficiency.idle_refit_bubble_seconds forces an instrument change per category. Keep attribute cardinality bounded — a per-step or per-request value belongs on a span, not a metric label.
Keep rl.<subsystem>.<metric> naming and record only non-None values. See lens: metrics.
Testing new instrumentation#
NeMo-RL telemetry tests live under tests/ and use lens’s in-memory exporter fixtures (global OTel state reset per test). When adding a span:
Assert the span is emitted when its group is enabled and absent when disabled.
Assert on span name, tags, and parent relationships.
For a pure metrics-tee change, map_efficiency_seconds in nemo_rl/telemetry/metrics.py is a pure function — unit-test the key mapping directly with no OTel setup.
When not to add instrumentation#
Inside a tight inner loop (per-token) — even a gated
managed_span’s frozenset lookup adds up.On high-cardinality attributes (raw prompts, tensor shapes) — cardinality explosion at the backend.
As a replacement for logging — structured logs belong in logs (correlate via the log bridge,
telemetry.logs_enabled: true).
When in doubt, start with a coarse span at the boundary of a subsystem, not a fine-grained one at every internal call.