handoffmap 0.2.0__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.
handoffmap/otlp.py ADDED
@@ -0,0 +1,166 @@
1
+ """Read OTLP JSON / JSONL trace exports into :class:`~handoffmap.model.Trace` objects.
2
+
3
+ Two id encodings exist in the wild:
4
+
5
+ * protobuf-JSON (what ``MessageToDict`` writes): ids are base64 strings, span
6
+ kinds are names such as ``SPAN_KIND_CLIENT``;
7
+ * the OTLP/JSON spec (Collector ``file`` exporter, JS SDK): ids are hex, kinds
8
+ are integers.
9
+
10
+ Both are accepted and normalized to lowercase hex ids and integer kinds.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import binascii
17
+ import json
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .errors import NotOtlpError
23
+ from .model import Span, Trace
24
+
25
+ _HEX_ID = re.compile(r"^(?:[0-9a-fA-F]{16}|[0-9a-fA-F]{32})$")
26
+ _KINDS = {
27
+ "SPAN_KIND_UNSPECIFIED": 0,
28
+ "SPAN_KIND_INTERNAL": 1,
29
+ "SPAN_KIND_SERVER": 2,
30
+ "SPAN_KIND_CLIENT": 3,
31
+ "SPAN_KIND_PRODUCER": 4,
32
+ "SPAN_KIND_CONSUMER": 5,
33
+ }
34
+
35
+
36
+ def normalize_id(raw: Any) -> str | None:
37
+ """Return a lowercase hex id, or None for a missing/all-zero id."""
38
+ if raw is None or raw == "":
39
+ return None
40
+ if not isinstance(raw, str):
41
+ raise NotOtlpError("err.id_type", type=type(raw).__name__)
42
+ if _HEX_ID.match(raw):
43
+ hex_id = raw.lower()
44
+ else:
45
+ try:
46
+ hex_id = base64.b64decode(raw, validate=True).hex()
47
+ except (binascii.Error, ValueError) as exc:
48
+ raise NotOtlpError("err.id_encoding", raw=raw) from exc
49
+ if set(hex_id) == {"0"}:
50
+ return None
51
+ return hex_id
52
+
53
+
54
+ def _decode_value(value: Any) -> Any:
55
+ """Decode an OTLP AnyValue (``{"stringValue": ...}`` and friends) to a Python value."""
56
+ if not isinstance(value, dict):
57
+ return value
58
+ if "stringValue" in value:
59
+ return value["stringValue"]
60
+ if "intValue" in value:
61
+ try:
62
+ return int(value["intValue"]) # int64 is a JSON string in protobuf-JSON
63
+ except (TypeError, ValueError):
64
+ return value["intValue"]
65
+ if "doubleValue" in value:
66
+ return value["doubleValue"]
67
+ if "boolValue" in value:
68
+ return value["boolValue"]
69
+ if "arrayValue" in value:
70
+ return [_decode_value(v) for v in value["arrayValue"].get("values", [])]
71
+ if "kvlistValue" in value:
72
+ return _decode_attrs(value["kvlistValue"].get("values", []))
73
+ if "bytesValue" in value:
74
+ return value["bytesValue"]
75
+ return None
76
+
77
+
78
+ def _decode_attrs(items: Any) -> dict[str, Any]:
79
+ out: dict[str, Any] = {}
80
+ for item in items or []:
81
+ if isinstance(item, dict) and "key" in item:
82
+ out[item["key"]] = _decode_value(item.get("value"))
83
+ return out
84
+
85
+
86
+ def _kind(raw: Any) -> int:
87
+ if isinstance(raw, int):
88
+ return raw
89
+ if isinstance(raw, str):
90
+ if raw in _KINDS:
91
+ return _KINDS[raw]
92
+ if raw.isdigit():
93
+ return int(raw)
94
+ return 0
95
+
96
+
97
+ def _int(raw: Any) -> int:
98
+ try:
99
+ return int(raw)
100
+ except (TypeError, ValueError):
101
+ return 0
102
+
103
+
104
+ def _spans_of_request(request: Any) -> list[Span]:
105
+ if not isinstance(request, dict) or not isinstance(request.get("resourceSpans"), list):
106
+ raise NotOtlpError("err.no_resource_spans")
107
+ spans: list[Span] = []
108
+ for resource_spans in request["resourceSpans"]:
109
+ # older exporters use instrumentationLibrarySpans instead of scopeSpans
110
+ scope_list = resource_spans.get("scopeSpans") or resource_spans.get("instrumentationLibrarySpans") or []
111
+ for scope_spans in scope_list:
112
+ scope = scope_spans.get("scope") or scope_spans.get("instrumentationLibrary") or {}
113
+ for raw in scope_spans.get("spans", []):
114
+ trace_id = normalize_id(raw.get("traceId"))
115
+ span_id = normalize_id(raw.get("spanId"))
116
+ if trace_id is None or span_id is None:
117
+ raise NotOtlpError("err.span_no_ids")
118
+ spans.append(
119
+ Span(
120
+ trace_id=trace_id,
121
+ span_id=span_id,
122
+ parent_id=normalize_id(raw.get("parentSpanId")),
123
+ name=str(raw.get("name", "")),
124
+ kind=_kind(raw.get("kind")),
125
+ start_ns=_int(raw.get("startTimeUnixNano")),
126
+ end_ns=_int(raw.get("endTimeUnixNano")),
127
+ attributes=_decode_attrs(raw.get("attributes")),
128
+ scope=str(scope.get("name", "")),
129
+ )
130
+ )
131
+ return spans
132
+
133
+
134
+ def parse(text: str) -> list[Trace]:
135
+ """Parse OTLP JSON (one request) or JSONL (one request per line) text."""
136
+ requests: list[Any]
137
+ try:
138
+ requests = [json.loads(text)]
139
+ except json.JSONDecodeError as whole_error:
140
+ lines = [ln for ln in text.splitlines() if ln.strip()]
141
+ try:
142
+ requests = [json.loads(ln) for ln in lines]
143
+ except json.JSONDecodeError:
144
+ raise NotOtlpError("err.invalid_json", msg=whole_error.msg, line=whole_error.lineno) from None
145
+ if not requests:
146
+ raise NotOtlpError("err.empty") from None
147
+
148
+ traces: dict[str, Trace] = {}
149
+ for request in requests:
150
+ for span in _spans_of_request(request):
151
+ traces.setdefault(span.trace_id, Trace(span.trace_id)).spans.append(span)
152
+ if not traces:
153
+ raise NotOtlpError("err.no_spans")
154
+ return list(traces.values())
155
+
156
+
157
+ def load(path: str | Path) -> list[Trace]:
158
+ """Read an OTLP JSON or JSONL file and return one :class:`Trace` per trace id."""
159
+ p = Path(path)
160
+ try:
161
+ text = p.read_text(encoding="utf-8")
162
+ except FileNotFoundError:
163
+ raise NotOtlpError("err.file_not_found", path=p) from None
164
+ except (OSError, UnicodeDecodeError) as exc:
165
+ raise NotOtlpError("err.cannot_read", path=p, error=exc) from None
166
+ return parse(text)
handoffmap/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Renderers: Mermaid sequence diagram, Markdown table, full report, JSON."""
@@ -0,0 +1,42 @@
1
+ """``--explain``: show which convention was detected and why each edge got its kind."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+
7
+ from ..i18n import t
8
+ from ..model import Edge, Flow
9
+
10
+
11
+ def _edge_line(e: Edge, lang: str) -> str:
12
+ src = e.caller or t(lang, "ex.none")
13
+ extra = t(lang, "ex.via", via=e.via) if e.via else ""
14
+ return f" {src} -> {e.callee}: {e.kind}{extra} [{t(lang, 'why.' + e.kind)}]"
15
+
16
+
17
+ def render_explain(flow: Flow, lang: str = "en") -> str:
18
+ """Describe the detection result; ``lang`` is ``en`` (default) or ``ko``."""
19
+ lines = [
20
+ t(lang, "ex.trace", trace=flow.trace_id),
21
+ t(lang, "ex.convention", convention=flow.convention),
22
+ t(lang, "ex.size", spans=flow.span_count, runs=len(flow.runs), agents=len(flow.agents)),
23
+ t(lang, "ex.agents", names=", ".join(a.name for a in flow.agents)),
24
+ t(lang, "ex.edges"),
25
+ ]
26
+ lines += [_edge_line(e, lang) for e in flow.edges]
27
+ if flow.ignored:
28
+ counts = Counter(flow.ignored)
29
+ shown = ", ".join(f"'{name}' x{n}" for name, n in counts.items())
30
+ lines.append(t(lang, "ex.ignored", shown=shown))
31
+ else:
32
+ lines.append(t(lang, "ex.ignored_none"))
33
+ if flow.unknown_operations:
34
+ lines.append(t(lang, "ex.unknown_ops", names=", ".join(flow.unknown_operations)))
35
+ tot = flow.totals
36
+ if tot.input_tokens is None and tot.output_tokens is None:
37
+ lines.append(t(lang, "ex.tokens_none"))
38
+ else:
39
+ lines.append(t(lang, "ex.tokens", input=tot.input_tokens or 0, output=tot.output_tokens or 0))
40
+ if flow.reported_usage is not None:
41
+ lines.append(t(lang, "ex.reported", input=flow.reported_usage[0], output=flow.reported_usage[1]))
42
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,54 @@
1
+ """Stable JSON output. Only names, ids, timings and counts: never message content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..model import AgentStats, Flow
8
+
9
+
10
+ def _agent(a: AgentStats) -> dict[str, Any]:
11
+ return {
12
+ "name": a.name,
13
+ "runs": a.runs,
14
+ "llm_calls": a.llm_calls,
15
+ "tool_calls": a.tool_calls,
16
+ "input_tokens": a.input_tokens,
17
+ "output_tokens": a.output_tokens,
18
+ "cost": a.cost,
19
+ "duration_ms": round(a.duration_ms, 3),
20
+ }
21
+
22
+
23
+ def to_dict(flow: Flow) -> dict[str, Any]:
24
+ """Convert a flow to plain dicts/lists (``json.dumps``-able)."""
25
+ t0 = min((r.start_ns for r in flow.runs.values()), default=0)
26
+ return {
27
+ "trace_id": flow.trace_id,
28
+ "convention": flow.convention,
29
+ "span_count": flow.span_count,
30
+ "agents": [_agent(a) for a in flow.agents],
31
+ "totals": _agent(flow.totals),
32
+ "edges": [
33
+ {
34
+ "kind": e.kind,
35
+ "from": e.caller,
36
+ "to": e.callee,
37
+ "via": e.via,
38
+ "start_ms": round((e.start_ns - t0) / 1e6, 3),
39
+ "duration_ms": round(e.duration_ms, 3),
40
+ }
41
+ for e in flow.edges
42
+ ],
43
+ "parallel": [
44
+ {
45
+ "agent": g.agent,
46
+ "members": [
47
+ {"kind": m.kind, "name": m.name, "duration_ms": round((m.end_ns - m.start_ns) / 1e6, 3)}
48
+ for m in g.members
49
+ ],
50
+ }
51
+ for g in flow.parallel
52
+ ],
53
+ "warnings": [{"code": w.code, "message": w.message} for w in flow.warnings],
54
+ }
@@ -0,0 +1,30 @@
1
+ """The default report: Mermaid diagram + agent table + warnings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..i18n import t
6
+ from ..model import Flow
7
+ from .mermaid import render_mermaid
8
+ from .table import render_table
9
+
10
+
11
+ def render_markdown(flow: Flow, *, show_tools: bool = True, heading: bool = True, lang: str = "en") -> str:
12
+ """Render the full report; ``lang`` (``en`` default, or ``ko``) only changes the prose, not the diagram."""
13
+ parts: list[str] = []
14
+ if heading:
15
+ parts.append(
16
+ t(lang, "md.heading", trace=flow.trace_id[:8], convention=flow.convention, spans=flow.span_count) + "\n"
17
+ )
18
+ parts.append("```mermaid\n" + render_mermaid(flow, show_tools=show_tools) + "```\n")
19
+ parts.append(t(lang, "md.agents") + "\n")
20
+ parts.append(render_table(flow, lang=lang))
21
+ parts.append(t(lang, "md.footnote") + "\n")
22
+ if flow.warnings:
23
+ parts.append(t(lang, "md.warnings") + "\n")
24
+ lines = []
25
+ for w in flow.warnings:
26
+ # warnings built by find_warnings carry args; a hand-made one falls back to its English message
27
+ message = t(lang, f"warn.{w.code}", **w.args) if w.args else w.message
28
+ lines.append(f"- **{w.code}**: {message}")
29
+ parts.append("\n".join(lines) + "\n")
30
+ return "\n".join(parts)
@@ -0,0 +1,115 @@
1
+ """Mermaid ``sequenceDiagram`` output.
2
+
3
+ Mermaid is picky about names: participants get plain aliases (``a1``, ``a2``)
4
+ and the real name goes after ``as``; ``#``, ``;``, ``<`` and ``>`` in any text
5
+ are written as Mermaid entity codes (``#59;``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from ..model import Edge, Flow, ParallelGroup, ToolCall
14
+
15
+ _SPECIAL = re.compile(r"[#;<>]")
16
+
17
+
18
+ def escape(text: str) -> str:
19
+ """Make ``text`` safe for a Mermaid message or participant label."""
20
+ text = re.sub(r"\s+", " ", text).strip()
21
+ return _SPECIAL.sub(lambda m: f"#{ord(m.group())};", text)
22
+
23
+
24
+ def _ms(value_ms: float) -> str:
25
+ return f"{value_ms:.1f} ms"
26
+
27
+
28
+ @dataclass
29
+ class _Act:
30
+ """One thing an agent run did: start a sub-agent/handoff (edge) or call a tool."""
31
+
32
+ ref: str # callee run id (edge) or tool span id (tool)
33
+ start_ns: int
34
+ edge: Edge | None = None
35
+ tool: ToolCall | None = None
36
+
37
+
38
+ def render_mermaid(flow: Flow, *, show_tools: bool = True) -> str:
39
+ """Render the flow as a Mermaid sequence diagram (no code fence)."""
40
+ alias: dict[str, str] = {}
41
+ for run in sorted(flow.runs.values(), key=lambda r: (r.start_ns, r.end_ns)):
42
+ alias.setdefault(run.name, f"a{len(alias) + 1}")
43
+
44
+ lines = ["sequenceDiagram"]
45
+ for name, a in alias.items():
46
+ lines.append(f" participant {a} as {escape(name)}")
47
+
48
+ # what each agent run did, in time order
49
+ acts_by_run: dict[str, list[_Act]] = {}
50
+ for e in flow.edges:
51
+ if e.caller_run is not None:
52
+ acts_by_run.setdefault(e.caller_run, []).append(_Act(e.callee_run, e.start_ns, edge=e))
53
+ if show_tools:
54
+ for t in flow.tools:
55
+ if not t.wraps_agent: # the sub-agent's own edge already shows this call
56
+ acts_by_run.setdefault(t.run_id, []).append(_Act(t.span_id, t.start_ns, tool=t))
57
+ for acts in acts_by_run.values():
58
+ acts.sort(key=lambda a: a.start_ns)
59
+
60
+ groups_by_run: dict[str, list[ParallelGroup]] = {}
61
+ for g in flow.parallel:
62
+ groups_by_run.setdefault(g.run_id, []).append(g)
63
+
64
+ visited: set[str] = set() # a run is drawn once, even if the data has a handoff cycle
65
+
66
+ def act_line(act: _Act, indent: str) -> str:
67
+ if act.tool is not None:
68
+ t = act.tool
69
+ a = alias[t.agent]
70
+ return f"{indent}{a}->>{a}: {escape('tool ' + t.name)} ({_ms((t.end_ns - t.start_ns) / 1e6)})"
71
+ e = act.edge
72
+ assert e is not None
73
+ src, dst = alias[e.caller or ""], alias[e.callee]
74
+ if e.kind == "handoff":
75
+ return f"{indent}{src}-){dst}: handoff ({_ms(e.duration_ms)})" # -) = async, control does not come back
76
+ label = f"as_tool via {e.via}" if e.kind == "as_tool" and e.via else e.kind
77
+ return f"{indent}{src}->>{dst}: {escape(label)} ({_ms(e.duration_ms)})"
78
+
79
+ def emit_act(act: _Act, indent: str) -> None:
80
+ lines.append(act_line(act, indent))
81
+ if act.edge is not None:
82
+ emit_run(act.edge.callee_run, indent)
83
+
84
+ def emit_run(run_id: str, indent: str) -> None:
85
+ if run_id in visited:
86
+ return
87
+ visited.add(run_id)
88
+ acts = acts_by_run.get(run_id, [])
89
+ by_ref = {a.ref: a for a in acts}
90
+ group_of: dict[str, list[_Act]] = {}
91
+ for g in groups_by_run.get(run_id, []):
92
+ branches = [by_ref[m.ref] for m in g.members if m.ref in by_ref]
93
+ if len(branches) > 1: # with --no-tools a group can shrink to one branch: then no par
94
+ for b in branches:
95
+ group_of[b.ref] = branches
96
+
97
+ drawn: set[str] = set()
98
+ for act in acts:
99
+ branches = group_of.get(act.ref)
100
+ if branches is None:
101
+ emit_act(act, indent)
102
+ elif act.ref not in drawn:
103
+ lines.append(f"{indent}par concurrent")
104
+ for i, b in enumerate(branches):
105
+ if i:
106
+ lines.append(f"{indent}and")
107
+ emit_act(b, indent + " ")
108
+ drawn.add(b.ref)
109
+ lines.append(f"{indent}end")
110
+
111
+ for e in flow.edges:
112
+ if e.kind == "root":
113
+ lines.append(f" Note over {alias[e.callee]}: start")
114
+ emit_run(e.callee_run, " ")
115
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,45 @@
1
+ """Per-agent Markdown table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..i18n import t
6
+ from ..model import AgentStats, Flow
7
+
8
+ DASH = "–" # shown when a value is not in the trace
9
+
10
+
11
+ def _cell(text: str) -> str:
12
+ # `|` would split the cell; `<`/`>` would be read as HTML by Markdown renderers
13
+ return text.replace("|", "\\|").replace("<", "&lt;").replace(">", "&gt;").replace("\n", " ")
14
+
15
+
16
+ def _tokens(value: int | None) -> str:
17
+ return DASH if value is None else str(value)
18
+
19
+
20
+ def _row(a: AgentStats, with_cost: bool, bold: bool = False, label: str | None = None) -> str:
21
+ shown = a.name if label is None else label # the totals row gets a translated label
22
+ name = f"**{_cell(shown)}**" if bold else _cell(shown)
23
+ cells = [name, str(a.runs), str(a.llm_calls), str(a.tool_calls), _tokens(a.input_tokens), _tokens(a.output_tokens)]
24
+ if with_cost:
25
+ cells.append(DASH if a.cost is None else f"{a.cost:.6f}".rstrip("0").rstrip("."))
26
+ cells.append(f"{a.duration_ms:.1f} ms")
27
+ return "| " + " | ".join(cells) + " |"
28
+
29
+
30
+ def render_table(flow: Flow, lang: str = "en") -> str:
31
+ """Render one row per agent name plus a total row (when there is more than one agent)."""
32
+ with_cost = any(a.cost is not None for a in flow.agents)
33
+ keys = ["agent", "runs", "llm_calls", "tools", "input_tokens", "output_tokens"]
34
+ if with_cost:
35
+ keys.append("cost")
36
+ keys.append("time")
37
+ headers = [t(lang, f"table.{k}") for k in keys]
38
+ lines = [
39
+ "| " + " | ".join(headers) + " |",
40
+ "|" + "|".join(["---"] + ["---:"] * (len(headers) - 1)) + "|",
41
+ ]
42
+ lines += [_row(a, with_cost) for a in flow.agents]
43
+ if len(flow.agents) > 1:
44
+ lines.append(_row(flow.totals, with_cost, bold=True, label=t(lang, "table.total")))
45
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,10 @@
1
+ # Built-in sample traces
2
+
3
+ All four were recorded on 2026-11-11 with **mock models** (no network, no API key), so token counts and timings are practice values.
4
+
5
+ | File | Made with |
6
+ |---|---|
7
+ | `pydantic_ai_v2.otlp.json`, `pydantic_ai_v5.otlp.json`, `pydantic_ai_v6.otlp.json` | pydantic-ai-slim 2.67.0, `FunctionModel`, instrumentation `version=2/5/6`; see `examples/make_pydantic_trace.py` |
8
+ | `openinference_agents.otlp.json` | openai-agents 0.24.0 `ScriptedModel`, openinference-instrumentation-openai-agents 2.7.1; see `examples/make_openai_agents_trace.py` |
9
+
10
+ They use the protobuf-JSON form (base64 ids, `SPAN_KIND_*` names). Prompt/response text in them is mock text; handoffmap never prints it.