Skip to content

Commit 0ecff89

Browse files
authored
Merge pull request #116 from NVIDIA-NeMo/condensed-traces
feat(tracing): add portable journal file exporter
2 parents 10c6846 + 8229922 commit 0ecff89

11 files changed

Lines changed: 535 additions & 11 deletions

File tree

examples/quickstart/06_tracing.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@
99
Workflow:
1010
1. nooa start-dev # start trace viewer and OTLP receiver
1111
2. uv run python examples/quickstart/06_tracing.py
12-
3. View traces at http://localhost:5001
12+
3. nooa import-traces traces/quickstart-06-journal
13+
4. View traces at http://localhost:5001
1314
"""
1415

16+
from pathlib import Path
17+
1518
from nooa import hidden
19+
from nooa.tracing import enable_tracing, exporters
1620
from nooa.util.quickstart import *
1721

22+
TRACE_DIR = Path("traces/quickstart-06-journal")
23+
1824

1925
class MathAgent(Agent, llm=llm):
2026
"""Agent that performs calculations with full tracing."""
@@ -42,8 +48,9 @@ async def _format(self, value: float) -> str:
4248

4349
@autorun
4450
async def main():
45-
# Tracing is auto-enabled when `nooa start-dev` is running.
46-
# To write JSONL files instead, call enable_tracing(trace_dir="./traces") explicitly.
51+
# The journal file keeps message bodies content-addressed instead of
52+
# repeating the full LLM conversation on every OTLP span.
53+
enable_tracing(exporters=[exporters.journal_file(TRACE_DIR)])
4754

4855
agent = MathAgent()
4956
result = await agent.run("(10 + 5) * 2")
@@ -56,5 +63,6 @@ async def main():
5663
print(" - explain() ellipsis/LLM method, child of run()")
5764
print(" - _format() private helper (also traced)")
5865
print("=" * 80)
59-
print("\nTrace Viewer: http://localhost:5001")
66+
print(f"\nJournal trace: {TRACE_DIR}")
67+
print(f"Import with: nooa import-traces {TRACE_DIR}")
6068
print("=" * 80)

packages/nooa-cli/src/nooa_cli/commands/_otlp_helpers.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
import click
1010

11+
JOURNAL_ENVELOPE_KEY = "nooaJournal"
12+
JOURNAL_FORMAT = "nooa.message_journal"
13+
JOURNAL_VERSION = 1
14+
1115

1216
def validate_endpoint(endpoint: str) -> None:
1317
"""Validate that the endpoint uses an HTTP(S) scheme."""
@@ -102,6 +106,57 @@ def post_annotations(endpoint: str, annotations: list[dict]) -> int:
102106
return imported
103107

104108

109+
def get_journal_record(body: dict) -> dict | None:
110+
"""Return a validated NOOA journal envelope, or ``None`` for other lines."""
111+
record = body.get(JOURNAL_ENVELOPE_KEY)
112+
if not isinstance(record, dict):
113+
return None
114+
if record.get("format") != JOURNAL_FORMAT or record.get("version") != JOURNAL_VERSION:
115+
return None
116+
return record
117+
118+
119+
def post_journal_record(endpoint: str, record: dict, session_id: str) -> bool:
120+
"""POST one portable-file journal record to the viewer.
121+
122+
``session_id`` is authoritative so renaming a trace file and Harbor's
123+
trial-name remapping affect OTLP spans and their journal sideband equally.
124+
Manifest records are accepted as no-ops.
125+
"""
126+
record_type = record.get("type")
127+
if record_type == "manifest":
128+
return True
129+
if record_type == "blocks":
130+
payload = record.get("blocks")
131+
if not isinstance(payload, list):
132+
return False
133+
path = "/v1/journal/blocks"
134+
headers = {"Content-Type": "application/json", "X-Session-Id": session_id}
135+
elif record_type == "call":
136+
source = record.get("call")
137+
if not isinstance(source, dict):
138+
return False
139+
payload = dict(source)
140+
payload["session_id"] = session_id
141+
path = "/v1/journal/calls"
142+
headers = {"Content-Type": "application/json"}
143+
else:
144+
return False
145+
146+
data = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
147+
request = urllib.request.Request(
148+
f"{endpoint.rstrip('/')}{path}",
149+
data=data,
150+
headers=headers,
151+
method="POST",
152+
)
153+
try:
154+
with urllib.request.urlopen(request, timeout=30) as response:
155+
return response.status < 300
156+
except Exception:
157+
return False
158+
159+
105160
def session_exists(endpoint: str, session_id: str) -> bool:
106161
"""Check whether a session already exists in the viewer."""
107162
url = f"{endpoint.rstrip('/')}/api/trace-count?session_id={urllib.parse.quote(session_id)}"

packages/nooa-cli/src/nooa_cli/commands/import_harbor.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
3-
"""Import NVIDIA OO Agents OTLP traces from a Harbor job directory into the viewer.
3+
"""Import NOOA OTLP or portable journal traces from Harbor into the viewer.
44
55
Walks a Harbor job directory (or any directory containing one), finds all
66
traces under ``artifacts/traces/*.jsonl``, enriches them with Harbor metadata
@@ -24,7 +24,9 @@
2424

2525
from ._otlp_helpers import (
2626
check_endpoint_reachable,
27+
get_journal_record,
2728
inject_resource_attrs,
29+
post_journal_record,
2830
post_traces_batch,
2931
session_exists,
3032
validate_endpoint,
@@ -34,7 +36,7 @@
3436

3537

3638
def _find_harbor_traces(root: Path) -> list[Path]:
37-
"""Find all OTLP trace files nested under Harbor artifact directories.
39+
"""Find all OTLP or portable journal trace files under Harbor artifacts.
3840
3941
Harbor copies the container's ``/logs/artifacts/`` to ``trial_dir/artifacts/``
4042
on the host. The agent decides the layout within that directory — a common
@@ -310,7 +312,7 @@ def _import_trace_file(
310312
batch_lines: int,
311313
batch_bytes: int,
312314
) -> tuple[bool, list[str]]:
313-
"""Import one OTLP JSONL file, posting its lines in batches.
315+
"""Import one OTLP or portable journal JSONL file.
314316
315317
Accumulates OTLP bodies and flushes them in batches: many ``resourceSpans``
316318
envelopes are merged into one POST, avoiding one HTTP request per line. A flush
@@ -346,6 +348,12 @@ def flush() -> None:
346348
body = json.loads(raw_line)
347349
except json.JSONDecodeError:
348350
continue
351+
journal_record = get_journal_record(body)
352+
if journal_record is not None:
353+
session_id = str(resource_attrs["session.id"])
354+
if not post_journal_record(endpoint, journal_record, session_id):
355+
errors.append(f"{jsonl_path.name}: failed to post journal record")
356+
continue
349357
if "resourceSpans" not in body:
350358
continue
351359

packages/nooa-cli/src/nooa_cli/commands/import_traces.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
3-
"""Import OTLP trace .jsonl files into the viewer.
3+
"""Import OTLP or portable NOOA journal .jsonl files into the viewer.
44
55
Usage:
66
nooa import-traces ./traces/
@@ -18,16 +18,18 @@
1818

1919
from ._otlp_helpers import (
2020
check_endpoint_reachable,
21+
get_journal_record,
2122
inject_resource_attrs,
2223
post_annotations,
24+
post_journal_record,
2325
post_trace,
2426
session_exists,
2527
validate_endpoint,
2628
)
2729

2830
NAME = "import-traces"
2931

30-
TRACE_EXTENSIONS = {".jsonl"}
32+
TRACE_EXTENSIONS = (".nooa.jsonl", ".jsonl")
3133

3234

3335
def _find_trace_files(path: Path) -> list[Path]:
@@ -80,7 +82,7 @@ def _session_id_from_filename(path: Path) -> str:
8082
help="Batch ID for this import (default: auto-generated).",
8183
)
8284
def command(path: str, endpoint: str, batch_id: str | None):
83-
"""Import OTLP trace .jsonl files into the viewer."""
85+
"""Import OTLP and portable NOOA journal .jsonl files into the viewer."""
8486
target = Path(path)
8587
files = _find_trace_files(target)
8688

@@ -136,6 +138,12 @@ def command(path: str, endpoint: str, batch_id: str | None):
136138
except json.JSONDecodeError:
137139
continue
138140

141+
journal_record = get_journal_record(body)
142+
if journal_record is not None:
143+
if not post_journal_record(endpoint, journal_record, session_id):
144+
errors.append(f"{file.name}:{line_num}: failed to post journal record")
145+
continue
146+
139147
# Handle annotation lines from exported traces
140148
if "annotations" in body and "resourceSpans" not in body:
141149
anns = body["annotations"]

src/nooa/tracing/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
1717
# JSONL only (no viewer required)
1818
enable_tracing(exporters=[exporters.jsonl()])
19+
20+
# Compact portable file (OTLP spans + content-addressed message journal)
21+
enable_tracing(exporters=[exporters.journal_file("./traces")])
1922
"""
2023

2124
from __future__ import annotations

src/nooa/tracing/_journal_exporter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ def _install(self) -> MessageJournalCallback:
7979

8080
with _INSTALL_LOCK:
8181
for cb in litellm.callbacks:
82-
if isinstance(cb, MessageJournalCallback):
82+
# FileMessageJournalCallback subclasses MessageJournalCallback
83+
# to reuse normalization, but is a different sink. Match the
84+
# exact class so exporter construction order cannot mix them.
85+
if isinstance(cb, MessageJournalCallback) and type(cb) is MessageJournalCallback:
8386
cb.add_destination(self._base_url)
8487
return cb
8588

0 commit comments

Comments
 (0)