Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.trodo.ai/docs/llms.txt

Use this file to discover all available pages before exploring further.

What / When / Minimum

When your agent makes an HTTP call to another of your services (microservice, worker API, internal tool), you want the downstream work to appear as nested spans in the same run — not as a separate run with no link back. Trodo solves this with two headers — X-Trodo-Run-Id and X-Trodo-Parent-Span-Id — and a symmetric pair of helpers:
  • Caller: propagationHeaders() → inject into the outbound request.
  • Callee: expressMiddleware() / fastapi_middleware() → detect headers, call joinRun automatically.
Result: one run, a waterfall that spans two services.

The recipe

(a) Caller — inject headers

import trodo from 'trodo-node';

await trodo.wrapAgent('orchestrator', async () => {
  // propagationHeaders() returns {} when called outside a run — safe to spread unconditionally
  const r = await fetch(`${WORKER_URL}/search`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      ...trodo.propagationHeaders(),
    },
    body: JSON.stringify({ query: 'vector dbs' }),
  });
  return await r.json();
});

(b) Callee — Express middleware

import express from 'express';
import trodo from 'trodo-node';

trodo.init({ siteId: process.env.TRODO_SITE_ID });

const app = express();
app.use(express.json());
app.use(trodo.expressMiddleware()); // drop-in

app.post('/search', async (req, res) => {
  const hits = await trodo.withSpan('vector.search', async (s) => {
    s.setInput(req.body);
    return await search(req.body.query);
  }, { kind: 'retrieval' });
  res.json({ hits });
});
The middleware detects X-Trodo-Run-Id; if present, it opens a span (kind='agent', named http.POST./search by default) under the caller’s run and runs the handler inside that context. Any withSpan inside the handler nests below it. If no header is present, the middleware is a pure pass-through — the handler runs with no active run, so tracking is a silent no-op.

(c) Callee — FastAPI middleware

from fastapi import FastAPI
import trodo

trodo.init(site_id=os.environ["TRODO_SITE_ID"])

app = FastAPI()
app.middleware("http")(trodo.fastapi_middleware())

@app.post("/search")
async def search(body: dict):
    with trodo.span("vector.search", kind="retrieval") as s:
        s.set_input(body)
        hits = await vector_search(body["query"])
        s.set_output({"count": len(hits)})
        return {"hits": hits}

(d) Callee — manual joinRun (workers, queues, gRPC)

When you can’t use HTTP middleware — a BullMQ worker, an SQS consumer, a gRPC handler — call joinRun yourself:
queue.process(async (job) => {
  const { runId, parentSpanId, ...payload } = job.data;
  if (!runId) {
    // Job wasn't dispatched inside a run — open a fresh one.
    return await trodo.wrapAgent('queue-worker', () => process(payload));
  }
  return await trodo.joinRun(runId, parentSpanId, async () => {
    return await process(payload);
  }, { name: 'queue.process', kind: 'agent', input: payload });
});
On the enqueue side:
await queue.add('process', {
  ...payload,
  runId: trodo.currentRunId(),
  parentSpanId: trodo.currentSpanId(), // if you expose it
});

Full example — two services, one run

service-a/parent.js (receives user traffic, orchestrates)
import trodo from 'trodo-node';
import express from 'express';

trodo.init({ siteId: process.env.TRODO_SITE_ID });
const app = express();

app.post('/chat', express.json(), async (req, res) => {
  const { result } = await trodo.wrapAgent('parent-service', async (run) => {
    run.setInput(req.body);

    const kb = await fetch(`${KB_URL}/search`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', ...trodo.propagationHeaders() },
      body: JSON.stringify({ q: req.body.q }),
    }).then((r) => r.json());

    return synthesize(kb.hits);
  });
  res.json({ result });
});
service-b/worker.js (KB service)
import trodo from 'trodo-node';
import express from 'express';

trodo.init({ siteId: process.env.TRODO_SITE_ID });
const app = express();
app.use(express.json());
app.use(trodo.expressMiddleware());

app.post('/search', async (req, res) => {
  const hits = await trodo.withSpan('kb.search', async (s) => {
    s.setInput(req.body);
    return await kbSearch(req.body.q);
  }, { kind: 'retrieval' });
  res.json({ hits });
});
Dashboard shows a single run parent-service with:
  • http.POST./search span (auto-opened by the middleware on service-b)
    • kb.search (retrieval) ← nested inside because it was inside the middleware’s context

Verification

After triggering a request, trodo.currentRunId() inside the handler on service-b returns the same UUID the parent generated. That’s the single strongest signal propagation worked. The scenarios in backend/scripts/agent-scenarios/12-crossservice-node-node/ demonstrate this end-to-end.

Headers reference

HeaderTypeSet byRead by
X-Trodo-Run-IdUUIDpropagationHeaders()middleware / manual joinRun
X-Trodo-Parent-Span-IdUUIDpropagationHeaders()middleware / manual joinRun
X-Trodo-Agent-Namestring(optional) callermiddleware uses for default span name

Gotchas

  • The callee’s SDK must be initialised (trodo.init) with any valid site id — it doesn’t need to be the same as the caller’s, because the run is identified by UUID. In practice, use the same site so both services’ metrics land in the same team.
  • Circular calls (A→B→A) work but produce a deep waterfall. Trodo deduplicates spans by UUID, so no double-counting.
  • If the callee’s handler throws before awaiting the middleware’s wrapped work, the joined span is recorded as error — parent’s error_count increments.
  • Browser-originated requests never carry Trodo headers (browsers don’t have propagationHeaders). Propagation is server-to-server only.

See also

  • Sub-agents — when you want a separate run linked via parent_run_id instead of a joined span.
  • Propagation internals — how ALS / contextvars hand off context through async boundaries.