nooa-cli 0.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,512 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Import NVIDIA OO Agents OTLP traces from a Harbor job directory into the viewer.
4
+
5
+ Walks a Harbor job directory (or any directory containing one), finds all
6
+ traces under ``artifacts/traces/*.jsonl``, enriches them with Harbor metadata
7
+ (trial name, task name, reward score, experiment grouping), and posts them to
8
+ the viewer.
9
+
10
+ Usage:
11
+ nooa import-harbor ./jobs/my-job/
12
+ nooa import-harbor ./workspaces/ --endpoint http://host:5001
13
+ nooa import-harbor ./jobs/ --experiment my-eval --batch-id run-42
14
+ """
15
+
16
+ import json
17
+ import time
18
+ import urllib.parse
19
+ import urllib.request
20
+ import uuid
21
+ from pathlib import Path
22
+
23
+ import click
24
+
25
+ from ._otlp_helpers import (
26
+ check_endpoint_reachable,
27
+ inject_resource_attrs,
28
+ post_traces_batch,
29
+ session_exists,
30
+ validate_endpoint,
31
+ )
32
+
33
+ NAME = "import-harbor"
34
+
35
+
36
+ def _find_harbor_traces(root: Path) -> list[Path]:
37
+ """Find all OTLP trace files nested under Harbor artifact directories.
38
+
39
+ Harbor copies the container's ``/logs/artifacts/`` to ``trial_dir/artifacts/``
40
+ on the host. The agent decides the layout within that directory — a common
41
+ convention is ``artifacts/traces/*.jsonl``, but we search the full subtree
42
+ to be robust to other layouts.
43
+ """
44
+ return sorted(root.rglob("artifacts/**/*.jsonl"))
45
+
46
+
47
+ def _read_json(path: Path) -> dict:
48
+ """Read a JSON file, returning an empty dict on any failure."""
49
+ try:
50
+ return json.loads(path.read_text())
51
+ except Exception:
52
+ return {}
53
+
54
+
55
+ def _coerce_float(value: object) -> float | None:
56
+ """Coerce a value to float, returning None if it cannot be coerced."""
57
+ try:
58
+ return float(value) # type: ignore[arg-type]
59
+ except (TypeError, ValueError, OverflowError):
60
+ return None
61
+
62
+
63
+ def _read_score(trial_dir: Path, trial_result: dict) -> float | None:
64
+ """Read the trial reward score, trying current Harbor result shapes in order.
65
+
66
+ Different Harbor/BinPool versions write the scalar reward in different places.
67
+ Fallback order (first coercible float wins):
68
+
69
+ 1. ``verifier/reward.json["score"]``
70
+ 2. ``verifier/reward.json["reward"]``
71
+ 3. ``result.json["verifier_result"]["rewards"]["score"]``
72
+ 4. ``result.json["verifier_result"]["rewards"]["reward"]``
73
+ 5. ``verifier/reward.txt`` (plain float string)
74
+
75
+ Explicit ``None`` checks (not truthiness) ensure a valid ``0.0`` is returned.
76
+ """
77
+ reward_json = _read_json(trial_dir / "verifier" / "reward.json")
78
+ for key in ("score", "reward"):
79
+ score = _coerce_float(reward_json.get(key))
80
+ if score is not None:
81
+ return score
82
+
83
+ verifier_result = trial_result.get("verifier_result")
84
+ rewards = verifier_result.get("rewards") if isinstance(verifier_result, dict) else None
85
+ if isinstance(rewards, dict):
86
+ for key in ("score", "reward"):
87
+ score = _coerce_float(rewards.get(key))
88
+ if score is not None:
89
+ return score
90
+
91
+ reward_txt = trial_dir / "verifier" / "reward.txt"
92
+ if reward_txt.exists():
93
+ return _coerce_float(reward_txt.read_text().strip())
94
+
95
+ return None
96
+
97
+
98
+ def _trial_meta(jsonl_path: Path) -> dict:
99
+ """Extract Harbor metadata for a trace file from its surrounding directory structure.
100
+
101
+ Expected layout::
102
+
103
+ <job_dir>/
104
+ result.json ← job-level (stats.evals for experiment name)
105
+ <trial_name>/
106
+ result.json ← trial_name, task_name, agent_info
107
+ verifier/
108
+ reward.json ← {"reward"|"score": <float>} (or reward.txt)
109
+ artifacts/ ← copy of /logs/artifacts/ from container
110
+ [traces/] ← agent-defined layout; traces can be here
111
+ <file>.jsonl ← this file
112
+ """
113
+ # Walk up from the JSONL file to find the 'artifacts' directory;
114
+ # trial_dir is its parent (works regardless of depth under artifacts/).
115
+ trial_dir = jsonl_path.parent
116
+ for parent in jsonl_path.parents:
117
+ if parent.name == "artifacts":
118
+ trial_dir = parent.parent
119
+ break
120
+ job_dir = trial_dir.parent
121
+
122
+ trial_result = _read_json(trial_dir / "result.json")
123
+ job_result = _read_json(job_dir / "result.json")
124
+
125
+ trial_name = trial_result.get("trial_name") or trial_dir.name
126
+ task_name = trial_result.get("task_name", "")
127
+ config = trial_result.get("config") if isinstance(trial_result.get("config"), dict) else {}
128
+ agent_config = config.get("agent") if isinstance(config.get("agent"), dict) else {}
129
+ task_config = config.get("task") if isinstance(config.get("task"), dict) else {}
130
+ agent_name = (trial_result.get("agent_info") or {}).get("name", "") or agent_config.get(
131
+ "name", ""
132
+ )
133
+ model_name = agent_config.get("model_name", "")
134
+ agent_type = ""
135
+ kwargs = agent_config.get("kwargs") if isinstance(agent_config.get("kwargs"), dict) else {}
136
+ if kwargs:
137
+ agent_type = str(kwargs.get("agent_type") or "")
138
+ source = trial_result.get("source") or task_config.get("source") or ""
139
+
140
+ # Reward scalar lives in different places across Harbor versions; see _read_score.
141
+ score = _read_score(trial_dir, trial_result)
142
+
143
+ # Keep the Harbor eval key as metadata, but group viewer Evaluations by
144
+ # Harbor job by default. The eval key is usually broad (for example,
145
+ # "nemo-oo-agents__swebench_all") and otherwise collapses separate model
146
+ # jobs into one row.
147
+ harbor_eval = ""
148
+ evals = (job_result.get("stats") or {}).get("evals") or {}
149
+ if evals:
150
+ harbor_eval = next(iter(evals))
151
+
152
+ return {
153
+ "trial_name": trial_name,
154
+ "task_name": task_name,
155
+ "agent_name": agent_name,
156
+ "agent_type": agent_type,
157
+ "model_name": model_name,
158
+ "source": source,
159
+ "started_at": trial_result.get("started_at", ""),
160
+ "finished_at": trial_result.get("finished_at", ""),
161
+ "score": score,
162
+ "harbor_eval": harbor_eval,
163
+ "experiment": job_dir.name or harbor_eval or "harbor",
164
+ "job_name": job_dir.name,
165
+ }
166
+
167
+
168
+ def _harbor_resource_attrs(meta: dict, experiment: str, batch_id: str) -> dict[str, str | bool]:
169
+ """Build viewer resource attrs that make a Harbor trial appear as an eval row."""
170
+ attrs: dict[str, str | bool] = {
171
+ "session.id": meta["trial_name"],
172
+ "experiment": experiment,
173
+ "batch_id": batch_id,
174
+ "eval.test_id": meta["task_name"] or meta["trial_name"],
175
+ "eval.test_name": meta["task_name"] or meta["trial_name"],
176
+ "eval.display_name": meta["task_name"] or meta["trial_name"],
177
+ "eval.method": "harbor",
178
+ "eval.harbor_trial_name": meta["trial_name"],
179
+ }
180
+ if meta.get("model_name"):
181
+ attrs["eval.model"] = str(meta["model_name"])
182
+ if meta.get("agent_type"):
183
+ attrs["eval.agent_class"] = str(meta["agent_type"])
184
+ elif meta.get("agent_name"):
185
+ attrs["eval.agent_class"] = str(meta["agent_name"])
186
+ if meta.get("agent_name"):
187
+ attrs["eval.agent_name"] = str(meta["agent_name"])
188
+ if meta.get("source"):
189
+ attrs["eval.suite_name"] = str(meta["source"])
190
+ if meta.get("harbor_eval"):
191
+ attrs["eval.harbor_eval"] = str(meta["harbor_eval"])
192
+ if meta.get("score") is not None:
193
+ score = meta["score"]
194
+ attrs["eval.score"] = str(score)
195
+ attrs["eval.weighted_score"] = str(score)
196
+ attrs["eval.passed"] = score >= 1.0
197
+ return attrs
198
+
199
+
200
+ def _find_matching_live_session(endpoint: str, meta: dict, experiment: str) -> str | None:
201
+ """Ask the viewer for the live-streamed session matching this Harbor trial."""
202
+ task_name = meta.get("task_name")
203
+ if not task_name:
204
+ return None
205
+
206
+ def request_match(search_experiment: str) -> str | None:
207
+ query = {
208
+ "task_name": task_name,
209
+ "model": meta.get("model_name") or "",
210
+ "started_at": meta.get("started_at") or "",
211
+ "finished_at": meta.get("finished_at") or "",
212
+ "experiment": search_experiment,
213
+ }
214
+ url = f"{endpoint.rstrip('/')}/api/eval/match-session?{urllib.parse.urlencode(query)}"
215
+ try:
216
+ req = urllib.request.Request(url, method="GET")
217
+ with urllib.request.urlopen(req, timeout=10) as resp:
218
+ if resp.status >= 300:
219
+ return None
220
+ data = json.loads(resp.read().decode("utf-8"))
221
+ except Exception:
222
+ return None
223
+ match = data.get("match") if isinstance(data, dict) else None
224
+ if not isinstance(match, dict):
225
+ return None
226
+ session_id = match.get("session_id")
227
+ return str(session_id) if session_id else None
228
+
229
+ return request_match("default") or request_match(experiment)
230
+
231
+
232
+ def _build_eval_only_body(resource_attrs: dict[str, str | bool]) -> dict:
233
+ """Build a minimal OTLP payload that enriches/creates one viewer eval session."""
234
+ now_ns = time.time_ns()
235
+ passed = bool(resource_attrs.get("eval.passed", False))
236
+ score = resource_attrs.get("eval.score")
237
+ span_attrs: list[dict] = [{"key": "eval.passed", "value": {"boolValue": passed}}]
238
+ if score is not None:
239
+ try:
240
+ span_attrs.append({"key": "eval.score", "value": {"doubleValue": float(score)}})
241
+ span_attrs.append(
242
+ {"key": "eval.weighted_score", "value": {"doubleValue": float(score)}}
243
+ )
244
+ except (TypeError, ValueError):
245
+ span_attrs.append({"key": "eval.score", "value": {"stringValue": str(score)}})
246
+
247
+ def value(v: str | bool) -> dict:
248
+ if isinstance(v, bool):
249
+ return {"boolValue": v}
250
+ return {"stringValue": str(v)}
251
+
252
+ return {
253
+ "resourceSpans": [
254
+ {
255
+ "resource": {
256
+ "attributes": [
257
+ {"key": key, "value": value(val)} for key, val in resource_attrs.items()
258
+ ]
259
+ },
260
+ "scopeSpans": [
261
+ {
262
+ "scope": {"name": "harbor-import"},
263
+ "spans": [
264
+ {
265
+ "traceId": uuid.uuid4().hex,
266
+ "spanId": uuid.uuid4().hex[:16],
267
+ "name": "eval",
268
+ "kind": 1,
269
+ "startTimeUnixNano": str(now_ns),
270
+ "endTimeUnixNano": str(now_ns),
271
+ "attributes": span_attrs,
272
+ "status": {"code": 1 if passed else 2, "message": ""},
273
+ }
274
+ ],
275
+ }
276
+ ],
277
+ }
278
+ ]
279
+ }
280
+
281
+
282
+ def _trial_dirs(root: Path) -> list[Path]:
283
+ """Find Harbor trial directories under a job dir or parent dir."""
284
+ roots = [root]
285
+ roots.extend(p.parent for p in root.rglob("result.json") if p.parent != root)
286
+ out: list[Path] = []
287
+ seen: set[Path] = set()
288
+ for candidate in roots:
289
+ if candidate in seen:
290
+ continue
291
+ seen.add(candidate)
292
+ result = candidate / "result.json"
293
+ if not result.exists():
294
+ continue
295
+ data = _read_json(result)
296
+ if data.get("trial_name") or (candidate / "config.json").exists():
297
+ out.append(candidate)
298
+ return sorted(out)
299
+
300
+
301
+ def _trial_meta_from_dir(trial_dir: Path) -> dict:
302
+ """Extract Harbor metadata when only a trial directory is available."""
303
+ return _trial_meta(trial_dir / "artifacts" / "traces" / "_synthetic.jsonl")
304
+
305
+
306
+ def _import_trace_file(
307
+ endpoint: str,
308
+ jsonl_path: Path,
309
+ resource_attrs: dict[str, str | bool | int],
310
+ batch_lines: int,
311
+ batch_bytes: int,
312
+ ) -> tuple[bool, list[str]]:
313
+ """Import one OTLP JSONL file, posting its lines in batches.
314
+
315
+ Accumulates OTLP bodies and flushes them in batches: many ``resourceSpans``
316
+ envelopes are merged into one POST, avoiding one HTTP request per line. A flush
317
+ is triggered when the batch reaches ``batch_lines`` envelopes or ``batch_bytes``
318
+ of raw input (an approximation of the eventual POST size). Returns
319
+ ``(file_imported, errors)`` where ``file_imported`` is True if any flush
320
+ succeeded (preserving the previous any-success semantics).
321
+ """
322
+ file_imported = False
323
+ errors: list[str] = []
324
+ batch: list[dict] = []
325
+ batch_input_bytes = 0
326
+ flush_count = 0
327
+
328
+ def flush() -> None:
329
+ nonlocal file_imported, batch, batch_input_bytes, flush_count
330
+ if not batch:
331
+ return
332
+ flush_count += 1
333
+ if post_traces_batch(endpoint, batch):
334
+ file_imported = True
335
+ else:
336
+ errors.append(f"{jsonl_path.name}: batch #{flush_count} failed to post")
337
+ batch = []
338
+ batch_input_bytes = 0
339
+
340
+ with open(jsonl_path) as f:
341
+ for raw_line in f:
342
+ raw_line = raw_line.strip()
343
+ if not raw_line:
344
+ continue
345
+ try:
346
+ body = json.loads(raw_line)
347
+ except json.JSONDecodeError:
348
+ continue
349
+ if "resourceSpans" not in body:
350
+ continue
351
+
352
+ inject_resource_attrs(body, resource_attrs)
353
+ batch.append(body)
354
+ # Approximation: raw line length before injection; the re-serialized
355
+ # POST body (with injected resource attrs) is slightly larger.
356
+ batch_input_bytes += len(raw_line)
357
+
358
+ if len(batch) >= batch_lines or batch_input_bytes >= batch_bytes:
359
+ flush()
360
+
361
+ flush()
362
+
363
+ return file_imported, errors
364
+
365
+
366
+ @click.command()
367
+ @click.argument("path", type=click.Path(exists=True))
368
+ @click.option(
369
+ "--endpoint",
370
+ default="http://localhost:5001",
371
+ show_default=True,
372
+ help="Viewer API endpoint.",
373
+ )
374
+ @click.option(
375
+ "--experiment",
376
+ default=None,
377
+ help="Override experiment name (default: Harbor job directory name).",
378
+ )
379
+ @click.option(
380
+ "--batch-id",
381
+ default=None,
382
+ help="Batch ID for this import (default: job directory name).",
383
+ )
384
+ @click.option(
385
+ "--batch-lines",
386
+ default=1000,
387
+ show_default=True,
388
+ help="Max OTLP lines combined into a single POST (per trace file).",
389
+ )
390
+ @click.option(
391
+ "--batch-bytes",
392
+ default=4_000_000,
393
+ show_default=True,
394
+ help="Max raw input bytes accumulated before flushing a POST (per trace file).",
395
+ )
396
+ @click.option(
397
+ "--eval-only",
398
+ is_flag=True,
399
+ help="Post Harbor result metadata as eval spans without importing trace JSONL files.",
400
+ )
401
+ def command(
402
+ path: str,
403
+ endpoint: str,
404
+ experiment: str | None,
405
+ batch_id: str | None,
406
+ batch_lines: int,
407
+ batch_bytes: int,
408
+ eval_only: bool,
409
+ ):
410
+ """Import NVIDIA OO Agents OTLP traces from a Harbor job directory.
411
+
412
+ \b
413
+ PATH can be:
414
+ - A Harbor job directory (contains result.json + trial subdirs)
415
+ - Any parent directory — traces are discovered recursively
416
+
417
+ \b
418
+ Examples:
419
+ nooa import-harbor ./jobs/my-job/
420
+ nooa import-harbor ./workspaces/ --endpoint http://host:5001
421
+ nooa import-harbor ./jobs/ --experiment my-eval
422
+ nooa import-harbor ./jobs/ --batch-lines 2000 --batch-bytes 8000000
423
+
424
+ OTLP lines are posted in batches (combining many resourceSpans into one
425
+ request) to keep large imports fast; tune with --batch-lines/--batch-bytes.
426
+ """
427
+ root = Path(path)
428
+ files = _find_harbor_traces(root)
429
+
430
+ validate_endpoint(endpoint)
431
+
432
+ if not check_endpoint_reachable(endpoint):
433
+ click.echo(f"Cannot reach viewer at {endpoint}. Is it running?")
434
+ raise SystemExit(1)
435
+
436
+ imported = 0
437
+ skipped = 0
438
+ already_exist = 0
439
+ errors = []
440
+
441
+ if eval_only:
442
+ trial_dirs = _trial_dirs(root)
443
+ if not trial_dirs:
444
+ click.echo(f"No Harbor trial result directories found under {path}")
445
+ raise SystemExit(1)
446
+ click.echo(f"Found {len(trial_dirs)} Harbor trial result(s)...")
447
+ for trial_dir in trial_dirs:
448
+ meta = _trial_meta_from_dir(trial_dir)
449
+ session_id = meta["trial_name"]
450
+ exp = experiment or meta["experiment"]
451
+ bid = batch_id or meta["job_name"]
452
+ resource_attrs = _harbor_resource_attrs(meta, exp, bid)
453
+ matched_session_id = _find_matching_live_session(endpoint, meta, exp)
454
+ if matched_session_id:
455
+ resource_attrs["session.id"] = matched_session_id
456
+ body = _build_eval_only_body(resource_attrs)
457
+ if post_traces_batch(endpoint, [body]):
458
+ imported += 1
459
+ score_str = f"{meta['score']:.3f}" if meta["score"] is not None else "n/a"
460
+ match_str = f" -> {matched_session_id}" if matched_session_id else ""
461
+ click.echo(
462
+ f" + {session_id}{match_str} score={score_str} task={meta['task_name']}"
463
+ )
464
+ else:
465
+ skipped += 1
466
+ errors.append(f"{session_id}: failed to post eval metadata")
467
+ else:
468
+ if not files:
469
+ click.echo(f"No Harbor trace files found under {path}")
470
+ click.echo("Expected: <job>/<trial>/artifacts/traces/*.jsonl")
471
+ click.echo("Tip: use --eval-only to group Harbor result metadata without trace files")
472
+ raise SystemExit(1)
473
+
474
+ click.echo(f"Found {len(files)} trace file(s)...")
475
+
476
+ for jsonl_path in files:
477
+ meta = _trial_meta(jsonl_path)
478
+ session_id = meta["trial_name"]
479
+ exp = experiment or meta["experiment"]
480
+ bid = batch_id or meta["job_name"]
481
+
482
+ if session_exists(endpoint, session_id):
483
+ click.echo(f" ! {session_id}: already exists, skipping")
484
+ already_exist += 1
485
+ continue
486
+
487
+ resource_attrs = _harbor_resource_attrs(meta, exp, bid)
488
+
489
+ file_imported, file_errors = _import_trace_file(
490
+ endpoint, jsonl_path, resource_attrs, batch_lines, batch_bytes
491
+ )
492
+ errors.extend(file_errors)
493
+
494
+ if file_imported:
495
+ imported += 1
496
+ score_str = f"{meta['score']:.3f}" if meta["score"] is not None else "n/a"
497
+ click.echo(f" + {session_id} score={score_str} task={meta['task_name']}")
498
+ else:
499
+ skipped += 1
500
+
501
+ click.echo(f"\n{imported} imported, {skipped} skipped, {already_exist} already existed")
502
+ if errors:
503
+ for err in errors[:10]:
504
+ click.echo(f" ! {err}")
505
+ if len(errors) > 10:
506
+ click.echo(f" ... and {len(errors) - 10} more errors")
507
+
508
+ if imported:
509
+ encoded_batch = urllib.parse.quote(bid or "", safe="")
510
+ encoded_exp = urllib.parse.quote(exp or "", safe="")
511
+ click.echo(f"\nView at: {endpoint}/traces?batch_id={encoded_batch}")
512
+ click.echo(f"Evaluations: {endpoint}/evaluations/{encoded_exp}")
@@ -0,0 +1,189 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Import OTLP trace .jsonl files into the viewer.
4
+
5
+ Usage:
6
+ nooa import-traces ./traces/
7
+ nooa import-traces my_trace.jsonl --endpoint http://host:5001
8
+ nooa import-traces ./experiment/ --batch-id my-experiment-v2
9
+ """
10
+
11
+ import json
12
+ import urllib.parse
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+ import click
18
+
19
+ from ._otlp_helpers import (
20
+ check_endpoint_reachable,
21
+ inject_resource_attrs,
22
+ post_annotations,
23
+ post_trace,
24
+ session_exists,
25
+ validate_endpoint,
26
+ )
27
+
28
+ NAME = "import-traces"
29
+
30
+ TRACE_EXTENSIONS = {".jsonl"}
31
+
32
+
33
+ def _find_trace_files(path: Path) -> list[Path]:
34
+ """Find all trace JSONL files in a file or directory."""
35
+ if path.is_file():
36
+ return [path]
37
+ if not path.is_dir():
38
+ return []
39
+ files = []
40
+ for ext in TRACE_EXTENSIONS:
41
+ files.extend(path.rglob(f"*{ext}"))
42
+ seen = set()
43
+ unique = []
44
+ for f in sorted(files):
45
+ if f.resolve() not in seen:
46
+ seen.add(f.resolve())
47
+ unique.append(f)
48
+ return unique
49
+
50
+
51
+ def _detect_format(line: dict) -> str:
52
+ """Detect whether a parsed JSON line is OTLP or legacy format."""
53
+ if "resourceSpans" in line:
54
+ return "otlp"
55
+ if "span_id" in line or "trace_id" in line:
56
+ return "legacy"
57
+ return "unknown"
58
+
59
+
60
+ def _session_id_from_filename(path: Path) -> str:
61
+ """Derive a session ID from the trace file's basename."""
62
+ name = path.name
63
+ for ext in sorted(TRACE_EXTENSIONS, key=len, reverse=True):
64
+ if name.endswith(ext):
65
+ return name[: -len(ext)]
66
+ return path.stem
67
+
68
+
69
+ @click.command()
70
+ @click.argument("path", type=click.Path(exists=True))
71
+ @click.option(
72
+ "--endpoint",
73
+ default="http://localhost:5001",
74
+ show_default=True,
75
+ help="Viewer API endpoint.",
76
+ )
77
+ @click.option(
78
+ "--batch-id",
79
+ default=None,
80
+ help="Batch ID for this import (default: auto-generated).",
81
+ )
82
+ def command(path: str, endpoint: str, batch_id: str | None):
83
+ """Import OTLP trace .jsonl files into the viewer."""
84
+ target = Path(path)
85
+ files = _find_trace_files(target)
86
+
87
+ if not files:
88
+ click.echo(f"No trace files found in {path}")
89
+ raise SystemExit(1)
90
+
91
+ validate_endpoint(endpoint)
92
+
93
+ if batch_id is None:
94
+ batch_id = f"import_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid4().hex[:6]}"
95
+
96
+ # Verify endpoint is reachable
97
+ if not check_endpoint_reachable(endpoint):
98
+ click.echo(f"Cannot reach viewer at {endpoint}. Is it running?")
99
+ raise SystemExit(1)
100
+
101
+ click.echo(f"Importing {len(files)} trace file(s) (batch_id={batch_id})...")
102
+
103
+ imported = 0
104
+ skipped = 0
105
+ already_exist = 0
106
+ annotations_imported = 0
107
+ errors = []
108
+
109
+ for file in files:
110
+ session_id = _session_id_from_filename(file)
111
+
112
+ # Check for existing session before importing
113
+ if session_exists(endpoint, session_id):
114
+ click.echo(
115
+ f" ! {file.name}: session '{session_id}' already exists, skipping "
116
+ f"(delete it first or rename the file to import as a new session)"
117
+ )
118
+ already_exist += 1
119
+ continue
120
+
121
+ inject_attrs = {"batch_id": batch_id}
122
+
123
+ file_has_session = False
124
+ file_imported = False
125
+ is_legacy = False
126
+ deferred_annotations: list[dict] = []
127
+
128
+ with open(file) as f:
129
+ for line_num, raw_line in enumerate(f, 1):
130
+ raw_line = raw_line.strip()
131
+ if not raw_line:
132
+ continue
133
+
134
+ try:
135
+ body = json.loads(raw_line)
136
+ except json.JSONDecodeError:
137
+ continue
138
+
139
+ # Handle annotation lines from exported traces
140
+ if "annotations" in body and "resourceSpans" not in body:
141
+ anns = body["annotations"]
142
+ if isinstance(anns, list):
143
+ deferred_annotations.extend(anns)
144
+ continue
145
+
146
+ fmt = _detect_format(body)
147
+
148
+ if fmt == "legacy":
149
+ if not is_legacy:
150
+ errors.append(f"{file.name}: legacy format not supported, skipping")
151
+ is_legacy = True
152
+ continue
153
+
154
+ if fmt != "otlp":
155
+ continue
156
+
157
+ if not file_has_session:
158
+ inject_attrs["session.id"] = session_id
159
+ file_has_session = True
160
+
161
+ inject_resource_attrs(body, inject_attrs)
162
+
163
+ if post_trace(endpoint, body):
164
+ file_imported = True
165
+ else:
166
+ errors.append(f"{file.name}:{line_num}: failed to post")
167
+
168
+ if file_imported:
169
+ imported += 1
170
+ # Import annotations after spans so the session exists
171
+ if deferred_annotations:
172
+ count = post_annotations(endpoint, deferred_annotations)
173
+ annotations_imported += count
174
+ elif not is_legacy:
175
+ skipped += 1
176
+
177
+ click.echo(f" {imported} imported, {skipped} skipped")
178
+ if already_exist:
179
+ click.echo(f" {already_exist} skipped (already exist)")
180
+ if annotations_imported:
181
+ click.echo(f" {annotations_imported} annotation(s) imported")
182
+ if errors:
183
+ for err in errors[:10]:
184
+ click.echo(f" ! {err}")
185
+ if len(errors) > 10:
186
+ click.echo(f" ... and {len(errors) - 10} more errors")
187
+
188
+ encoded_batch = urllib.parse.quote(batch_id, safe="")
189
+ click.echo(f"\nView at: {endpoint}/traces?batch_id={encoded_batch}")