Tutorial 9 — Building a knowledge graph from OTel and native events¶
Packages:
mas-library-kg(native→KG is first-party; OTel→KG reuses thenormpackage from SCOPE),mas-library-telemetry(span files from Tutorial 8) Prerequisite: Tutorial 8 Example traces:library-kg/examples/qa-agent/
Goal¶
Native events.jsonl and OTel spans are flat, ordered logs. You turn them
into a knowledge graph (KG): typed nodes (agent call, tool call, LLM call)
joined by typed edges, so you can ask "which tools did this agent call".
By the end you can build a KG from both sources, write it to a JSON-LD file or Neo4j (a graph database), check the two KGs agree, validate against the ontology (the published node and edge vocabulary), and keep the KG current during a run.
How the pieces fit¶
| Step | Input | Command or call | Output |
|---|---|---|---|
| 1 | otel_spans.jsonl |
normalize_otel |
kg.jsonld |
| 2 | events.jsonl |
normalize_events |
kg.jsonld |
| 3 | kg.jsonld |
kg neo4j-push |
Cypher or Neo4j graph |
| 4 | both kg.jsonld |
pytest | equivalence verdict |
| 5 | kg.jsonld |
kg validate |
structural and SHACL report |
| 6 | growing spans file | convert, normalize, push on a timer | current graph |
SHACL is a W3C language for checking graph shape.
SCOPE (System for Cognitive Observability Provenance and Explainability, github.com/ioc-project/scope) is the observability platform MAS-Lab feeds. Two of its pieces are not part of MAS Lab:
oxp-ontology(PyPI, hard dependency ofmas-library-kg): the ontology, the SHACL shapes (mas-shapes.ttl) and the Python modelsoxp_ontology.models.*.norm(extramas-library-kg[norm], installed from git): turns OTel spans into ontology nodes and edges withnorm.normalize().
mas-library-kg is first-party: the native→KG normalizer, validation, Neo4j
serialization, mas-lab kg commands and pipeline steps. The OTel path wraps
norm.normalize(), so it matches the platform's graph. The native path builds
the same models without OTel or norm, because nothing else reads
events.jsonl.
mas-lab kg normalize and kg neo4j-push are shortcuts for the steps
normalize_events, normalize_otel and neo4j_push
(kg:pipelines/native-to-kg.yaml, native-to-neo4j.yaml). The sink is a file
path (-o), an infra manifest (--infra, kind: Neo4j) or $NEO4J_URI.
Layers structure, execution and trajectory are on by default;
provenance, governance and infrastructure are off. Enable them with
--include-governance, --include-provenance and similar flags. Reference:
library-kg docs.
What you need¶
uv pip install -e "library-kg[verify]" # native → KG
uv pip install -e "library-kg[norm,verify]" # OTel → KG (SCOPE wrap)
uv pip install -e "library-telemetry[convert]" # if you still need replay
Run from the repository root. OTel→KG checks skip without the norm extra.
pytest tests/tutorials/test_tutorial_09.py
pytest tests/tutorials/test_scenario_commands.py -k tuto-09
Step 1 — Build a KG from OTel spans¶
python - <<'PY'
from pathlib import Path
from mas.library.kg.steps.normalize_otel import run_normalize_otel
out = Path("/tmp/t9-otel-kg")
art = run_normalize_otel(
Path("library-kg/examples/qa-agent/otel_spans.jsonl"),
run_id="t9-otel",
output_dir=out,
)
print(art.node_count, "nodes", art.edge_count, "edges →", out / "kg.jsonld")
PY
You should see a node count, an edge count and the path to kg.jsonld.
Without the norm extra you get a warning and an empty KG (0 nodes).
CLI: mas-lab kg normalize --otel spans.jsonl -o kg.jsonld. Pipeline:
kg:pipelines/otel-to-kg.yaml. Shipped result:
library-kg/examples/qa-agent/kg-otel.jsonld.
Step 2 — Build a KG from native events¶
Native event kinds with no mapping are skipped with a warning, not an error.
python - <<'PY'
from pathlib import Path
from mas.library.kg.steps.normalize import run_normalize
out = Path("/tmp/t9-native-kg")
art = run_normalize(
Path("library-kg/examples/qa-agent/events.jsonl"),
run_id="t9-native",
output_dir=out,
)
print(art.node_count, "nodes", art.edge_count, "edges →", out / "kg.jsonld")
PY
The output has the same form as Step 1. CLI:
mas-lab kg normalize events.jsonl -o kg.jsonld (add --include-governance
to opt that layer in). Pipeline: kg:pipelines/native-to-kg.yaml. Shipped
result: library-kg/examples/qa-agent/kg-native.jsonld.
Step 3 — Serialize to a file or Neo4j¶
Steps 1 and 2 wrote files. To query the graph, push it to Neo4j.
docker compose -f library-kg/docker/compose.yaml up -d
export NEO4J_URI=bolt://localhost:7687
--dry-run prints the Cypher statements (Neo4j's query language) without
connecting. neo4j-push is an alias of push.
mas-lab kg neo4j-push library-kg/examples/qa-agent/kg-native.jsonld --dry-run
mas-lab kg neo4j-push library-kg/examples/qa-agent/kg-native.jsonld \
--infra library-kg/infra/local-neo4j.yaml --dry-run
For a shared Neo4j, copy local-neo4j.yaml, set the URI and pass it with
--infra. See library-kg/docker/README.md and the
library-kg tutorial
for a full Neo4j round trip. The SCOPE platform UI stack is separate
(compose file);
you do not need it here.
Step 4 — Check that both KGs agree¶
Both paths target one ontology, so they must agree on the same run. The test compares the canonical call tree, ignoring order and ids, on the default and complete layers.
cd library-kg
python -m pytest tests/kg/test_native_otel_norm_equivalence.py -q
It needs mas-library-kg[norm] and mas-library-telemetry[convert]. Every
case should pass.
Step 5 — Validate against the ontology¶
The validator checks vocabulary, blocks, layers and the SHACL shapes in
oxp-ontology.
uv pip install -e "library-kg[validation]" # rdflib + pyshacl
mas-lab kg validate library-kg/examples/qa-agent/kg-native.jsonld
mas-lab kg validate library-kg/examples/qa-agent/kg-otel.jsonld
Without [validation], SHACL is skipped, not passed. On the qa-agent
fixtures the vocabulary checks (unknown_*, block, layer, orphans) pass.
The remaining SHACL rows are mostly sh:Warning: LLM token and provider
fields, hasToolCall on a tool-less agent, and a few belongsToMAS
cardinality hits on this short capture. Run kg validate on every new
kg.jsonld.
Step 6 — Keep the graph current in realtime¶
Tutorial 8, Step 6
showed --realtime spans. normalize_otel and neo4j_push read whatever is
in the file now, so Steps 1 and 3 work unchanged on a realtime file. You
re-run them periodically.
mas-lab telemetry convert library-telemetry/examples/qa-agent/events.jsonl \
-o /tmp/t9-realtime-spans.jsonl --app-name qa-agent --realtime
mas-lab kg normalize --otel /tmp/t9-realtime-spans.jsonl \
-o /tmp/t9-realtime-kg.jsonld --run-id t9-realtime
mas-lab kg neo4j-push /tmp/t9-realtime-kg.jsonld --dry-run
You should see a normalize summary with node and edge counts, then MERGE
statements. This path needs mas-library-kg[norm]; without it the KG is empty.
To go live, replace the first command with an agent run that uses the
observability-otel-realtime overlay, and run the other two on a timer.
Re-running on the whole file is safe:
- Node and edge ids derive from call and span ids, not a counter. The started/completed id pairing from Tutorial 8 keeps one call as one node.
neo4j-pushCypher usesMERGE, notCREATE(check with--dry-run), so a repeated push changes nothing and new properties update in place.
Each tick's graph is a superset of the last and converges on the end-of-run graph.
This is not incremental ingestion. norm.normalize() builds a fresh
Registry() per call, so normalizing only new spans would leave tool calls
disconnected. It does not scale to long-running agents. True streaming needs
an event stream with a resumable cursor and an incremental registry in norm
and mas-library-kg. Neither exists yet.
What you learned¶
- OTel→KG reuses SCOPE's
norm; native→KG is first-party. Both targetoxp_ontology.models.*, and a test keeps them aligned. kg normalizeandkg neo4j-pushare shortcuts for pipeline steps; a path,--infraor$NEO4J_URIpicks the sink, and--dry-runneeds no Neo4j.- Layers match Tutorial 8's categories, with governance off by default. Realtime KG means re-normalizing the whole growing file on a timer.
Next¶
Tutorial 10 — Evaluation metrics scores runs with metrics and judges.