Distributed Tracing
The two IDs
Section titled “The two IDs”protoWorkstacean uses two IDs to track causality across the bus, the executor layer, and external A2A calls:
| ID | Header | Bus field | Meaning |
|---|---|---|---|
correlationId | X-Correlation-Id | msg.correlationId | Trace ID — identifies the entire logical flow. Never changes. Set once at the entry point. |
parentId | X-Parent-Id | msg.parentId | Span 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.
Where correlationId is set
Section titled “Where correlationId is set”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
correlationIdfrom theworld.goal.violatedmessage, which inherited it from theworld.state.updatedmessage, which was generated byWorldStateEnginewhen the poll completed
Once set, correlationId must not be changed. Every downstream bus message, every A2A call, every executor invocation carries the same value.
Where parentId is set
Section titled “Where parentId is set”parentId is the span boundary. It changes at each meaningful processing step:
-
RouterPlugin publishes
agent.skill.requestwithparentId = inboundMessage.id. The inbound message’sidis the parent span of the skill dispatch. -
SkillDispatcherPlugin constructs a
SkillRequestwithparentId = busMessage.id. This is the critical linkage: theSkillRequestknows which bus message caused it. -
A2AExecutor sends
X-Parent-Id: req.parentIdto the external agent. The receiving service should record this as its parent span and generate its own span ID for its own work. -
Agents that respond via
agent.skill.response.<correlationId>carry the samecorrelationId. They may include their own span information in the response payload, but the bus message’sid(a new UUID generated by the bus) becomes theparentIdfor 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-003 → msg-002 → msg-001 all share trace-abc.
contextId in A2A responses
Section titled “contextId in A2A responses”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.
Why not use OpenTelemetry directly
Section titled “Why not use OpenTelemetry directly”OpenTelemetry is the right long-term answer. The current implementation uses plain strings because:
- The bus is the primary observability layer — every message is written to
data/events.dbwith its full payload, andcorrelationId+parentIdare indexed columns - Adding an OTel exporter is a future concern; the IDs are already there when that work happens
- The A2A protocol boundary (HTTP headers) is already compatible with OTel trace context —
X-Correlation-Idmaps totraceparent’s trace-id,X-Parent-Idmaps 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.