Cloudflare AI Chat tracing with Future AGI
Send Cloudflare AI Chat agent traces to Future AGI through a Workers OTLP destination and your own OpenTelemetry Collector.
Send Cloudflare AI Chat (@cloudflare/ai-chat) traces to Future AGI using Workers tracing and an OpenTelemetry Collector you run. The collector adds the project, maps span types and session attributes, deduplicates usage, and filters content before forwarding traces. Future AGI keys remain on the collector, not in the Worker.
This example uses AIChatAgent with a server-side tool, AI SDK v7, and the pinned packages below. Client-side tools and approvals, resumed or programmatic turns, sub-agents, multiple messages on one socket, sampling below 1, and Workers logs export are outside this recipe’s scope. A plain Cloudflare Agent or @cloudflare/think uses a different agent setup. Do not add @traceai/vercel to this Workers recipe; use Cloudflare’s wrapAISDK for model and tool spans.
Warning
Exporting traces to an OTLP destination requires Workers Paid. Review Cloudflare’s observability pricing before enabling export.
1. Install
npm install --save-exact @cloudflare/ai-chat@0.12.1 agents@0.28.1 ai@7.0.142 @ai-sdk/openai@4.0.95 zod@4.6.7
npm install --save-dev --save-exact wrangler@4.151.1
wrapAISDK ships in agents (agents/observability/ai). This supported recipe uses AI SDK v7 and the OpenAI provider versions pinned above.
2. Trace the AI SDK inside onChatMessage
Wrap the AI SDK namespace and call the wrapped streamText. Give the turn an agent identity, so the spans carry gen_ai.agent.name, gen_ai.agent.id and gen_ai.conversation.id.
// A Cloudflare AI Chat agent (@cloudflare/ai-chat) whose turns are traced by Workers tracing.
//
// Nothing in this file talks to Future AGI. The Worker only creates spans; Cloudflare exports them to the
// OTLP destination named in wrangler.jsonc, and your collector (otelcol.yaml) forwards them to Future AGI.
//
// AIChatAgent (chat_turn, persist_*, ... spans) emitted by @cloudflare/ai-chat itself
// wrapAISDK(ai) (invoke_agent, chat, execute_tool) emitted by agents/observability/ai
//
// Without wrapAISDK you still get the AIChatAgent spans, but no model, token or tool spans.
import { AIChatAgent } from "@cloudflare/ai-chat";
import { routeAgentRequest } from "agents";
import { wrapAISDK } from "agents/observability/ai";
import { createOpenAI } from "@ai-sdk/openai";
import * as ai from "ai";
import { z } from "zod";
type Env = {
SupportChat: DurableObjectNamespace;
OPENAI_API_KEY: string; // secret: `wrangler secret put OPENAI_API_KEY`
OPENAI_BASE_URL?: string; // optional, any OpenAI-compatible endpoint
MODEL?: string;
// Cloudflare's payload switches, both off unless set to "true". Off: no message text, tool arguments or
// tool results are written to spans at all, so nothing of it leaves Cloudflare.
STORE_MESSAGES?: string;
STORE_TOOLS?: string;
};
export class SupportChat extends AIChatAgent<Env> {
async onChatMessage() {
const { streamText } = wrapAISDK(ai, {
storeMessages: this.env.STORE_MESSAGES === "true",
storeTools: this.env.STORE_TOOLS === "true",
});
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY, baseURL: this.env.OPENAI_BASE_URL });
const result = streamText({
model: openai.chat(this.env.MODEL ?? "gpt-4o-mini"),
system: "You are a support agent. Use the lookup_order tool for any order id.",
messages: await ai.convertToModelMessages(this.messages),
tools: {
lookup_order: ai.tool({
description: "Look up an order by id",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => {
// Demo failure path: the tool throws, the turn continues, and the execute_tool span carries error.type.
if (orderId.startsWith("FAIL")) throw new Error(`order backend unavailable for ${orderId}`);
return { orderId, status: "shipped" };
},
}),
},
stopWhen: ai.isStepCount(3),
// Agent identity on invoke_agent, chat and execute_tool (gen_ai.agent.name / .id, gen_ai.conversation.id).
runtimeContext: { agentId: this.name, conversationId: this.ctx.id.toString() },
telemetry: { functionId: "support-chat", includeRuntimeContext: { agentId: true, conversationId: true } },
});
return result.toUIMessageStreamResponse();
}
}
export default {
async fetch(request: Request, env: Env) {
return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
STORE_MESSAGES and STORE_TOOLS are this example’s names for Cloudflare’s storeMessages and storeTools options. Both default to off.
3. Turn on tracing and name the destination
// The Worker as you deploy it.
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "support-chat",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": { "bindings": [{ "name": "SupportChat", "class_name": "SupportChat" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SupportChat"] }],
"observability": {
"traces": {
"enabled": true,
// The name of the Traces destination you create in the Cloudflare dashboard (Workers Observability).
"destinations": ["future-agi"],
// Every turn; lower it for busy Workers.
"head_sampling_rate": 1,
// Export to your collector only, keep no copy in the Cloudflare dashboard.
"persist": false
}
}
// Secrets, never in this file: wrangler secret put OPENAI_API_KEY
// Optional vars: MODEL, OPENAI_BASE_URL, STORE_MESSAGES / STORE_TOOLS ("true" opts in to payloads).
}
destinations must match the destination name (step 5). With persist: false Cloudflare keeps no copy in its own dashboard. Tracing needs no code in the Worker and no flush call: Cloudflare exports after the invocation.
4. Run the collector
The collector accepts OTLP/HTTP from Cloudflare only with your bearer token, drops the Durable Object storage spans, sets project_name, maps span types, errors and session attributes, keeps content out unless you opt in, and forwards to Future AGI.
# OpenTelemetry Collector (contrib) between a Cloudflare Workers OTLP traces destination and Future AGI.
#
# A Workers destination has a name, a type (Traces), an OTLP endpoint and optional custom headers. It has no
# field for resource attributes, and Future AGI rejects any batch whose resource has no `project_name`. This
# collector fills that gap and keeps your Future AGI keys out of Cloudflare:
# 1. accepts OTLP/HTTP from the destination, only with your bearer token (a custom header on the destination),
# 2. drops the per-call Durable Object storage spans (hundreds per chat turn, no AI information) and the test span
# Cloudflare sends when you create or edit the destination,
# 3. sets the Observe project,
# 4. maps the Agents SDK span names onto Observe span types, errors and sessions,
# 5. keeps prompts, completions, tool arguments/results and error text OUT unless you opt in,
# 6. forwards plain (uncompressed) OTLP to Future AGI with the two Future AGI key headers.
#
# Environment:
# CF_DESTINATION_TOKEN secret you invent; on the destination add the header `Authorization: Bearer <token>`
# FI_API_KEY, FI_SECRET_KEY Future AGI keys (they never go to Cloudflare)
# FI_PROJECT_NAME Observe project every Worker trace goes to
# FI_TRACES_URL full traces URL: https://api.futureagi.com/tracer/v1/traces
# or, self-hosted, http://<your-host>:<port>/v1/traces
# FI_CAPTURE_CONTENT optional, default false. `true` lets message text, tool arguments/results and error
# text through. Cloudflare only records them when the Worker sets STORE_MESSAGES /
# STORE_TOOLS, so content needs both switches.
extensions:
# Cloudflare sends from its network, so this endpoint must be reachable from the internet. Never leave it open.
bearertokenauth/cloudflare:
scheme: Bearer
token: ${env:CF_DESTINATION_TOKEN}
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
auth:
authenticator: bearertokenauth/cloudflare
# Put TLS in front of this (load balancer or reverse proxy), or set `tls:` here with cert_file and key_file.
processors:
# Creating or editing a destination makes Cloudflare send one test span ("Hello from Cloudflare ... (<destination>)",
# resource telemetry.sdk.name = pre-flight-check). The receiver still answers 200, so the check passes.
filter/preflight:
error_mode: ignore
traces:
span:
- resource.attributes["telemetry.sdk.name"] == "pre-flight-check"
# durable_object_storage_get / _put / _exec ...: one span per storage call. They are leaves (no children), so
# dropping them keeps every other parent link intact. Delete this processor to keep them.
filter/storage_spans:
error_mode: ignore
traces:
span:
- IsMatch(name, "^durable_object_storage_")
resource/future_agi:
attributes:
- key: project_name
value: ${env:FI_PROJECT_NAME}
action: upsert
- key: project_type
value: observe
action: upsert
transform/cloudflare_agents:
error_mode: ignore
trace_statements:
- context: span
statements:
# Span types. chat -> LLM and execute_tool -> TOOL are read by Future AGI directly; invoke_agent carries the
# turn's model and token totals, so it is pinned to AGENT (otherwise it would be counted as a second LLM
# call). Everything else (chat_turn, fetch, Durable Object and WebSocket invocations) is a chain step.
- set(attributes["gen_ai.span.kind"], "AGENT") where attributes["gen_ai.operation.name"] == "invoke_agent"
- set(attributes["fi.span.kind"], "CHAIN") where attributes["gen_ai.operation.name"] == nil
# invoke_agent repeats the sum of its chat spans' usage. Session and project totals add up every span, so
# keep usage on the chat spans only. The turn total stays readable as cloudflare.agents.usage.total_tokens.
- delete_matching_keys(attributes, "^gen_ai\\.usage\\.") where attributes["gen_ai.operation.name"] == "invoke_agent"
# Session attributes: one Durable Object instance is one conversation.
# This maps identity on the same span only; it does not stamp runtime roots.
- set(attributes["session.id"], attributes["cloudflare.agents.session.id"]) where attributes["cloudflare.agents.session.id"] != nil
- set(attributes["session.id"], attributes["gen_ai.conversation.id"]) where attributes["gen_ai.conversation.id"] != nil
# WebSocket upgrade requests: when the client closes the socket, Cloudflare ends the upgrade with outcome
# responseStreamDisconnected and status ERROR. That is a normal close, so clear the status; the outcome stays.
- set(status.code, STATUS_CODE_UNSET) where attributes["cloudflare.outcome"] == "responseStreamDisconnected"
- set(status.message, "") where attributes["cloudflare.outcome"] == "responseStreamDisconnected"
# Errors. The Workers custom-span API has no status setter, so the Agents SDK records a failure as the
# attribute error.type (an error class name, never the message). Turn that into span status ERROR.
- set(status.message, "error") where status.message != "" and "${env:FI_CAPTURE_CONTENT:-false}" != "true"
- set(status.code, STATUS_CODE_ERROR) where attributes["error.type"] != nil
- set(status.message, attributes["error.type"]) where attributes["error.type"] != nil
# Content ON: fill the Input and Output panes from the OpenTelemetry GenAI JSON strings.
- set(attributes["input.value"], attributes["gen_ai.input.messages"]) where attributes["gen_ai.input.messages"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
- set(attributes["output.value"], attributes["gen_ai.output.messages"]) where attributes["gen_ai.output.messages"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
- set(attributes["input.value"], attributes["gen_ai.tool.call.arguments"]) where attributes["gen_ai.tool.call.arguments"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
- set(attributes["output.value"], attributes["gen_ai.tool.call.result"]) where attributes["gen_ai.tool.call.result"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
# Content OFF (the default): messages, system instructions, tool arguments and results, in every spelling.
- delete_matching_keys(attributes, "^gen_ai\\.(input\\.messages|output\\.messages|system_instructions|tool\\.call\\.arguments|tool\\.call\\.result|prompt|completion)(\\..*)?$") where "${env:FI_CAPTURE_CONTENT:-false}" != "true"
- delete_matching_keys(attributes, "^(input|output)\\.value$") where "${env:FI_CAPTURE_CONTENT:-false}" != "true"
# Request URLs: keep the path, drop scheme, host, query string and fragment (tokens often ride in queries).
- replace_pattern(attributes["url.full"], "^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*([^?#]*).*$", "$$1")
- delete_key(attributes, "url.query")
# Cloudflare records the caller's city and region on request spans. Removed in both modes; country,
# continent and timezone stay.
- delete_matching_keys(attributes, "^geo\\.(locality|region|postal_code|location)(\\..*)?$")
# E-mail-shaped values (agent instance names, URLs) are replaced in both modes.
- replace_all_patterns(attributes, "value", "[A-Za-z0-9._+-]+(@|%40)[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}", "<email>")
- context: spanevent
statements:
# Exception and log events can carry messages, paths and stack traces: keep only the exception type.
- keep_keys(attributes, ["exception.type"]) where "${env:FI_CAPTURE_CONTENT:-false}" != "true"
# Future AGI reads at most 16 MiB per request, so batching keeps requests small.
batch:
send_batch_size: 256
send_batch_max_size: 512
timeout: 2s
exporters:
otlp_http/future_agi:
traces_endpoint: ${env:FI_TRACES_URL}
encoding: proto
compression: none # optional: Future AGI also accepts gzip bodies
headers:
X-Api-Key: ${env:FI_API_KEY}
X-Secret-Key: ${env:FI_SECRET_KEY}
service:
extensions: [bearertokenauth/cloudflare]
telemetry:
metrics:
level: none # no self-metrics endpoint; this collector handles traces only
logs:
level: warn
pipelines:
traces:
receivers: [otlp]
processors: [filter/preflight, filter/storage_spans, resource/future_agi, transform/cloudflare_agents, batch]
exporters: [otlp_http/future_agi]
export CF_DESTINATION_TOKEN="$(openssl rand -hex 24)" # paste into the destination header in step 5
export FI_API_KEY="your-futureagi-api-key"
export FI_SECRET_KEY="your-futureagi-secret-key"
export FI_PROJECT_NAME="support-chat"
export FI_TRACES_URL="https://api.futureagi.com/tracer/v1/traces" # self-hosted: http://<host>:<port>/v1/traces
docker run -d --name fi-cloudflare-collector -p 4318:4318 \
-e CF_DESTINATION_TOKEN -e FI_API_KEY -e FI_SECRET_KEY -e FI_PROJECT_NAME -e FI_TRACES_URL -e FI_CAPTURE_CONTENT \
-v "$PWD/otelcol.yaml:/etc/otelcol-contrib/config.yaml:ro" \
otel/opentelemetry-collector-contrib:0.162.0
Cloudflare sends from its network, so the collector needs a public HTTPS address with TLS in front of port 4318. Future AGI does not host the collector.
On a platform that assigns the listening port, such as Google Cloud Run, keep otelcol.yaml unchanged and load a second file after it that only moves the receiver:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:${env:PORT}
otelcol-contrib --config=otelcol.yaml --config=port.yaml
5. Create the destination and deploy
In the Cloudflare dashboard, go to Observability > Destinations, select Add destination, and fill in:
| Field | Value |
|---|---|
| Destination name | future-agi (the name in wrangler.jsonc) |
| Destination type | Traces |
| OTLP endpoint | https://<your-collector-host>/v1/traces |
| Custom headers | Authorization: Bearer <CF_DESTINATION_TOKEN> |
Or create the destination with the Cloudflare API using a token with Workers Observability Write:
curl -X POST "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/observability/destinations" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "content-type: application/json" \
--data '{"name": "future-agi", "enabled": true, "configuration": {"type": "logpush",
"logpushDataset": "opentelemetry-traces", "url": "https://<your-collector-host>/v1/traces",
"headers": {"Authorization": "Bearer <CF_DESTINATION_TOKEN>"}}}'
Saving the destination (create or edit) makes Cloudflare send one test span, Hello from Cloudflare 👋 (<destination name>), to the endpoint. The collector accepts it, so the check passes, and its filter/preflight processor drops it.
Then deploy:
npx wrangler secret put OPENAI_API_KEY
npx wrangler deploy
Cloudflare says events may take several minutes to reach a destination. Do not create a Logs destination for this collector: it has a traces pipeline only, and Future AGI ingests traces only.
6. Check it in Future AGI
Send a message to the deployed agent using your useAgentChat client. Open the project named in FI_PROJECT_NAME under Observe > Tracing. Open the hibernatableWebSocket trace and expand chat_interaction > chat_turn > invoke_agent to see model and tool spans. The connection upgrade is a separate GET trace. Inspect session.id on the agent spans for correlation, and open each message trace separately; this recipe does not provide trace-level session grouping (see What is captured).
What is captured
| Span | Type in Future AGI | What it carries |
|---|---|---|
invoke_agent <functionId> | agent | agent name/id, conversation id, requested model, provider, finish reason, tool count, cloudflare.agents.usage.total_tokens. Its gen_ai.usage.* copies are removed by the collector, so sessions do not count a turn’s tokens twice. |
chat <model> | llm | requested and response model, provider, response id, time to first chunk, input/output/cache/reasoning token counts. Cost appears when Future AGI knows the model’s price. |
execute_tool <tool> | tool | tool name, type and call id |
chat_turn, chat_interaction, persist_*, agent_* | chain | AIChatAgent lifecycle: request id, turn admission and generation, Durable Object id and instance name |
GET, hibernatableWebSocket, fetch, durable_object_subrequest | chain | Workers runtime: trigger, method, URL path, status code, outcome, CPU and wall time, data center, and the caller’s country, continent and timezone |
durable_object_storage_* | (dropped) | one span per storage call (with its SQL text), removed by the collector’s filter |
Hello from Cloudflare 👋 (<destination>) | (dropped) | the destination’s test span, removed by the collector’s filter |
Session identity is available for span-level correlation only. The collector copies gen_ai.conversation.id (else cloudflare.agents.session.id, the Durable Object id) to session.id on the same span. The AIChatAgent and wrapAISDK spans carry it; the runtime spans (GET, hibernatableWebSocket, fetch) do not. The transform does not propagate identity from child spans to runtime roots. Future AGI trace-level session grouping requires identity on the root span, so the shared child attribute does not group the whole conversation into a session.
Resource: Cloudflare sends one resource per span with service.name, faas.name and cloudflare.script_name (the Worker name), faas.version and cloudflare.script_version.id (the deployed version), cloudflare.entrypoint, cloud.provider, cloud.platform, cloudflare.colo, faas.invoked_region and the telemetry.sdk.* fields. The collector adds project_name.
Not captured: message text, system instructions, tool arguments and results, and raw error messages (off by default, see below); user ids (Cloudflare records user.id only from AI SDK v6 telemetry metadata); retrieval spans; cost from Cloudflare.
Privacy
Content needs two switches, and both are off by default. Set FI_CAPTURE_CONTENT=true in the shell before creating the Docker collector; if it already exists, remove and recreate it with the command above. Restarting a container does not update its environment.
| Switch | Where | Default | Effect |
|---|---|---|---|
storeMessages / storeTools | wrapAISDK(ai, {...}) in the Worker (STORE_MESSAGES / STORE_TOOLS in the example) | off | Cloudflare writes gen_ai.input.messages / gen_ai.output.messages on chat and gen_ai.tool.call.arguments / gen_ai.tool.call.result on execute_tool. Off: these never leave Cloudflare. |
FI_CAPTURE_CONTENT | collector environment | false | true lets those attributes, error text and exception details through and fills the Input and Output panes. false deletes them, replaces status messages with the error class, and keeps only exception.type on span events. |
In both modes the collector replaces e-mail-shaped values (for example an agent instance named after a user’s e-mail, which also appears in url.full, url.path, gen_ai.agent.id and cloudflare.agents.session.name) with <email>, keeps only the path of request URLs (no host, query string or fragment), drops url.query, and removes the caller’s city and region (geo.locality.name, geo.locality.region) that Cloudflare records on every incoming request span. Country, continent and timezone stay. The agent’s own wrapper never records raw error messages, request headers or provider options (Agents SDK observability guide); the error text of a failing tool still reaches the model’s input messages, so it is stored only when you opt in.
Do not put credentials, tokens or user input in runtimeContext keys you include in telemetry: they become span attributes.
How it behaves
- Tool throws: the turn continues (the model sees the tool error).
execute_toolis ERROR with the messageError(the class name), not the error text. - Model call fails:
chatandinvoke_agentare ERROR withAI_APICallError, the providerfetchunderchatis ERROR too (Cloudflare marks the HTTP 404 subrequest, no message), and the client receives a stream error. - Status: the Workers custom-span API has no status setter, so Cloudflare records failures as
error.typewith status UNSET. The collector turns that into span status ERROR. The trace row in the list keeps the root’s status (UNSET); open the trace to see the failing span. - Closed sockets: when the client closes the socket, Cloudflare ends the upgrade
GETwith outcomeresponseStreamDisconnectedand status ERROR. That is a normal close, so the collector clears the status and keepscloudflare.outcome. - Trace name and latency: the turn’s trace is named
hibernatableWebSocketand its root lasts as long as the turn’s invocation. The upgradeGETtrace ends when the socket closes. - Durations: spans that do no I/O, such as
execute_toolfor a tool that only computes, show 0 ms. Workers clocks only advance on I/O.
Limitations
- Keep the collector for project stamping, mapping, token deduplication and privacy. One collector serves one project.
- A conversation is several traces (one per message, plus the upgrade request), not a single trace.
- Spans still open when their invocation ends can be force-closed without attributes, leaving them as chain spans without model or token data. See cloudflare/agents#2539.
- This recipe uses the pinned
@cloudflare/ai-chatandagentsversions above and AI SDK v7. - Workers logs and
console.loglines are not exported through this traces pipeline.
Troubleshooting
- No project in Future AGI. The collector sets
project_name; check its logs (docker logs fi-cloudflare-collector). A 401 in the logs means wrongFI_API_KEY/FI_SECRET_KEY. Cloudflare can take minutes to deliver, and the destination status in the dashboard shows the last delivery or an error. - Destination shows an error. The endpoint must be the full
/v1/tracesURL on a public HTTPS address, and the header must be exactlyAuthorization: Bearer <token>with the token the collector has. Without it the collector answers 401. - A trace named
Hello from Cloudflare 👋 (...). That is the destination’s test span. Thefilter/preflightprocessor is missing from the pipeline. - Every
GETtrace is ERROR. Those are upgrade requests that ended because the client closed the socket. The collector’sresponseStreamDisconnectedstatements are missing or edited. - Only chain spans (
GET,chat_turn,persist_*), noinvoke_agent,chatorexecute_tool. The Worker calls the plain AI SDK. Call the functions returned bywrapAISDK(ai). invoke_agentstored as llm, or session token totals doubled. Thetransform/cloudflare_agentsprocessor is missing or edited; it pinsinvoke_agentto agent and removes its token copies.invoke_agentorchatstored as a chain span with no model or tokens. The span was still open when its invocation ended and the runtime closed it without attributes (see Limitations).- Hundreds of
durable_object_storage_*spans. Thefilter/storage_spansprocessor is not in the pipeline. - Input and Output panes are empty. That is the default. Set
STORE_MESSAGES/STORE_TOOLSin the Worker andFI_CAPTURE_CONTENT=trueon the collector. - Looking for an
onChatMessagespan. There is none. The turn ischat_turn.
Questions & Discussion