Cloudflare Workers OpenTelemetry: Ahorra 90% con OTLP

Por qué la observabilidad es difícil en el edge
En los entornos de servidores tradicionales, es posible escribir registros en archivos, adjuntar agentes de APM como sidecars y mantener el contexto durante el prolongado ciclo de vida de una solicitud. Sin embargo, Cloudflare Workers representa un mundo completamente diferente.
Workers se ejecuta en contextos V8 aislados con una vida útil de milisegundos. No existe un sistema de archivos ni procesos en segundo plano persistentes. Si se depura con console.log y ocurre un error de causa desconocida en producción, no había otra alternativa clara que observar los registros en tiempo real mediante wrangler tail.
Sin embargo, a finales de 2024, Cloudflare comenzó a incorporar soporte nativo para OpenTelemetry en Workers. Ahora, sin modificar una sola línea de código y añadiendo únicamente unas pocas líneas de configuración en wrangler.jsonc, se recopilan automáticamente las trazas distribuidas de todas las solicitudes fetch, consultas D1, lecturas/escrituras en KV y llamadas a Durable Objects.
En este artículo, se aborda cómo configurar el trazado automático nativo de OTel, añadir spans personalizados, exportar mediante OTLP a Axiom y Honeycomb, y reducir los costos mensuales de observabilidad en más del 90% mediante estrategias de muestreo.
Conceptos clave de OpenTelemetry
Antes de proceder con la configuración detallada, es fundamental repasar tres conceptos clave de OTel.
Trace
└── Span: Unidad de trabajo
├── Span: Consulta D1 (child span)
├── Span: Lectura KV (child span)
└── Span: Llamada API externa (child span)
Cada Span incluye:
- Hora de inicio/fin
- Duración (duration)
- Atributos (attributes): metadatos clave-valor
- Eventos (events): registro de momentos
- Estado (status): OK / Error
- Trace: El recorrido completo de una solicitud a través del sistema.
- Span: Una unidad de trabajo individual dentro de un trace (consultas a bases de datos, llamadas a API, etc.).
- OTLP: OpenTelemetry Protocol — El formato estándar para la transmisión de telemetría.
En Workers, un trace visualiza todo el flujo —solicitud a Workers → consulta a D1 → llamada a API externa— en un único diagrama en cascada (waterfall).
Estructura de costos de la observabilidad
Antes de la configuración, se debe comprender la estructura de costos. Si se configura de manera incorrecta, el costo del trazado puede superar el costo de ejecución de Workers.
| Componente | Límite gratuito | De pago |
|---|---|---|
| Trazos del panel de Cloudflare Workers | 20 millones de eventos/mes | Excedente $0.60/millón |
| Ingesta de datos en Axiom | 500 GB/mes | Excedente $1/GB |
| Trazos en Grafana Cloud | 50 GB/mes | Excedente $0.55/GB |
| Honeycomb | 20 millones de eventos/mes | Excedente aprox. $1/millón |
Problema principal: Con tráfico elevado, Workers puede generar cientos de millones de solicitudes diarias. Si se recopilan trazas por cada solicitud, el volumen mensual de eventos explota.
Solución: Head Sampling. Al muestrear solo el 5% del total de las solicitudes, se reducen los eventos en un 95% obteniendo al mismo tiempo datos estadísticamente significativos. Al configurar la captura de errores al 100%, se mantiene la capacidad de depuración reduciendo los costos en un 90%+.
Paso 1: Activar el trazado automático nativo
Sin modificar el código, se edita únicamente wrangler.jsonc.
// wrangler.jsonc
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"traces": {
"enabled": true,
// Head Sampling: Muestrear solo el 5% del total de solicitudes
"head_sampling_rate": 0.05,
// Guardar también en el panel de Cloudflare (retención de 7 días)
"persist": true
},
"logs": {
"enabled": true,
"head_sampling_rate": 0.1
}
},
"d1_databases": [
{
"binding": "DB",
"database_name": "my-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
]
}
Con esta única configuración, se traza automáticamente lo siguiente:
- Todos los puntos de entrada del controlador
fetch() - Consultas a la base de datos D1 (incluyendo las sentencias SQL)
- Operaciones de lectura y escritura en KV
- Operaciones en el almacenamiento de objetos R2
- Llamadas a Durable Objects
- Solicitudes HTTP
fetch()salientes - Consumidores y productores de Queue
Al ejecutar wrangler deploy, se pueden visualizar inmediatamente los trazos en el panel de Cloudflare → Workers → Observability.
Paso 2: Trazar la lógica de la aplicación con spans personalizados
Aunque el trazado automático nativo cubre las operaciones de la plataforma, se requieren spans personalizados para el interior de la lógica de negocio.
Novedad de 2025: API de trazado nativo cloudflare:workers
// src/index.ts
import { tracing } from "cloudflare:workers";
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { users, posts } from "./db/schema";
type Env = {
DB: D1Database;
CACHE: KVNamespace;
};
const app = new Hono<{ Bindings: Env }>();
app.get("/api/posts/:slug", async (c) => {
const slug = c.req.param("slug");
// Span personalizado: agrupa toda la lógica de consulta del post en un solo span
return tracing.enterSpan("get-post-by-slug", async (span) => {
// Añadir atributos de contexto de negocio al span
span.setAttribute("post.slug", slug);
span.setAttribute("app.feature", "blog");
const db = drizzle(c.env.DB);
// Consulta en caché KV (genera un child span automáticamente)
const cacheKey = `post:${slug}`;
const cached = await c.env.CACHE.get(cacheKey, "json");
if (cached) {
span.setAttribute("cache.hit", true);
// Evento de span: registra un momento específico
span.addEvent("cache-hit", { key: cacheKey });
return c.json(cached);
}
span.setAttribute("cache.hit", false);
// Consulta a D1 (genera un child span automáticamente — incluye sentencia SQL)
const post = await db
.select()
.from(posts)
.where(eq(posts.slug, slug))
.get();
if (!post) {
// Registrar estado de error
span.setStatus({ code: "ERROR", message: "Post not found" });
return c.json({ error: "Not found" }, 404);
}
span.setAttribute("post.id", post.id);
span.setAttribute("post.published", post.published);
// Almacenamiento en caché KV (fire-and-forget)
c.executionCtx.waitUntil(
c.env.CACHE.put(cacheKey, JSON.stringify(post), {
expirationTtl: 300,
})
);
return c.json(post);
});
});
export default app;
Spans anidados: trazado de flujos de trabajo complejos
// Lógica de negocio compleja con múltiples pasos
async function processOrder(
env: Env,
ctx: ExecutionContext,
orderId: string
) {
return tracing.enterSpan("process-order", async (orderSpan) => {
orderSpan.setAttribute("order.id", orderId);
// Paso 1: Verificar inventario
const inventory = await tracing.enterSpan(
"check-inventory",
async (span) => {
span.setAttribute("order.id", orderId);
const db = drizzle(env.DB);
return db.select().from(inventoryTable)
.where(eq(inventoryTable.orderId, orderId))
.get();
}
);
if (!inventory || inventory.stock < 1) {
orderSpan.setStatus({
code: "ERROR",
message: "Out of stock",
});
throw new Error("Out of stock");
}
// Paso 2: Procesar pago (API externa)
const payment = await tracing.enterSpan(
"process-payment",
async (span) => {
span.setAttribute("payment.provider", "stripe");
// El fetch saliente genera un span automáticamente
const res = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
// ...
});
span.setAttribute("payment.status", res.status);
return res.json();
}
);
orderSpan.setAttribute("payment.id", payment.id);
orderSpan.addEvent("order-completed", {
orderId,
paymentId: payment.id,
});
return payment;
});
}
tracing.enterSpan() finaliza automáticamente el span cuando el callback retorna o se resuelve la promesa. No es necesario llamar a span.end() manualmente.
Paso 3: Integración con paneles externos mediante OTLP
Para superar el límite de retención de 7 días del panel de Cloudflare, se debe exportar a un endpoint OTLP externo.
Integración con Axiom (500 GB/mes gratis)
// Añadir destinations a wrangler.jsonc
{
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 0.05,
"persist": false, // Desactivar almacenamiento en el panel de Cloudflare
"destinations": ["axiom-traces"]
},
"logs": {
"enabled": true,
"head_sampling_rate": 0.1,
"destinations": ["axiom-logs"]
}
}
}
Añadir Destination desde el panel de Cloudflare:
- Workers → Observability → Destinations → Add Destination
- Name:
axiom-traces - Type:
OTLP - Endpoint:
https://api.axiom.co/v1/traces - Headers:
Authorization: Bearer <AXIOM_API_TOKEN>,X-Axiom-Dataset: my-workers
Integración con Honeycomb
Endpoint: https://api.honeycomb.io/v1/traces
Headers:
x-honeycomb-team: <HONEYCOMB_API_KEY>
x-honeycomb-dataset: cloudflare-workers
Integración con Grafana Cloud
Endpoint: https://otlp-gateway-prod-<region>.grafana.net/otlp/v1/traces
Headers:
Authorization: Basic <base64(instanceId:apiToken)>
@microlabs/otel-cf-workers: Personalización a nivel de código
Si el OTel nativo no es suficiente y se requiere un control más preciso:
// src/index.ts — Uso de @microlabs/otel-cf-workers
import { instrument, ResolveConfigFn } from "@microlabs/otel-cf-workers";
import { trace, context } from "@opentelemetry/api";
type Env = {
DB: D1Database;
OTEL_EXPORTER_OTLP_ENDPOINT: string;
OTEL_EXPORTER_OTLP_HEADERS: string;
};
const handler = {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const tracer = trace.getTracer("my-worker");
return tracer.startActiveSpan("handle-request", async (span) => {
try {
span.setAttribute("http.method", request.method);
span.setAttribute("http.url", request.url);
const url = new URL(request.url);
const response = await routeRequest(request, env, ctx);
span.setAttribute("http.status_code", response.status);
return response;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
},
};
// Leer configuración OTLP desde variables de entorno
const config: ResolveConfigFn = (env: Env, trigger) => ({
exporter: {
url: env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: Object.fromEntries(
new URLSearchParams(env.OTEL_EXPORTER_OTLP_HEADERS)
),
},
service: {
name: "my-cloudflare-worker",
version: "1.0.0",
},
});
export default instrument(handler, config);
Paso 4: Estrategia de muestreo — La clave para reducir costos un 90%
El muestreo no consiste simplemente en reducir la tasa. La clave es capturar los errores al 100% mientras se muestrean únicamente las solicitudes normales.
Head Sampling (Muestreo en la cabecera)
Decide si trazar en el momento de iniciar la solicitud. Es la opción más simple y minimiza la sobrecarga de rendimiento.
{
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 0.05 // Muestreo del 5%
}
}
}
Desventaja: Las solicitudes con errores también pueden descartarse con una probabilidad del 95%.
Tail Sampling (Muestreo al final): Preservación del 100% de errores
Utilizando @microlabs/otel-cf-workers y un muestreador personalizado, es posible implementar Tail Sampling:
// src/sampling.ts — 100% para errores, 5% para solicitudes normales
import { Sampler, SamplingResult, SamplingDecision } from "@opentelemetry/sdk-trace-base";
export class ErrorAlwaysSampler implements Sampler {
private baseRate: number;
constructor(baseRate = 0.05) {
this.baseRate = baseRate;
}
shouldSample(
context: any,
traceId: string,
spanName: string,
spanKind: any,
attributes: any,
links: any
): SamplingResult {
// Muestrear siempre estados de error HTTP
const statusCode = attributes["http.status_code"];
if (statusCode && statusCode >= 400) {
return { decision: SamplingDecision.RECORD_AND_SAMPLED };
}
// Muestrear siempre si existe el atributo de error
if (attributes["error"] === true) {
return { decision: SamplingDecision.RECORD_AND_SAMPLED };
}
// Muestrear el resto con la probabilidad baseRate
return {
decision:
Math.random() < this.baseRate
? SamplingDecision.RECORD_AND_SAMPLED
: SamplingDecision.NOT_RECORD,
};
}
toString() {
return `ErrorAlwaysSampler(${this.baseRate})`;
}
}
// Aplicar muestreador personalizado en la configuración de instrument
const config: ResolveConfigFn = (env: Env) => ({
exporter: { url: env.OTEL_ENDPOINT },
service: { name: "my-worker" },
sampler: new ErrorAlwaysSampler(0.05),
});
Simulación de costos según la tasa de muestreo
Asumiendo un Worker que procesa 1.000 millones de solicitudes al mes:
| Tasa de muestreo | Eventos mensuales | Costo en Axiom | Costo en Honeycomb |
|---|---|---|---|
| 100% (sin muestreo) | 1.000 millones | ~$500/mes | ~$1.000/mes |
| 10% | 100 millones | ~$50/mes | ~$100/mes |
| 5% | 50 millones | ~$25/mes | ~$50/mes |
| 1% | 10 millones | Gratis | ~$10/mes |
Con solo un 5% de muestreo, los costos se reducen en un 95% mientras se obtienen miles de muestras estadísticamente representativas por segundo.
Paso 5: Uso de trazas — Escenarios reales de producción
Identificación de consultas D1 lentas
En Axiom, se buscan las consultas D1 que toman más de 100 ms utilizando la siguiente consulta:
// Consulta Axiom APL
['cloudflare-workers']
| where ['span.kind'] == "client"
| where ['db.system'] == "cloudflare.d1"
| where duration > 100ms
| summarize count(), avg(duration) by ['db.statement']
| order by avg_duration desc
| limit 20
Análisis de patrones de errores
// Filtrar solo las trazas con errores 5xx
['cloudflare-workers']
| where ['http.status_code'] >= 500
| where ['span.is_root'] == true
| project _time, ['http.url'], ['http.method'], duration, ['error.message']
| order by _time desc
Seguimiento de latencia P99
// Latencia P50/P95/P99 para un endpoint específico
['cloudflare-workers']
| where ['http.route'] == "/api/posts/:slug"
| summarize
p50 = percentile(duration, 50),
p95 = percentile(duration, 95),
p99 = percentile(duration, 99)
by bin(_time, 5m)
| order by _time desc
Paso 6: Trazado de Durable Objects
Dado que Durable Objects tiene un ciclo de vida más complejo que Workers, requiere un trazado independiente.
Trazado automático nativo (Recomendado)
Con solo configurar observability.traces.enabled = true en wrangler.jsonc, las llamadas a DO se vinculan automáticamente como child spans del trace padre en Workers. No se requiere @microlabs/otel-cf-workers.
Trace de solicitud en Workers
└── fetch handler (root span)
├── Consulta D1 (span automático)
└── Llamada a Durable Object (child span automático)
├── DO fetch handler
└── Consulta D1 interna de DO
@microlabs/otel-cf-workers: Instrumentación manual de DO
// src/rate-limiter.ts — Durable Object con OTel
import { instrumentDO } from "@microlabs/otel-cf-workers";
import { trace } from "@opentelemetry/api";
class RateLimiterBase {
private state: DurableObjectState;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const tracer = trace.getTracer("rate-limiter-do");
return tracer.startActiveSpan("rate-limit-check", async (span) => {
const key = new URL(request.url).searchParams.get("key") ?? "global";
span.setAttribute("rate_limit.key", key);
const count = (await this.state.storage.get<number>(key)) ?? 0;
const limit = 100;
if (count >= limit) {
span.setAttribute("rate_limit.exceeded", true);
span.setStatus({ code: "ERROR", message: "Rate limit exceeded" });
span.end();
return new Response("Rate limited", { status: 429 });
}
await this.state.storage.put(key, count + 1);
span.setAttribute("rate_limit.count", count + 1);
span.end();
return new Response("OK");
});
}
}
// Envolver la clase Durable Object con OTel
export const RateLimiter = instrumentDO(RateLimiterBase, config);
Paso 7: Integración con CI/CD y alertas
Verificación automática tras el despliegue en GitHub Actions
# .github/workflows/deploy.yml
- name: Deploy Worker
run: npx wrangler deploy
- name: Verify Observability (Verificar recolección de trazas tras despliegue)
run: |
sleep 30 # Esperar recolección de trazas
# Verificar cantidad de errores de los últimos 5 minutos mediante API de Axiom
ERRORS=$(curl -s "https://api.axiom.co/v1/datasets/my-workers/query" \
-H "Authorization: Bearer $AXIOM_TOKEN" \
-d '{"apl":"[\"my-workers\"] | where status >= 500 | where _time > ago(5m) | count"}' \
| jq '.matches[0].data._count // 0')
if [ "$ERRORS" -gt "10" ]; then
echo "❌ Aumento drástico de errores tras despliegue: $ERRORS errores en 5min"
exit 1
fi
echo "✅ Cantidad de errores normal tras despliegue: $ERRORS"
Configuración de alertas en Axiom
Notificación por Slack mediante Axiom Monitor cuando la latencia P99 supera el umbral:
{
"name": "Workers P99 Latency Alert",
"query": {
"apl": "['my-workers'] | where ['span.is_root'] == true | summarize p99 = percentile(duration, 99) by bin(_time, 1m) | where p99 > 2000ms"
},
"frequencyMinutes": 5,
"durationMinutes": 5,
"notifiers": ["slack-webhook"]
}
Guía de selección de proveedores
| Situación | Proveedor recomendado | Razón |
|---|---|---|
| Startup inicial, ahorro de presupuesto | Axiom | 500 GB/mes gratis, integración de logs + trazas |
| Uso del stack de Grafana | Grafana Cloud | Integración con paneles existentes, 50 GB gratis |
| Necesidad de análisis/consultas complejas | Honeycomb | Detección de anomalías BubbleUp, consultas avanzadas |
| Requisitos on-premise | SigNoz (Autoalojado) | Código abierto, despliegue en Kubernetes |
| Solo necesidad de logs simples | Panel de Cloudflare | Gratis, sin configuración adicional |
Lista de verificación para la optimización de costos
Lista de optimizaciones para aplicar en entornos reales de producción:
Estrategia de muestreo:
-
head_sampling_rate: 0.05(muestreo del 5% para solicitudes normales) - Muestreo del 100% en caso de error (muestreador personalizado)
- Muestreo del 100% para solicitudes lentas (>1s)
Reducción de datos:
- Eliminación de atributos innecesarios (información personal, cargas útiles de gran tamaño)
- Limitación de la cardinalidad de nombres de spans (normalización de parámetros URL)
- Prevención del almacenamiento duplicado en el panel de Cloudflare mediante
persist: false
Monitoreo de costos:
- Configuración de alertas de recuento mensual de eventos en el panel del proveedor
- Verificación semanal del recuento de solicitudes en la analítica de Cloudflare Workers
- Ajuste dinámico de la tasa de muestreo según el incremento del tráfico
Ruta de migración: De wrangler tail a OTel
Si anteriormente se dependía únicamente de wrangler tail y console.log, la transición se debe realizar por etapas:
Semana 1: Añadir observability.traces.enabled: true a wrangler.jsonc y verificar trazas en el panel de Cloudflare
Semana 2: Añadir Axiom Destination y aprovechar 30 días de retención gratuita
Semana 3: Instrumentar la lógica de negocio principal con spans personalizados
Semana 4: Ajustar la tasa de muestreo y configurar alertas
No es necesario eliminar los console.log existentes. Los registros de Workers también se pueden exportar configurando observability.logs.enabled: true.
Conclusión
El soporte para OpenTelemetry en Cloudflare Workers ha madurado rápidamente desde finales de 2024. Gracias al trazado automático nativo, se instrumentan todas las operaciones de la plataforma sin modificar el código, y es posible añadir lógica de negocio con precisión utilizando la API tracing.enterSpan() de cloudflare:workers.
El costo se puede controlar por completo mediante el muestreo:
- Head sampling del 5%: Reducción del 95% de costos manteniendo visibilidad estadística
- Captura del 100% de errores: Sin pérdida de capacidad de depuración
- 500 GB gratis en Axiom: Gratuito hasta producciones de mediana escala
El tiempo necesario para pasar de una función edge de caja negra a un sistema distribuido completamente observable es de un minuto, el cual toma añadir 5 líneas a wrangler.jsonc.
Artículo relacionado: En el artículo Cloudflare Workflows y Agentes de IA de Ejecución Durable se pueden consultar los patrones de gestión de estado de Durable Objects.