Every OCG v0.3 decision chain is already a distributed trace waiting to be emitted. The execution_hash maps to a spanId, parent_hashes[0] maps to a parentSpanId, and the root artifact's hash anchors the traceId. This guide covers the canonical field mapping, the artifactToOtelSpan() adapter function, OTLP export, and how to surface full decision chains (credit scoring, risk evaluation, compliance gating, capital adequacy) as end-to-end distributed traces in Datadog, Jaeger, or Grafana Tempo. No new infrastructure required if you already run an OTel collector.
OpenTelemetry's distributed tracing model describes a trace as a tree of spans, where each span has a unique 8-byte spanId, an optional 8-byte parentSpanId, and both share a common 16-byte traceId. A span also carries a name, a start and end timestamp, and a set of key-value attributes describing what happened.
An OCG v0.3 decision chain has the same shape. A root artifact (say, an AP2 mandate validator) runs and produces an execution_hash. A downstream artifact (say, a capital adequacy assessor) declares that root hash in its parent_hashes[] field, establishing the parent-child relationship. A full decision chain (mandate → risk → compliance → capital) is a depth-4 span tree with a shared root hash as the trace anchor.
The mapping is not an approximation. It is structurally exact: OCG's hash-based chaining mechanism and OTel's ID-based parent linking describe identical DAGs. The artifactToOtelSpan() adapter (§4) is a pure conversion; no information is added or lost.
| OTel concept | OCG v0.3 equivalent | Notes |
|---|---|---|
| traceId (16 bytes) | execution_hash of the root artifact, first 16 bytes |
Same value for every span in the chain. Identifies the originating decision event. |
| spanId (8 bytes) | execution_hash of this artifact, first 8 bytes |
Unique within the trace by construction; SHA-256 collisions are not a practical concern at this key space. |
| parentSpanId (8 bytes) | parent_hashes[0], first 8 bytes |
Omitted on root spans. If an artifact has multiple parent_hashes[], [0] is the primary parent; siblings are carried in span attributes. |
| name | mandate_type + tool_id |
Example: "liquidity_mandate/liquidity-coverage-ratio-monitor" |
| startTimeUnixNano | generated_at (ISO 8601 → nanoseconds) |
OCG records generation time, not wall-clock execution start. For single-turn tools, these are equivalent. |
| endTimeUnixNano | generated_at + estimated duration |
OCG does not record execution duration. Default to startTime + 1ms or pass duration_ms as an optional attribute. |
| status | Derived from compliance_flags[] |
PASS → OK; any FAIL → ERROR; all PARTIAL → UNSET. |
| attributes | All remaining OCG fields | tool_version, ap2_version, mandate_type, execution_hash (full), audit_signature.signatures[0].keyid, etc. |
OTel traceId is exactly 16 bytes (128 bits) and spanId is exactly 8 bytes (64 bits), expressed as lowercase hex strings. execution_hash is a full SHA-256 hex string (64 hex chars = 32 bytes). Taking the first 32 hex chars (16 bytes) for traceId and the first 16 hex chars (8 bytes) for spanId is the canonical truncation: it preserves the uniqueness properties of SHA-256 at the required bit widths. The full 64-char hash is preserved in the ocg.execution_hash span attribute for verification purposes.
The complete field-level mapping, used by the artifactToOtelSpan() function in §4. Fields in the left column are the OCG source; fields in the center are the OTel destination. Every OCG field not mapped to a standard OTel field is carried as a prefixed span attribute (ocg.*).
execution_hash. Must be propagated from the chain root; not derivable from a single artifact in isolation.execution_hash.parent_hashes[0]. Omitted (root span) when parent_hashes is absent or empty.`${mandate_type}/${tool_id}`, e.g. "liquidity_mandate/lcr-monitor".new Date(generated_at).getTime() * 1_000_000, ISO 8601 to Unix nanoseconds.duration_ms attribute to override.FAIL → { code: 2, message: "FAIL" } (ERROR). All PASS → { code: 1 } (OK). Mixed/PARTIAL → { code: 0 } (UNSET).ocg., e.g. ocg.tool_id, ocg.execution_hash, ocg.signer_keyid, ocg.secondary_parent_hashes.A four-tool payment authorization chain (AP2 mandate validation, risk scoring, compliance gating, and capital adequacy) produces four OCG artifacts. Converted to OTel spans and emitted to a backend, they appear as a single end-to-end trace with the mandate validator at the root. This is what Datadog's trace view, Jaeger, and Grafana Tempo all render from the same span data.
Each bar is a single OCG artifact. The parent-child relationships are established by matching each span's parentSpanId (derived from the parent artifact's execution_hash) to the corresponding spanId. The tracing backend assembles the tree: OCG provides the hash wiring; OTel provides the rendering.
The adapter needs to know which artifact in the chain is the root in order to compute the shared traceId. Three approaches: (1) pass rootHash explicitly to artifactToOtelSpan(); (2) resolve it by walking parent_hashes[] back to an artifact with no parent; (3) use the chain.root_hash field if your chain serialization includes it. If rootHash is omitted and no parent exists, the adapter defaults to treating the current artifact as root and using its own execution_hash as both traceId and spanId prefix.
artifactToOtelSpan() adapter functionThe canonical JavaScript implementation. It converts a single OCG v0.3 artifact to an OTel span object in the OTLP JSON format used by the OTel Collector's HTTP receiver. The output is directly serializable to the /v1/traces endpoint.
/** * artifactToOtelSpan(artifact, options) * artifact — a completed OCG v0.3 artifact object * options.rootHash — hex string: execution_hash of the chain root artifact * (if omitted, this artifact is treated as root) * options.durationMs — number: override the default 1ms span duration * * Returns an OTLP-compatible span object for use in a ResourceSpans payload. */ function artifactToOtelSpan(artifact, options = {}) { const rootHash = options.rootHash ?? artifact.execution_hash; const durationMs = options.durationMs ?? 1; const isRoot = rootHash === artifact.execution_hash; // Derive OTel IDs from execution hashes (hex substring truncation) const traceId = rootHash.slice(0, 32); // 16 bytes = 32 hex chars const spanId = artifact.execution_hash.slice(0, 16); // 8 bytes = 16 hex chars const parentSpanId = (!isRoot && artifact.parent_hashes?.length) ? artifact.parent_hashes[0].slice(0, 16) : undefined; // Derive OTel status from compliance_flags[] const flags = artifact.compliance_flags ?? []; let statusCode = 0; // UNSET if (flags.every(f => f.status === "PASS")) statusCode = 1; // OK else if (flags.some(f => f.status === "FAIL")) statusCode = 2; // ERROR // Timestamps: nanosecond precision const startNs = BigInt(new Date(artifact.generated_at).getTime()) * 1_000_000n; const endNs = startNs + BigInt(durationMs) * 1_000_000n; // Build span name from mandate_type + tool_id const spanName = `${artifact.mandate_type ?? "unknown"}/${artifact.tool_id ?? "unknown"}`; // Collect all secondary parent hashes as an attribute const secondaryParents = (artifact.parent_hashes ?? []).slice(1); // Build the span object (OTLP JSON schema) const span = { traceId, spanId, name: spanName, kind: 3, // SPAN_KIND_CLIENT — deterministic tool invocation startTimeUnixNano: startNs.toString(), endTimeUnixNano: endNs.toString(), status: { code: statusCode, ...(statusCode === 2 && { message: flags.filter(f => f.status === "FAIL").map(f => f.framework).join(", ") }) }, attributes: [ { key: "ocg.tool_id", value: { stringValue: artifact.tool_id ?? "" } }, { key: "ocg.tool_version", value: { stringValue: artifact.tool_version ?? "" } }, { key: "ocg.ap2_version", value: { stringValue: artifact.ap2_version ?? "" } }, { key: "ocg.mandate_type", value: { stringValue: artifact.mandate_type ?? "" } }, { key: "ocg.execution_hash", value: { stringValue: artifact.execution_hash ?? "" } }, { key: "ocg.generated_at", value: { stringValue: artifact.generated_at ?? "" } }, { key: "ocg.zero_pii_verified", value: { boolValue: artifact.audit_signature?.audit_signature?.zero_pii_verified ?? true } }, ...(artifact.audit_signature?.signatures?.[0]?.keyid ? [{ key: "ocg.signer_keyid", value: { stringValue: artifact.audit_signature.signatures[0].keyid } }] : []), ...(artifact.audit_signature?.signatures?.[0]?.sig ? [{ key: "ocg.audit_sig", value: { stringValue: artifact.audit_signature.signatures[0].sig } }] : []), ...(secondaryParents.length ? [{ key: "ocg.secondary_parent_hashes", value: { stringValue: secondaryParents.join(",") } }] : []), ...(flags.length ? [{ key: "ocg.compliance_flags", value: { stringValue: JSON.stringify(flags) } }] : []), ] }; // Attach parentSpanId only on non-root spans if (parentSpanId) span.parentSpanId = parentSpanId; return span; } /** * chainToOtlpPayload(artifacts, options) * artifacts — array of OCG artifacts in any order; root is auto-detected * options.serviceName — string for the OTel Resource (default: "ocg-chain") * options.durationMs — optional per-span duration override * * Returns a complete OTLP ResourceSpans payload, ready for POST to /v1/traces. */ function chainToOtlpPayload(artifacts, options = {}) { const serviceName = options.serviceName ?? "ocg-chain"; // Auto-detect root: artifact whose execution_hash doesn't appear in any parent_hashes[] const allParentHashes = new Set( artifacts.flatMap(a => a.parent_hashes ?? []) ); const rootArtifact = artifacts.find(a => !allParentHashes.has(a.execution_hash)); const rootHash = rootArtifact?.execution_hash ?? artifacts[0].execution_hash; const spans = artifacts.map(a => artifactToOtelSpan(a, { ...options, rootHash })); return { resourceSpans: [{ resource: { attributes: [ { key: "service.name", value: { stringValue: serviceName } }, { key: "ocg.root_hash", value: { stringValue: rootHash } }, { key: "ocg.chain_length", value: { intValue: artifacts.length } } ] }, scopeSpans: [{ scope: { name: "ocg-otel-adapter", version: "0.2.0" }, spans }] }] }; }
OTel defines five span kinds: SERVER, CLIENT, PRODUCER, CONSUMER, and INTERNAL. OCG tools are modeled as CLIENT spans (kind 3) because they initiate a deterministic operation and wait for a result, analogous to an RPC call in a microservices trace. This makes OCG decision spans visually distinct from your application's server spans in Datadog and Jaeger while keeping them correctly positioned in the trace hierarchy.
The OTLP/HTTP endpoint (/v1/traces) is the lowest-friction export path: a single fetch() call, no SDK required. If your environment already runs an OTel Collector (even just the default agent that forwards to Datadog, Jaeger, or Grafana Tempo) OCG spans flow through it with zero additional infrastructure.
/** * exportChainToOtel(artifacts, collectorUrl, options) * artifacts — array of completed OCG v0.3 artifact objects * collectorUrl — base URL of the OTel Collector HTTP receiver * e.g. "http://localhost:4318" or "https://otlp.nr-data.net" * options — passed through to chainToOtlpPayload() * * Returns { ok: bool, status: number, message: string } */ async function exportChainToOtel(artifacts, collectorUrl, options = {}) { const payload = chainToOtlpPayload(artifacts, options); const url = collectorUrl.replace(/\/+$/, "") + "/v1/traces"; try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...(options.headers ?? {}) // pass API keys / auth headers here }, body: JSON.stringify(payload) }); if (!res.ok) { const text = await res.text().catch(() => ""); return { ok: false, status: res.status, message: text }; } return { ok: true, status: res.status, message: "Spans accepted" }; } catch (err) { return { ok: false, status: 0, message: err.message }; } } // Usage — emit a 4-tool decision chain to a local collector: const result = await exportChainToOtel( [mandateArtifact, riskArtifact, complianceArtifact, capitalArtifact], "http://localhost:4318", { serviceName: "ainumbers-payment-auth" } ); console.log(result); // { ok: true, status: 200, message: "Spans accepted" }
OCG tools run entirely in-browser with no network calls during execution. The OTLP export call above happens after execution completes, triggered explicitly by the application layer, not by the tool itself. The artifacts are already computed and signed; the OTel call is an optional downstream delivery step. This means OTel export can be conditionally enabled, batched, or omitted entirely without affecting the integrity of the artifact or its execution_hash.
The OTLP payload format is identical for all three backends. The only differences are the collector URL, the authentication header, and minor backend-specific attribute conventions. In each case, the existing OTel Collector in your environment handles translation; you do not need a separate OCG integration per backend.
exportChainToOtel() at your local Datadog Agent's OTLP receiver (localhost:4318 by default when otlp_config.receiver.protocols.http is enabled in datadog.yaml). The agent translates OTLP to Datadog's native trace format. Decision chains appear in APM → Traces with full waterfall view. The service.name resource attribute sets the Datadog service. No API key needed in the fetch() call; authentication is the agent's responsibility.collectorUrl to your Jaeger instance's OTLP receiver port (default 4318). No authentication headers required for local/internal deployments. In Jaeger UI, filter by service: ainumbers-payment-auth to find the chain, then click into the root span to expand the full decision-chain waterfall. ocg.* attributes are searchable in the Tags panel.Authorization: Basic base64(instanceId:apiKey). Pass this via options.headers. In Grafana, use TraceQL to query by span.ocg.mandate_type = "liquidity_mandate" or filter by ocg.execution_hash to jump directly to a specific artifact's span.# otel-collector-config.yaml receivers: otlp: protocols: http: endpoint: "0.0.0.0:4318" cors: allowed_origins: ["*"] # restrict to your domain in production processors: batch: timeout: "5s" send_batch_size: 100 attributes: actions: - key: "deployment.environment" value: "production" action: insert exporters: datadog: api: key: "${DD_API_KEY}" jaeger: endpoint: "jaeger-collector:14250" tls: insecure: true otlp/tempo: endpoint: "tempo:4317" tls: insecure: true service: pipelines: traces: receivers: [otlp] processors: [batch, attributes] exporters: [datadog, jaeger, otlp/tempo]
Because OCG tools run in-browser, exportChainToOtel() is a cross-origin fetch() if the Collector is on a different host or port. The OTel Collector's HTTP receiver supports CORS via the cors.allowed_origins configuration key. In development, "*" is fine. In production, restrict to your application's origin. Alternatively, route through your own backend proxy to avoid CORS entirely: the proxy forwards the OTLP payload to the Collector server-side.
When OCG artifacts are generated or aggregated server-side (in a compliance pipeline, an MCP orchestrator, or a batch audit job) the Python adapter below converts an entire artifact chain to OTLP and submits it directly. Compatible with the same Collector configuration from §6.
import json, requests from datetime import datetime, timezone def iso_to_unix_ns(iso_str: str) -> int: """ISO 8601 string → Unix nanoseconds (integer).""" dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) return int(dt.timestamp() * 1e9) def artifact_to_otel_span(artifact: dict, root_hash: str, duration_ms: int = 1) -> dict: """Convert a single OCG v0.3 artifact to an OTLP span dict.""" execution_hash = artifact["execution_hash"] parent_hashes = artifact.get("parent_hashes", []) is_root = (execution_hash == root_hash) trace_id = root_hash[:32] span_id = execution_hash[:16] parent_id = parent_hashes[0][:16] if (not is_root and parent_hashes) else None # Status from compliance_flags flags = artifact.get("compliance_flags", []) statuses = {f["status"] for f in flags} if "FAIL" in statuses: status_code = 2 elif statuses == {"PASS"}: status_code = 1 else: status_code = 0 start_ns = iso_to_unix_ns(artifact["generated_at"]) end_ns = start_ns + duration_ms * 1_000_000 mandate = artifact.get("mandate_type", "unknown") tool_id = artifact.get("tool_id", "unknown") attrs = [ {"key": "ocg.tool_id", "value": {"stringValue": tool_id}}, {"key": "ocg.tool_version", "value": {"stringValue": artifact.get("tool_version", "")}}, {"key": "ocg.mandate_type", "value": {"stringValue": mandate}}, {"key": "ocg.execution_hash", "value": {"stringValue": execution_hash}}, {"key": "ocg.generated_at", "value": {"stringValue": artifact.get("generated_at", "")}}, ] sig_block = (artifact.get("audit_signature") or {}).get("signatures", []) if sig_block: attrs.append({"key": "ocg.signer_keyid", "value": {"stringValue": sig_block[0].get("keyid", "")}}) if flags: attrs.append({"key": "ocg.compliance_flags", "value": {"stringValue": json.dumps(flags)}}) span = { "traceId": trace_id, "spanId": span_id, "name": f"{mandate}/{tool_id}", "kind": 3, "startTimeUnixNano": str(start_ns), "endTimeUnixNano": str(end_ns), "status": {"code": status_code}, "attributes": attrs, } if parent_id: span["parentSpanId"] = parent_id return span def export_chain_to_otel(artifacts: list, collector_url: str, service_name: str = "ocg-chain", duration_ms: int = 1, headers: dict = None) -> dict: """Convert a list of OCG artifacts and POST to an OTel Collector.""" # Auto-detect root all_parents = {h for a in artifacts for h in a.get("parent_hashes", [])} root = next((a for a in artifacts if a["execution_hash"] not in all_parents), artifacts[0]) root_hash = root["execution_hash"] spans = [artifact_to_otel_span(a, root_hash, duration_ms) for a in artifacts] payload = { "resourceSpans": [{ "resource": {"attributes": [ {"key": "service.name", "value": {"stringValue": service_name}}, {"key": "ocg.root_hash", "value": {"stringValue": root_hash}}, {"key": "ocg.chain_length", "value": {"intValue": len(artifacts)}}, ]}, "scopeSpans": [{ "scope": {"name": "ocg-otel-adapter", "version": "0.2.0"}, "spans": spans }] }] } url = collector_url.rstrip("/") + "/v1/traces" resp = requests.post(url, json=payload, headers=headers or {}, timeout=10) return {"ok": resp.ok, "status": resp.status_code, "body": resp.text} # Usage: # result = export_chain_to_otel(artifacts, "http://localhost:4318", service_name="payment-auth") # assert result["ok"], result["body"]
tools/call endpointFor agentic orchestration scenarios (where an MCP agent coordinates a decision chain and wants to emit a trace automatically after the chain completes) the OTel export function can be exposed as a named MCP tool. This allows an agent to request trace emission without knowing the collector URL or payload format.
{
"mcp_tool_definition": {
"name": "emit_ocg_otel_trace",
"description": "Convert a completed OCG v0.3 decision chain (array of artifacts) to OpenTelemetry spans and emit them to the configured OTel Collector. Returns the traceId and spanIds for downstream reference. Call this after a multi-tool OCG chain completes to make the full decision flow visible in Datadog, Jaeger, or Grafana Tempo.",
"inputSchema": {
"type": "object",
"required": ["artifacts"],
"properties": {
"artifacts": {
"type": "array",
"description": "Array of completed OCG v0.3 artifact objects. Order does not matter — root is auto-detected.",
"items": { "type": "object" },
"minItems": 1
},
"service_name": {
"type": "string",
"description": "OTel service.name resource attribute. Defaults to 'ocg-chain'.",
"default": "ocg-chain"
},
"duration_ms": {
"type": "integer",
"description": "Per-span duration in milliseconds. Defaults to 1.",
"default": 1
}
}
}
}
}
When tools/call receives a request for emit_ocg_otel_trace, the MCP server invokes chainToOtlpPayload() with the provided artifacts, POSTs to the configured Collector URL (injected as an environment variable, not from the agent), and returns the resulting traceId and span count. The agent can then include the traceId in its own output for correlation.
The OTel Collector endpoint is infrastructure configuration, not agent input. Exposing it as an agent-controllable parameter would allow an agent to redirect trace traffic to an arbitrary URL, a data-exfiltration path. The MCP server resolves the Collector URL from its own configuration (environment variable, secrets manager) and never exposes it in the tools/list response or the tool's inputSchema.
Not every OCG decision chain needs to be exported as a trace. High-volume environments (batch sanction screening, automated settlement validation) may generate thousands of artifacts per minute. The OTel Collector's tail sampling and attribute filtering processors let you selectively retain traces based on OCG-specific attributes without changing the adapter code.
# Add to the processors section of otel-collector-config.yaml processors: tail_sampling: decision_wait: "10s" num_traces: 50000 policies: # Always retain chains with any FAIL compliance flag - name: retain-fail-chains type: string_attribute string_attribute: key: "ocg.compliance_flags" values: ["*FAIL*"] # Always retain capital adequacy assessment chains - name: retain-capital-chains type: string_attribute string_attribute: key: "ocg.mandate_type" values: ["capital_assessment"] # Sample 10% of routine liquidity_mandate chains - name: sample-liquidity-chains type: probabilistic probabilistic: sampling_percentage: 10 # Drop all remaining traces (adjust as needed) - name: drop-rest type: always_off
OTel trace retention is for operational observability: debugging, latency profiling, alerting. It is separate from regulatory artifact retention. OCG artifacts (the signed AP2 JSON with execution_hash and audit_signature) must be retained per your compliance obligations regardless of whether a corresponding OTel trace exists. Dropping a trace from Datadog does not affect the artifact's integrity or audit trail. Keep artifact retention and trace retention policies independent.
Seven steps to verify that your OCG-to-OTel integration is working end-to-end. Each step is independently testable against a local OTel Collector or directly against a backend.
| Step | Action | Verified by |
|---|---|---|
| 1 | Run any OCG v0.3 tool and confirm the artifact has a 64-char lowercase hex execution_hash. This is the raw material for all OTel IDs. |
artifact.execution_hash.length === 64 && /^[0-9a-f]+$/.test(artifact.execution_hash) |
| 2 | Call artifactToOtelSpan(artifact, { rootHash: artifact.execution_hash }) on a single artifact. Inspect the result: traceId must be 32 chars, spanId 16 chars, no parentSpanId on a root. |
span.traceId.length === 32, span.spanId.length === 16, !("parentSpanId" in span) |
| 3 | Assemble a two-artifact chain: artifact B declares artifact A's execution_hash in its parent_hashes[]. Call chainToOtlpPayload([A, B]). Verify that B's parentSpanId equals A's spanId. |
spans[1].parentSpanId === spans[0].spanId |
| 4 | Start a local OTel Collector with the config from §6 (OTLP/HTTP on port 4318). Call exportChainToOtel([A, B], "http://localhost:4318"). Result must be { ok: true, status: 200 }. |
result.ok === true and Collector logs show "Traces exporter: pushing spans to the queue, count=2" |
| 5 | Open your backend UI (Datadog APM, Jaeger, Grafana Tempo). Search for traces with service.name equal to the value you passed as serviceName. Find the root span and confirm the child span is nested under it. |
Visual: two spans visible in a parent-child waterfall with matching traceId |
| 6 | Click into the root span. Confirm that ocg.execution_hash, ocg.mandate_type, ocg.tool_id, and ocg.signer_keyid (if signed) are all present in the span attributes. |
All four ocg.* attributes visible in the backend's span detail panel |
| 7 | Introduce a FAIL into a compliance_flags[] entry in a test artifact. Verify that the span status.code is 2 (ERROR) and that the backend renders the span in error state (red in Datadog, orange in Jaeger). |
Span renders as ERROR in backend UI; status.message contains the failing framework name |
The OTel adapter described in this guide requires no OTel SDK, no auto-instrumentation agent, no OpenTelemetry API package, and no modifications to the OCG tool itself. It is a pure JSON construction and a single fetch() call. If you already run an OTel Collector (which most teams with Datadog, Grafana Cloud, or a managed observability platform do) OCG decision-chain tracing requires zero new infrastructure. The only new code is artifactToOtelSpan() and chainToOtlpPayload(), both of which fit in a single file under 100 lines.