Skip to content

Distributed Tracing

protoWorkstacean uses two IDs to track causality across the bus, the executor layer, and external A2A calls:

IDHeaderBus fieldMeaning
correlationIdX-Correlation-Idmsg.correlationIdTrace ID — identifies the entire logical flow. Never changes. Set once at the entry point.
parentIdX-Parent-Idmsg.parentIdSpan ID — identifies the immediate cause. Changes at each layer boundary.

This is the same mental model as OpenTelemetry traceId / parentSpanId. The implementation is lightweight (plain strings in bus messages and HTTP headers) but the semantics are equivalent.

correlationId is set once, at message entry:

  • DiscordPlugin: generates crypto.randomUUID() for each @mention
  • GitHubPlugin: uses the GitHub delivery ID
  • POST /publish: caller provides it, or the HTTP handler generates one if omitted
  • SchedulerPlugin: generates one per cron fire
  • ActionDispatcherPlugin: inherits the correlationId from the world.goal.violated message, which inherited it from the world.state.updated message, which was generated by WorldStateEngine when the poll completed

Once set, correlationId must not be changed. Every downstream bus message, every A2A call, every executor invocation carries the same value.

parentId is the span boundary. It changes at each meaningful processing step:

  1. RouterPlugin publishes agent.skill.request with parentId = inboundMessage.id. The inbound message’s id is the parent span of the skill dispatch.

  2. SkillDispatcherPlugin constructs a SkillRequest with parentId = busMessage.id. This is the critical linkage: the SkillRequest knows which bus message caused it.

  3. A2AExecutor sends X-Parent-Id: req.parentId to the external agent. The receiving service should record this as its parent span and generate its own span ID for its own work.

  4. Agents that respond via agent.skill.response.<correlationId> carry the same correlationId. They may include their own span information in the response payload, but the bus message’s id (a new UUID generated by the bus) becomes the parentId for any subsequent messages triggered by the response.

Flow example: Discord mention → protoMaker team → response

Section titled “Flow example: Discord mention → protoMaker team → response”
Discord @mention arrives
bus message {
id: "msg-001",
correlationId: "trace-abc",
parentId: undefined, ← entry point, no parent
topic: "message.inbound.discord.1234"
}
RouterPlugin publishes:
bus message {
id: "msg-002",
correlationId: "trace-abc",
parentId: "msg-001", ← Discord message is the parent
topic: "agent.skill.request",
payload: { skill: "sitrep", parentId: "msg-001", ... }
}
SkillDispatcherPlugin constructs SkillRequest:
{
skill: "sitrep",
correlationId: "trace-abc",
parentId: "msg-002", ← skill.request bus message is the parent
replyTopic: "agent.skill.response.trace-abc"
}
A2AExecutor calls the protoMaker team server:
POST /a2a
Headers:
X-Correlation-Id: trace-abc
X-Parent-Id: msg-002
X-API-Key: ...
Body: { contextId: "trace-abc", ... }
The protoMaker team processes, publishes response on agent.skill.response.trace-abc:
bus message {
id: "msg-003",
correlationId: "trace-abc",
parentId: "msg-002", ← protomaker response is a child of the skill request
topic: "agent.skill.response.trace-abc"
}
DiscordPlugin posts the reply.

The full chain is reconstructable from the log: msg-003msg-002msg-001 all share trace-abc.

The A2A spec uses contextId in the response to carry the trace ID back. A2AExecutor sends contextId: req.correlationId in the request. External A2A agents (the protoMaker team, Quinn, protoContent) propagate this value in the response. workstacean reads it and confirms the response belongs to the right trace.

If the contextId in the response does not match req.correlationId, the response is a stale reply from a previous conversation and is discarded.

OpenTelemetry is the right long-term answer. The current implementation uses plain strings because:

  1. The bus is the primary observability layer — every message is written to data/events.db with its full payload, and correlationId + parentId are indexed columns
  2. Adding an OTel exporter is a future concern; the IDs are already there when that work happens
  3. The A2A protocol boundary (HTTP headers) is already compatible with OTel trace context — X-Correlation-Id maps to traceparent’s trace-id, X-Parent-Id maps to the parent-id field

The implementation was deliberately kept simple to avoid a dependency on an OTel SDK before the instrumentation requirements are known.