Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/design/semconv.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ position: 4
Using constants instead of raw strings provides three key benefits:

1. **Grep-ability**: renaming an attribute across the codebase means changing one constant, not every call site.
2. **Type safety** (weak but real): `DL_RANK` is an exported name; typos become `ImportError`s. `"dl.rank"` typos become silent data loss.
2. **Type safety** (weak but real): `NV_DL_RANK` is an exported name; typos become `ImportError`s. `"nv.dl.rank"` typos become silent data loss.
3. **Central registry**: one file lists every attribute NeMo Lens might emit. Easy to review, easy to document.

Callers who want the string can use `DL_RANK` directly; Python strings-as-constants have no boxing cost.
Callers who want the string can use `NV_DL_RANK` directly; Python strings-as-constants have no boxing cost.

## Version Tracking

Expand Down Expand Up @@ -68,9 +68,9 @@ Shared across Megatron-LM, NeMo RL, NeMo Gym. Anything a distributed training jo
```
nv.dl.job.uuid — stable submitted-job identifier
nv.dl.run.uuid — stable run-attempt identifier
dl.rank — global rank
dl.world_size — total ranks
dl.local_rank — rank on this node
nv.dl.rank — global rank resource attribute
nv.dl.world_size — total ranks resource attribute
nv.dl.local_rank — rank on this node resource attribute
dl.tensor_parallel.{rank,size}
dl.pipeline_parallel.{rank,size}
dl.data_parallel.{rank,size}
Expand Down
2 changes: 2 additions & 0 deletions docs/fern/versions/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ navigation:
path: ../../user-guide/configuration.mdx
- page: "Instrumentation Primitives"
path: ../../user-guide/instrumentation.mdx
- page: "Command Line"
path: ../../user-guide/cli.mdx
- page: "Span Groups"
path: ../../user-guide/span-groups.mdx
- page: "Metrics"
Expand Down
55 changes: 55 additions & 0 deletions docs/user-guide/cli.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: "Command Line"
description: ""
position: 3
---
NeMo Lens installs a `nemo-lens` command for launch scripts and other process
boundaries that need to emit telemetry outside a normal Python application
lifecycle.

## `emit-spans`

`emit-spans` emits completed spans from explicit Unix epoch-second intervals.
Use it when a shell launcher or wrapper has already measured a start and end
time and needs to export that interval as a trace span.

Install the SDK dependencies before using the command:

```bash
pip install 'nemo-lens[sdk]'
```

Pass one or more `--span` values:

```bash
NEMO_LENS_EXPORTER=console nemo-lens emit-spans \
--service nv.dl.launch \
--span launch,1700000000.0,1700000003.0 \
--span python_startup,1700000000.2,1700000001.1,launch
```

Each span is encoded as:

```text
NAME,START_EPOCH_SECONDS,END_EPOCH_SECONDS[,PARENT_NAME]
```

Parent spans can appear before or after their children on the command line. The
command orders them before emission so the exported spans have parent context.
Parent/child intervals are not required to be nested; launch flows sometimes use
short anchor spans as parents for longer measured work.

If any span input is invalid, nothing is emitted. Invalid input includes
malformed fields, duplicate span names, missing parents, parent cycles,
non-finite timestamps, and end times before start times.

`--service` sets the exported `service.name`. Other attributes from
`OTEL_RESOURCE_ATTRIBUTES` are inherited by the SDK resource detector, but
`--service` takes precedence over `service.name` from the environment.

The command uses the same exporter configuration as NeMo Lens tracing. It
defaults to OTLP; set `NEMO_LENS_EXPORTER=console` to write spans to stdout.
Standard OTel endpoint and protocol variables configure OTLP, including the
trace-specific variables, which take precedence over their generic equivalents.

The command exits nonzero if provider setup, flushing, or span export fails.
8 changes: 4 additions & 4 deletions docs/user-guide/resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Every exporter-rank process emits these attributes (set in `providers.py:build_p
| `service.name` | `config.service_name` (populated from `OTEL_SERVICE_NAME` through `NemoLensConfig.from_env()`, default `"nemo"`) | `"megatron-lm"` |
| `service.version` | `nemo.lens.__version__` | `"0.1.0"`, `"0.1.0.post3+gabc1234"` |
| `service.instance.id` | `"{run_id}-rank{rank}"` | `"abc123-rank0"` |
| `dl.rank` | `rank` argument | `0` |
| `dl.world_size` | `world_size` argument | `8` |
| `nv.dl.rank` | `rank` argument | `0` |
| `nv.dl.world_size` | `world_size` argument | `8` |
| `nemo.run.id` | `config.run_id` (auto-generated if empty) | `"abc123"` |
| `nemo.user.id` | `config.user` (if set) | `"my-team"` |
| `deployment.environment` | `DEPLOYMENT_ENV` or `ENVIRONMENT` env var | `"production"` |
Expand Down Expand Up @@ -191,7 +191,7 @@ as consumer defaults for those keys.

### Filter by Global Rank

In Jaeger, use: `dl.rank=0`
In Jaeger, use: `nv.dl.rank=0`

### Compare Distinct Runs

Expand All @@ -206,7 +206,7 @@ Because these are resource attributes instead of span attributes, they apply to
## Attribute Conventions

- **Use standard names.** Apply standard OTel attribute names where they exist, such as `service.*`, `k8s.*`, and `host.*`.
- **Use distributed learning prefix.** Apply the `dl.*` (distributed learning) prefix for training-specific attributes that are shared across consumers.
- **Use distributed learning prefixes.** Apply the `nv.dl.*` prefix for participant resource attributes and the `dl.*` prefix for other training-specific attributes that are shared across consumers.
- **Use project-specific prefixes.** Apply the `<project>.*` prefix for project-specific attributes, such as `megatron.*`, `rl.*`, and `gym.*`.

See [semconv](../design/semconv.mdx) for the full attribute namespace conventions.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ dependencies = [
"opentelemetry-api>=1.20.0",
]

[project.scripts]
nemo-lens = "nemo.lens.cli:main"

[tool.setuptools.dynamic]
version = { attr = "nemo.lens.package_info.__version__" }

Expand Down
249 changes: 249 additions & 0 deletions src/nemo/lens/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Command line tools for nemo-lens."""

from __future__ import annotations

import argparse
import math
import sys
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, TextIO

from opentelemetry import trace
from opentelemetry.context import Context

from nemo.lens.providers import _build_span_emitter_provider
from nemo.lens.span_utilities import emit_span


@dataclass(frozen=True, slots=True)
class _SpanSpec:
name: str
start: float
end: float
parent: str | None = None


@dataclass(frozen=True, slots=True)
class _SpanValidation:
order: list[_SpanSpec]
invalid_count: int = 0


def main(argv: Sequence[str] | None = None) -> int:
"""Run the ``nemo-lens`` command line interface."""
parser = _build_parser()
args = parser.parse_args(argv)

if args.command == "emit-spans":
return _run_emit_spans(args)

parser.error("missing command")
return 2


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="nemo-lens")
subparsers = parser.add_subparsers(dest="command", required=True)

emit_spans = subparsers.add_parser(
"emit-spans",
description="Emit spans from explicit epoch-second intervals.",
help="emit spans from explicit intervals",
)
emit_spans.add_argument("--service", required=True, help="OpenTelemetry service.name")
emit_spans.add_argument(
"--span",
action="append",
default=[],
required=True,
metavar="NAME,START,END[,PARENT]",
help="Span name, start epoch seconds, end epoch seconds, and optional parent name.",
)
return parser


def _run_emit_spans(
args: argparse.Namespace,
*,
span_exporter: Any | None = None,
stderr: TextIO | None = None,
) -> int:
stderr = stderr or sys.stderr
validation = _validate_span_args(args.span, stderr=stderr)
if validation.invalid_count or not validation.order:
print("nothing emitted: invalid span input", file=stderr)
return 1
order = validation.order

try:
provider, tracked_exporter = _build_span_emitter_provider(
args.service,
span_exporter=span_exporter,
)
except (ImportError, ValueError) as exc:
print(f"error: {exc}", file=stderr)
return 2

tracer = trace.get_tracer(args.service)
emitted: dict[str, trace.Span] = {}
flush_succeeded = False
try:
for spec in order:
context = trace.set_span_in_context(emitted[spec.parent]) if spec.parent else Context()
emitted[spec.name] = emit_span(
tracer,
spec.name,
spec.start,
spec.end,
context=context,
)

flush_succeeded = provider.force_flush()
finally:
provider.shutdown()

if not flush_succeeded:
print("error: timed out while flushing spans", file=stderr)
return 2
if tracked_exporter.failed:
print("error: failed to export spans", file=stderr)
return 2

for spec in order:
print(
f" {spec.name:34s} {spec.end - spec.start:8.3f}s"
f"{'' if spec.parent is None else ' -> ' + spec.parent}",
file=stderr,
)
return 0


def _validate_span_args(
span_args: Sequence[str], *, stderr: TextIO | None = None
) -> _SpanValidation:
stderr = stderr or sys.stderr
spans: list[_SpanSpec] = []
invalid_count = 0

for span_arg in span_args:
spec = _parse_span_arg(span_arg, stderr=stderr)
if spec is None:
invalid_count += 1
continue
spans.append(spec)

ordered = _order_span_specs(spans, stderr=stderr)
return _SpanValidation(ordered.order, invalid_count + ordered.invalid_count)


def _parse_span_arg(arg: str, *, stderr: TextIO | None = None) -> _SpanSpec | None:
stderr = stderr or sys.stderr
parts = [part.strip() for part in arg.split(",")]
if len(parts) not in (3, 4):
print(f"error: skipping {arg!r}: expected NAME,START,END[,PARENT]", file=stderr)
return None

name = parts[0]
start = parts[1]
end = parts[2]
parent = parts[3] if len(parts) == 4 else ""

if not name:
print(f"error: skipping {arg!r}: missing span name", file=stderr)
return None

if not start or not end:
print(f"error: skipping {name!r}: missing timestamp", file=stderr)
return None

try:
start_epoch_seconds = float(start)
end_epoch_seconds = float(end)
except ValueError:
print(f"error: skipping {name!r}: unparseable timestamp", file=stderr)
return None

if not math.isfinite(start_epoch_seconds) or not math.isfinite(end_epoch_seconds):
print(f"error: skipping {name!r}: timestamp must be finite", file=stderr)
return None

if end_epoch_seconds < start_epoch_seconds:
print(f"error: skipping {name!r}: end timestamp precedes start timestamp", file=stderr)
return None

return _SpanSpec(name, start_epoch_seconds, end_epoch_seconds, parent or None)


def _order_span_specs(
spans: Sequence[_SpanSpec], *, stderr: TextIO | None = None
) -> _SpanValidation:
stderr = stderr or sys.stderr
invalid_count = 0

name_counts = Counter(spec.name for spec in spans)
duplicate_names = {name for name, count in name_counts.items() if count > 1}
for name in sorted(duplicate_names):
print(f"error: invalid span {name!r}: duplicate span name", file=stderr)
invalid_count += 1

candidate_specs: list[_SpanSpec] = []
candidate_names = {spec.name for spec in spans if spec.name not in duplicate_names}
for spec in spans:
if spec.name in duplicate_names:
continue
if spec.parent in duplicate_names:
print(
f"error: invalid span {spec.name!r}: parent {spec.parent!r} is duplicated",
file=stderr,
)
invalid_count += 1
elif spec.parent is not None and spec.parent not in candidate_names:
print(f"error: invalid span {spec.name!r}: missing parent {spec.parent!r}", file=stderr)
invalid_count += 1
else:
candidate_specs.append(spec)

if invalid_count:
return _SpanValidation([], invalid_count)

order: list[_SpanSpec] = []
ordered_names: set[str] = set()
remaining = list(candidate_specs)

while remaining:
ready = [spec for spec in remaining if spec.parent is None or spec.parent in ordered_names]
if not ready:
for spec in remaining:
print(
f"error: invalid span graph: parent cycle prevents emitting {spec.name!r}",
file=stderr,
)
invalid_count += 1
return _SpanValidation([], invalid_count)

order.extend(ready)
ordered_names.update(spec.name for spec in ready)
remaining = [spec for spec in remaining if spec.name not in ordered_names]

return _SpanValidation(order)


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading