Cloudflare AI Gateway tracing with Future AGI
Send Cloudflare AI Gateway traces to Observe through an OpenTelemetry Collector you run. Prompts and responses stay out by default.
Cloudflare AI Gateway sits between your application and the model provider. Its OpenTelemetry export sends one span per request it handles to any OTLP endpoint, so you see those calls in Observe without adding code to your app. This page sends those spans to Observe through an OpenTelemetry Collector that you run.
Use this route for requests from any gateway client. To trace calls inside your own application instead, use the OpenAI instrumentor with the gateway URL as the base URL.
The collector adds project_name, maps gateway attributes, and filters prompts and error text before export. Keep the Future AGI keys on the collector; Cloudflare authenticates with a separate bearer token. You need a collector endpoint reachable from Cloudflare over HTTPS.
Set up
Write the collector config
Save this as otelcol.yaml. Everything it reads from the environment is listed in the next step.
extensions:
# The endpoint has to be reachable from Cloudflare's network, so it must not be open: anyone who finds it
# could write into your project.
bearertokenauth/gateway:
scheme: Bearer
token: ${env:GATEWAY_TOKEN}
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
auth:
authenticator: bearertokenauth/gateway
# Put TLS in front of this (a load balancer, a reverse proxy, or a platform that terminates TLS for
# you, such as Cloud Run), or set `tls:` here with cert_file and key_file.
processors:
resource/future_agi:
attributes:
- key: project_name
value: ${env:FI_PROJECT_NAME}
action: upsert
- key: project_type
value: observe
action: insert
transform/cloudflare:
error_mode: ignore
trace_statements:
- context: span
statements:
# Span type in Observe: a span with a model is an LLM span, anything else a chain.
- set(attributes["fi.span.kind"], "LLM") where attributes["gen_ai.request.model"] != nil
- set(attributes["fi.span.kind"], "CHAIN") where attributes["gen_ai.request.model"] == nil
# Cloudflare sends gen_ai.provider.name; its page names gen_ai.model.provider. Observe reads the first.
- set(attributes["gen_ai.provider.name"], attributes["gen_ai.model.provider"]) where attributes["gen_ai.model.provider"] != nil
# Cloudflare's own cost estimate (gen_ai.usage.cost). Observe reads gen_ai.cost.total.
- set(attributes["gen_ai.cost.total"], attributes["gen_ai.usage.cost"]) where attributes["gen_ai.usage.cost"] != nil
# Content ON: fill the Input and Output panes from Cloudflare's JSON strings (request and response bodies).
- 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.prompt_json"]) where attributes["gen_ai.prompt_json"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
- set(attributes["output.value"], attributes["gen_ai.completion_json"]) where attributes["gen_ai.completion_json"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
- set(attributes["input.mime_type"], "application/json") where attributes["input.value"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
- set(attributes["output.mime_type"], "application/json") where attributes["output.value"] != nil and "${env:FI_CAPTURE_CONTENT:-false}" == "true"
# Content OFF (the default): the prompt and completion in both of Cloudflare's spellings and in the
# other OpenTelemetry GenAI spellings, indexed keys included. Content ON keeps nothing twice.
- delete_key(attributes, "gen_ai.input.messages")
- delete_key(attributes, "gen_ai.output.messages")
- delete_key(attributes, "gen_ai.prompt_json")
- delete_key(attributes, "gen_ai.completion_json")
- delete_matching_keys(attributes, "^gen_ai\\.(prompt|completion|input\\.messages|output\\.messages)(\\..*)?$") where "${env:FI_CAPTURE_CONTENT:-false}" != "true"
# Error text can echo the request. Keep the fact of the error, not the words.
- set(status.message, "error") where status.message != "" and "${env:FI_CAPTURE_CONTENT:-false}" != "true"
- context: spanevent
statements:
- delete_key(attributes, "exception.message") where "${env:FI_CAPTURE_CONTENT:-false}" != "true"
- delete_key(attributes, "exception.stacktrace") 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 # plain bodies work on every Future AGI version; self-hosted before v1.56.0 rejects gzip
headers:
X-Api-Key: ${env:FI_API_KEY}
X-Secret-Key: ${env:FI_SECRET_KEY}
service:
extensions: [bearertokenauth/gateway]
telemetry:
metrics:
level: none # no self-metrics endpoint; the gateway sends traces only
logs:
level: warn
pipelines:
traces:
receivers: [otlp]
processors: [resource/future_agi, transform/cloudflare, batch]
exporters: [otlp_http/future_agi]FI_CAPTURE_CONTENT is read as the exact string true. Any other value, or none, keeps content out.
Run the collector
export GATEWAY_TOKEN=$(openssl rand -hex 24) # the secret Cloudflare will send
export FI_API_KEY=YOUR_API_KEY FI_SECRET_KEY=YOUR_SECRET_KEY
docker run -d --name cloudflare-gateway-otelcol --memory 512m \
-p 4318:4318 \
-v "$PWD/otelcol.yaml:/etc/otelcol/config.yaml:ro" \
-e GATEWAY_TOKEN -e FI_API_KEY -e FI_SECRET_KEY \
-e FI_PROJECT_NAME=my-cloudflare-gateway \
-e FI_TRACES_URL=https://api.futureagi.com/tracer/v1/traces \
otel/opentelemetry-collector-contrib:0.162.0 --config /etc/otelcol/config.yaml
| Variable | Value |
|---|---|
GATEWAY_TOKEN | A secret you invent. It protects the collector, not Future AGI. |
FI_API_KEY, FI_SECRET_KEY | Your Future AGI keys. They stay on your side. |
FI_PROJECT_NAME | The existing Observe project every gateway trace goes to. |
FI_TRACES_URL | The full traces URL. Future AGI cloud: https://api.futureagi.com/tracer/v1/traces. Self-hosted: http://<your Future AGI host>:4318/v1/traces. |
FI_CAPTURE_CONTENT | Leave unset. See Privacy. |
Make it reachable over HTTPS
Cloudflare’s exporter runs in Cloudflare’s network, so the collector needs a public HTTPS URL. The collector listens on plain HTTP on port 4318. Put TLS in front of it with a load balancer, a reverse proxy or a platform that terminates TLS for you, or add tls: with cert_file and key_file under the receiver’s http: block. Every request must carry the bearer token; without it the collector answers 401.
For example, deploy the collector on Google Cloud Run with the config file and three secrets in Secret Manager:
PROJECT=my-gcp-project REGION=us-central1
gcloud secrets create cfaig-otelcol-config --project $PROJECT --data-file=otelcol.yaml
printf %s "$GATEWAY_TOKEN" | gcloud secrets create cfaig-gateway-token --project $PROJECT --data-file=-
printf %s "$FI_API_KEY" | gcloud secrets create cfaig-fi-api-key --project $PROJECT --data-file=-
printf %s "$FI_SECRET_KEY" | gcloud secrets create cfaig-fi-secret-key --project $PROJECT --data-file=-
# let the service's runtime service account read the four secrets (roles/secretmanager.secretAccessor)
gcloud run deploy cloudflare-gateway-otelcol --project $PROJECT --region $REGION \
--image docker.io/otel/opentelemetry-collector-contrib:0.162.0 \
--args=--config=/etc/cfaig/config.yaml --port 4318 \
--allow-unauthenticated --no-cpu-throttling --max-instances 1 --memory 512Mi \
--set-secrets=/etc/cfaig/config.yaml=cfaig-otelcol-config:latest,GATEWAY_TOKEN=cfaig-gateway-token:latest,FI_API_KEY=cfaig-fi-api-key:latest,FI_SECRET_KEY=cfaig-fi-secret-key:latest \
--set-env-vars=FI_PROJECT_NAME=my-cloudflare-gateway,FI_TRACES_URL=https://api.futureagi.com/tracer/v1/traces--allow-unauthenticated lets Cloudflare reach the service; the collector’s bearer token is what protects it. --no-cpu-throttling keeps CPU on between requests, because the batch processor sends to Future AGI after Cloudflare’s request has been answered. Store the token without a trailing newline (printf %s, not echo): the collector compares the whole value. The exporter URL is the service URL plus /v1/traces.
Add the exporter in Cloudflare
In the Cloudflare dashboard open your AI Gateway, go to Settings and add an OTEL exporter.
| Field | Value |
|---|---|
| URL | https://gateway-otel.example.com/v1/traces, your collector’s public URL with the /v1/traces path |
| Headers | Authorization: Bearer YOUR_GATEWAY_TOKEN, with the value of GATEWAY_TOKEN. Do not put your Future AGI keys here. |
| Authorization | Leave empty. Cloudflare says passing the token in Headers is simpler. |
| Content type | json (the default) or protobuf. |
You can also configure the exporter with the Cloudflare API using a token with AI Gateway Write. The otel list replaces the gateway’s exporters, and an update has to repeat the gateway’s other settings:
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai-gateway/gateways/$GATEWAY_ID" \
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{
"cache_invalidate_on_update": true, "cache_ttl": 0, "collect_logs": true,
"rate_limiting_interval": 0, "rate_limiting_limit": 0,
"otel": [{
"url": "https://gateway-otel.example.com/v1/traces",
"headers": {"Authorization": "Bearer YOUR_GATEWAY_TOKEN"},
"content_type": "json"
}]
}'The API returns the header value in its responses, so do not log them.
Send a request through the gateway
Any client works. cf-aig-metadata is optional and carries values you choose. cf-aig-otel-trace-id and cf-aig-otel-parent-span-id are optional and link the gateway span to a trace your application already started:
curl https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openai/chat/completions \
--header 'Authorization: Bearer {api_token}' \
--header 'Content-Type: application/json' \
--header 'cf-aig-metadata: {"team": "engineering", "feature": "support-bot"}' \
--header 'cf-aig-otel-trace-id: 0123456789abcdef0123456789abcdef' \
--header 'cf-aig-otel-parent-span-id: 0123456789abcdef' \
--data '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Hello!"}]}'The trace and span ids must be lowercase hex, 32 and 16 characters. For a streamed request, add "stream_options": {"include_usage": true} so the span gets the provider’s token counts.
Open Observe, select your project, and open the trace. Allow a few seconds for the span to arrive.
What shows up in Observe
For OpenAI chat requests through the gateway:
| Signal | What Observe shows |
|---|---|
| Span | One span per request, named cf.aig.request, kind internal, instrumentation scope ai, resource service.name ai-gateway. Nothing else is on the resource. |
| Hierarchy | With cf-aig-otel-trace-id and cf-aig-otel-parent-span-id set, the span is stored under that trace id and sits under your span, if your app sent that span to the same project. Without the headers Cloudflare makes a new trace id. The cf-aig-trace-id response header is a different id, not the exported trace id. |
| Span type | llm. This collector config sets it explicitly from gen_ai.request.model. |
| Model | gen_ai.request.model. It can differ from the model alias in the request. |
| Operation | gen_ai.operation.name, chat for chat completions. Kept as an attribute. |
| Provider | Cloudflare documents gen_ai.model.provider. The collector maps it to gen_ai.provider.name, which Observe reads, and also accepts that name directly. |
| Tokens | gen_ai.usage.input_tokens and gen_ai.usage.output_tokens, the provider’s counts. For streaming, request usage with include_usage. |
| Cost | gen_ai.usage.cost, which Cloudflare calls an estimate. Observe does not read that name, so the collector copies it to gen_ai.cost.total. Compare it with your provider’s bill. |
| Custom metadata | Each cf-aig-metadata key is a span attribute of its own, for example team. It is not mapped to user, session or project. |
| Prompt and response | Filtered out by default. Cloudflare documents the JSON attributes gen_ai.prompt_json and gen_ai.completion_json. With content capture on, the collector maps these to the Input and Output panes; it also accepts gen_ai.input.messages and gen_ai.output.messages. |
| Failed requests | No span. Provider-rejected requests do not appear in Observe. Use Cloudflare’s own logs for those. The collector still replaces any span status message with error unless you opt in. |
| Cache hits | No span. Requests served from the gateway cache do not appear in Observe. |
| Tool calls | Not exported as spans. A tool call inside the response is visible only with the opt-in. |
Privacy
Cloudflare sends request and response bodies to the collector even when gateway log collection (collect_logs) is off. The collector filters them before export to Observe by default.
| Switch | Where | Default |
|---|---|---|
FI_CAPTURE_CONTENT | The collector’s environment | false |
With the collector default, these never leave your collector, whatever Cloudflare sends:
| Removed | Why |
|---|---|
gen_ai.input.messages, gen_ai.output.messages | The request and response bodies |
gen_ai.prompt_json, gen_ai.completion_json | The names on Cloudflare’s page for the same content |
gen_ai.prompt, gen_ai.completion, with indexed spellings such as gen_ai.prompt.0.content | The other names the OpenTelemetry GenAI conventions use |
The span status message, replaced by error | Error text can echo the request |
exception.message and exception.stacktrace on span events | Stack traces carry paths and text |
Kept: model, provider, operation name, token counts, cost, timing, status, and every cf-aig-metadata key. Metadata is a value you choose and Cloudflare exports it, so do not put a secret, an e-mail address or free text in that header.
To send content on purpose with Docker, remove and recreate the collector container with -e FI_CAPTURE_CONTENT=true added to docker run; restarting an existing container does not change its environment. On Cloud Run, update the service environment with FI_CAPTURE_CONTENT=true. The collector then copies the request and response bodies into the Input and Output panes, marks both as JSON, removes the originals so nothing is stored twice, and leaves error text and exception details alone.
Limitations
- Failed provider requests and cache hits produce no span, so Observe shows only the requests the provider answered and the gateway did not serve from cache.
- Cloudflare spans carry no tool, retrieval, user or session fields. A user or session id needs
cf-aig-metadata, and this page does not map any key to them. - A failed export is not retried for a 401, and nothing tells your application. Watch the collector’s log.
- Cloudflare’s cost is an estimate, and streamed requests without
include_usagecarry estimated input tokens and 0 output tokens. - One collector sends to one project. Run one collector per project, each with its own endpoint and token.
- The collector keeps prompts out of Observe. It cannot stop Cloudflare from sending them to your collector.
Troubleshooting
| What you see | What it means |
|---|---|
| Nothing appears in Observe | Check the collector log for Exporting failed. Dropping data. A 401 there means the Future AGI keys or FI_TRACES_URL are wrong. Cloudflare never sees that error. If the collector log is empty, check that the exporter URL ends in /v1/traces and that the request succeeded: failed requests and cache hits export nothing. |
| The collector answers 401 | The token in the exporter’s Headers does not match GATEWAY_TOKEN. A token stored with a trailing newline (for example a secret written with echo) never matches. |
HTTP 400 no project_name | You pointed the exporter straight at Future AGI. Use the collector. |
Type unknown or an unexpected cost | Check the kind/operation and cost attributes. This config assigns the kind explicitly and copies Cloudflare’s estimate into gen_ai.cost.total; Future AGI 1.56.1 already recognizes the chat operation. |
| Odd token counts on streamed requests | Add "stream_options": {"include_usage": true} to the request. |
| No prompts or responses | That is the default. See Privacy. |
| The gateway span is not under my span | The trace id or parent span id was not valid lowercase hex, or your app never sent the parent span to the same project. Observe lists a trace by its root, so a trace whose root points at a missing parent is not listed; open it by trace id. |
The gateway answers 403 error code: 1010 | Cloudflare refused the client before the gateway saw the request. Check the client User-Agent and Cloudflare access rules. |
| A metadata key is missing | Cloudflare’s page says keys that start with gen_ai. are dropped. |
Related guides
- OpenAI instrumentor, for tracing calls your code makes through the gateway
- Observe
Questions & Discussion