aiml
SearchMy learning
Menu

Lesson 14 of 21Module 4

Build end-to-end request tracing with source provenance

Propagate a trace across two local Docuctl processes. Inspect parent links and distinguish current, stale and dropped sources before returning a citation.

Prerequisites

Python functions, dictionaries, exceptions and unittest; the Concepts section defines the protocol terms used by this lab.

By the end, you can

  • Propagate traceparent through a JSON envelope across two local processes and reconstruct the exported span tree.
  • Give every citation in an answer a provenance verdict from the retrieved passages, the context and the current document versions.
  • Distinguish missing propagation, stale content and dropped sources using local failure tests.
Runtime
Python 3.11+; local CPU
Dependencies
Python standard library; no model server for the docuctl demonstration or offline tests

Lab code, tests and fixtures (ZIP, 92 KB)Quiz check your understanding

Progress is saved in this browser.

Problem

When you need this. Use this when a response cites an existing document but you need to determine whether the failure began in lookup, context selection or the final source check.

An application log saying “request complete” cannot explain which page version reached the answer step. The lookup may have used an older index, or assembly may have dropped a retrieved passage before the response cited it. A valid page ID alone does not distinguish those failures.

Build a trace that follows one docuctl lookup across a gateway and a separate worker process. Retain source identity at the assembly boundary, then check citation membership and content identity. The active demonstration uses a fixed local lookup and response. It makes no model request and measures no model quality.

Earlier model-backed traces remain unchanged in the download. Their original requests, responses and model pin are historical records. They are separate from the current docuctl process and provenance experiment.

Concepts

A trace relates spans from one request

A span describes one operation. It has its own span ID, a trace ID shared with related operations and the span ID of its parent. A trace is the resulting tree. Operation names such as lookup.call, lookup, assemble and provenance tell you which application boundary each span describes.

The gateway passes its current trace identity to the worker. The lab uses the W3C traceparent wire format inside a JSON envelope sent through the child process’s standard input. An HTTP adapter would carry the value in a header. This implementation uses two processes; it does not claim to run an HTTP retrieval service for the new fixture.

Provenance is a separate contract

A citation must name a known current page and a passage retained for the answer step. Its content digest must match the current page. The checker distinguishes an unknown citation, a known page dropped during assembly and a stale retained passage.

Those checks explain where a source mismatch occurred. They do not prove that every statement follows from the source. The active driver returns a fixed passage quotation, while the historical model suite retains its separate answer checks.

Deterministic traces do not measure latency

The ID source and clock are injected. This fixture derives IDs from a seed and advances a counting clock so that the saved result can be compared exactly. These durations are synthetic. Replace the clock with a real monotonic clock and state the workload before using span durations as performance measurements.

Architecture

Gateway lookup.call spanchild of the request spanSeparate worker processlookup span inherits parentSelected passageID, version and hashAssemble contextrecord retained sourceProvenance checkknown, retained and currentreject a stale or dropped sourceFixed local responsequotation and citationCollected span treeIDs and hashes, not raw texttraceparentGateway lookup.call spanchild of the request spanSeparate worker processinherits traceparent identitySelected passageID, version and content hashAssemble contextrecord retained source hashProvenance checkknown, retained and currentreject stale or dropped sourcesFixed local responsequotation and citationCollected span treeIDs and hashes, not raw texttraceparent
A gateway span passes traceparent to a separate local worker through a JSON envelope. The worker returns a passage and exported span. Assembly records the retained source hash; provenance checks the cited source. Derived IDs and counting clocks make the fixture repeatable; durations are synthetic.

The gateway opens a request span and a child span around a subprocess call. The worker opens its lookup span with the incoming trace identity and returns both the selected passage and its exported span. The gateway records the retained source ID and content hash, then runs the provenance check.

collect reconstructs trees from both processes’ finished spans. Without propagation, the worker starts a separate trace. With propagation, its parent is the gateway’s lookup-call span. A missing parent export and a newly started trace are different faults; neither should be silently presented as one intact request tree.

Span attributes contain bounded case IDs, page IDs, digests and verdicts. The question, passage body and returned text remain outside telemetry. A content hash is still metadata that may be sensitive for a predictable document set; it is not an anonymization guarantee.

Working code

Prepare the complete lab

Download the complete lab, unpack it and enter the extracted directory. The active docuctl demonstration uses only the Python standard library:

Prepare, run and test the labTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python docuctl_trace.py
python -m unittest -v test_docuctl.py test_tracing.py

After setup, the run command exercises authored local inputs through the implementation. The test command also runs the preserved historical suite. Neither command requests a model answer. The historical suite still checks its original fixtures; it does not turn them into docuctl recordings.

Read the tracer and the driver

tracing.pySource · pythonDownload
"""A small span tracer with W3C Trace Context propagation. Python standard library only.

A trace is the tree of spans for one request. Each span has a trace ID shared by
the whole tree, its own span ID, and the span ID of its parent. A process passes
its current span to another process in a `traceparent` header:

    traceparent: 00-<32 hex trace-id>-<16 hex parent-id>-<2 hex flags>

Each process exports its own finished spans to its own JSON Lines file. collect()
reads every file and rebuilds the trees; a span whose parent is in no file is an
orphan, the sign that some hop did not pass the header on.

The clock and the ID source are injected. Tests use a counting clock and IDs
derived from a seed, so every span, ID and duration is the same on every run.
Real use passes time.perf_counter_ns and random IDs, as the W3C recommends.
"""
import hashlib
import json
import re
import secrets
import time
from pathlib import Path

TRACEPARENT = re.compile(r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
# Fields that describe one run's timing; they change on every rerun with a real clock.
TIMING_FIELDS = ("start_ns", "end_ns", "duration_ms")


def format_traceparent(trace_id, span_id, sampled=True):
    return f"00-{trace_id}-{span_id}-{'01' if sampled else '00'}"


def parse_traceparent(value):
    """Return (trace_id, parent_id) or None. All-zero IDs and other versions are invalid."""
    match = TRACEPARENT.match(value or "")
    if not match or set(match[1]) == {"0"} or set(match[2]) == {"0"}:
        return None
    return match[1], match[2]


class RandomIds:
    """IDs as the W3C recommends: random, so they are unique and reveal nothing."""

    def trace_id(self):
        return secrets.token_hex(16)

    def span_id(self):
        return secrets.token_hex(8)


class DerivedIds:
    """Reproducible IDs for recorded fixtures: SHA-256 of a seed and a counter."""

    def __init__(self, seed):
        self.seed, self.count = seed, 0

    def _next(self, length):
        self.count += 1
        return hashlib.sha256(f"{self.seed}:{self.count}".encode()).hexdigest()[:length]

    def trace_id(self):
        return self._next(32)

    def span_id(self):
        return self._next(16)


class CountingClock:
    """A fake monotonic clock: each reading is `step` nanoseconds after the last."""

    def __init__(self, step=1_000_000):
        self.now, self.step = 0, step

    def __call__(self):
        self.now += self.step
        return self.now


class Span:
    def __init__(self, tracer, name, trace_id, parent_id, attributes):
        self.tracer, self.name = tracer, name
        self.trace_id, self.span_id, self.parent_id = trace_id, tracer.ids.span_id(), parent_id
        self.attributes, self.events, self.status = dict(attributes), [], "ok"
        self.start_ns = tracer.clock()

    def set(self, **attributes):
        self.attributes.update(attributes)

    def event(self, name, **attributes):
        self.events.append({"name": name, **attributes})

    def traceparent(self):
        return format_traceparent(self.trace_id, self.span_id)

    def __enter__(self):
        self.tracer.stack.append(self)
        return self

    def __exit__(self, kind, error, traceback):
        self.tracer.stack.pop()
        if error is not None:
            self.status = "error"
            self.attributes["error.type"] = kind.__name__
        end_ns = self.tracer.clock()
        self.tracer.export({
            "trace_id": self.trace_id, "span_id": self.span_id, "parent_id": self.parent_id,
            "service": self.tracer.service, "name": self.name, "status": self.status,
            "attributes": self.attributes, "events": self.events,
            "start_ns": self.start_ns, "end_ns": end_ns, "duration_ms": round((end_ns - self.start_ns) / 1e6, 3),
        })
        return False  # never swallow the error


class Tracer:
    """Starts spans for one service and appends each finished span to its exporter."""

    def __init__(self, service, exporter, clock=time.perf_counter_ns, ids=None):
        self.service, self.export, self.clock = service, exporter, clock
        self.ids = ids or RandomIds()
        self.stack = []

    def span(self, name, traceparent=None, **attributes):
        """A child of the current span; else of an incoming traceparent; else a new trace."""
        if self.stack:
            parent = self.stack[-1]
            return Span(self, name, parent.trace_id, parent.span_id, attributes)
        incoming = parse_traceparent(traceparent)
        if incoming:
            return Span(self, name, incoming[0], incoming[1], attributes)
        span = Span(self, name, self.ids.trace_id(), None, attributes)
        if traceparent is not None:
            span.event("invalid traceparent ignored; trace restarted")
        return span


class JsonLinesExporter:
    """Appends one JSON object per finished span to a file, as a collector would receive them."""

    def __init__(self, path):
        self.path = Path(path)

    def __call__(self, span):
        with self.path.open("a", encoding="utf-8") as out:
            out.write(json.dumps(span, sort_keys=True) + "\n")


def read_spans(*paths):
    spans = []
    for path in paths:
        for line in Path(path).read_text(encoding="utf-8").splitlines():
            if line.strip():
                spans.append(json.loads(line))
    return spans


def collect(spans):
    """Group spans by trace and nest them. Return {trace_id: {"roots": [...], "orphans": [...]}}."""
    traces = {}
    for span in spans:
        traces.setdefault(span["trace_id"], []).append(span)
    result = {}
    for trace_id, members in traces.items():
        by_id = {span["span_id"]: dict(span, children=[]) for span in members}
        roots, orphans = [], []
        for span in sorted(by_id.values(), key=lambda s: (s["start_ns"], s["span_id"])):
            if span["parent_id"] is None:
                roots.append(span)
            elif span["parent_id"] in by_id:
                by_id[span["parent_id"]]["children"].append(span)
            else:
                orphans.append(span)  # its parent was never exported: a broken hop
        result[trace_id] = {"roots": roots, "orphans": orphans}
    return result


def walk(node, depth=0):
    """Yield (depth, span) in tree order."""
    yield depth, node
    for child in node["children"]:
        yield from walk(child, depth + 1)


def without_timing(value):
    """A copy without TIMING_FIELDS, for comparing a rerun with a record."""
    if isinstance(value, dict):
        return {k: without_timing(v) for k, v in value.items() if k not in TIMING_FIELDS}
    if isinstance(value, list):
        return [without_timing(v) for v in value]
    return value
docuctl_trace.pySource · pythonDownload
"""Trace a fixed docuctl document lookup across two local processes. No model request.

The worker accepts a W3C traceparent value in a JSON envelope on stdin. The fixture
uses derived IDs and a counting clock: durations are synthetic, not measured latency.
"""
import hashlib
import json
from pathlib import Path
import subprocess
import sys
from tracing import Tracer, DerivedIds, CountingClock, collect

HERE = Path(__file__).resolve().parent
PAGES = json.loads((HERE/'docuctl_pages.json').read_text())


def digest(text):
    return hashlib.sha256(text.encode('utf-8')).hexdigest()


class EvidenceError(ValueError):
    pass


def evidence_verdict(citation, kept, current):
    if citation not in current:
        return 'unknown citation'
    if citation not in kept:
        return 'citation not sent to answer step'
    if kept[citation]['sha256'] != digest(current[citation]['text']):
        return 'stale evidence'
    return 'current kept source'


def worker(packet):
    spans=[]
    tracer=Tracer('docs-worker',spans.append,clock=CountingClock(),ids=DerivedIds('docuctl-worker'))
    with tracer.span('lookup',traceparent=packet.get('traceparent'),page_id=packet['page_id']):
        page=PAGES[packet['page_id']]
        text='Use an older staging command.' if packet.get('stale') else page['text']
        passage={'id':packet['page_id'],'version':page['version'],'text':text,'sha256':digest(text)}
    return {'passage':passage,'spans':spans}


def run(*, question='Which command deploys staging version 3.5?', propagate=True, stale=False, drop=False):
    spans=[];result={}
    tracer=Tracer('gateway',spans.append,clock=CountingClock(),ids=DerivedIds('docuctl-gateway'))
    try:
        with tracer.span('request',case_id='staging-deploy'):
            if not isinstance(question,str) or not question.strip():raise ValueError('question must contain text')
            with tracer.span('lookup.call',page_id='deploy-35') as current:
                packet={'page_id':'deploy-35','stale':stale}
                if propagate:packet['traceparent']=current.traceparent()
                process=subprocess.run([sys.executable,str(Path(__file__).resolve()),'--worker'],
                    input=json.dumps(packet),text=True,capture_output=True,check=True,timeout=10)
                response=json.loads(process.stdout)
                spans.extend(response['spans'])
            with tracer.span('assemble',source_id=response['passage']['id'],source_sha256=response['passage']['sha256']):
                kept={} if drop else {response['passage']['id']:response['passage']}
            with tracer.span('provenance',citation_id='deploy-35') as check:
                verdict=evidence_verdict('deploy-35',kept,PAGES)
                check.set(verdict=verdict)
                if verdict!='current kept source':raise EvidenceError(verdict)
                result={'citation':'deploy-35','text':kept['deploy-35']['text'],'provenance':verdict}
    except (ValueError,EvidenceError) as error:
        result={'error':str(error)}
    return {'execution_mode':'fixed local lookup; two processes; no model request',
            'timing_clock':'synthetic counting clock; not measured latency',
            'result':result,'spans':spans,'traces':collect(spans)}

if __name__=='__main__':
    data=worker(json.load(sys.stdin)) if '--worker' in sys.argv else run()
    print(json.dumps(data,indent=2))

Follow the gateway-to-worker hop

Read the run function first. Its lookup.call span supplies traceparent to the child’s JSON envelope. The subprocess receives a fixed page ID and returns a bundled page. No external service or model is needed. The worker uses a separate tracer and exporter list, so the process boundary is real even though the fixture clock is simulated.

Compare source failures

The default run retains the current passage and its digest, so the provenance check succeeds. The tests also call run(stale=True) and run(drop=True). The first keeps an older text under a real page ID; the second retrieves the page and then omits it from the retained set. They must produce different errors.

run(propagate=False) demonstrates a different failure: the source check can still pass, but the worker appears in a separate trace. This is why a response outcome and trace completeness need separate checks.

Inspect a failed root

A blank question raises inside the request span. The context manager marks that span as failed before the driver records the error. The caller does not receive a citation for that request. The tests also check that raw question, passage and response text do not leak into the span export.

Inspect the local result

docuctl-output.json · local resultRecorded output · jsonDownload
{
  "result": {
    "citation": "deploy-35",
    "text": "Use docuctl deploy --env staging --version 3.5 for the staging deployment.",
    "provenance": "current kept source"
  },
  "spans": [
    {
      "name": "lookup",
      "trace_id": "75b849191c38f26cedf876ec4970f79d",
      "span_id": "7fa690b9d39e7f15",
      "parent_id": "d630d560a45546c1"
    },
    {
      "name": "lookup.call",
      "trace_id": "75b849191c38f26cedf876ec4970f79d",
      "span_id": "d630d560a45546c1",
      "parent_id": "da6fca857d1004da"
    },
    {
      "name": "assemble",
      "trace_id": "75b849191c38f26cedf876ec4970f79d",
      "span_id": "f46783e4798b1d1d",
      "parent_id": "da6fca857d1004da"
    },
    {
      "name": "provenance",
      "trace_id": "75b849191c38f26cedf876ec4970f79d",
      "span_id": "384b8a1983e8f11c",
      "parent_id": "da6fca857d1004da"
    },
    {
      "name": "request",
      "trace_id": "75b849191c38f26cedf876ec4970f79d",
      "span_id": "da6fca857d1004da",
      "parent_id": null
    }
  ]
}
A response cites a known page retained during assembly. What additional check detects a stale copy?

Choose one answer, then check your reasoning.

Read the explanation

This check establishes known, retained and current source identity. It does not prove that a generated answer follows from that source; the local response here is a fixed quotation.

Failure cases

Propagation is missing. The worker starts a separate trace. Invalid trace identity. Invalid or all-zero IDs are rejected by the parser rather than accepted as a valid parent.

Stale source. A real page ID with a different content digest is rejected. Dropped source. A retrieved page that was not retained cannot justify the response. Unknown citation. An ID outside the current page set is rejected separately.

Blank question. The root span is marked as failed and no citation is returned. Content in telemetry. The tests inspect exported attributes to keep question and page text out of spans. The exact saved-result test uses the deterministic clock and IDs; it is not a live latency benchmark.

Deployment

In an HTTP service, pass trace identity at the request boundary and configure exporters for each process. Keep request content out of tracing attributes unless an explicit data-handling policy permits it. Restrict access and retention for trace data as well as application records.

The teaching tracer exports local structures and implements a small part of trace-context handling. A production integration should use a maintained tracing implementation and test its propagation, sampling and exporter failure behavior. The separate historical lab includes a local HTTP process and saved model responses; its successful replay does not establish current deployment performance.

Scaling

Trace volume grows with requests and spans per request. A deployment must bound attribute sizes, exporter buffering and retention. Sampling reduces retained traces and can remove the very failure you need to inspect, so evaluate the sampling policy with known failure paths.

The counting clock and tiny case set establish none of those capacity limits. Use this lab to check tree identity and source boundaries before designing a load experiment.

Key takeaways

  • Trace identity must cross the process boundary explicitly.
  • Retrieval, retained context and current source identity are different source checks.
  • A complete trace does not prove a correct answer, and deterministic durations are not performance measurements.

Best practices

  • Preserve parent identity and inspect missing propagation separately from missing exports.
  • Retain source IDs and content hashes at assembly time, then check what the response actually cites.
  • Keep raw content out of telemetry and treat hashes according to their sensitivity.
  • Preserve original model traces without rewriting their inputs to match a new demonstration.

References

Official documentation checked October 20, 2026:

Written by Arjun Prabhulal.

Module 4

Control access, guard output and inspect requests