effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube
Español

Cloudflare Workers OpenTelemetry: Ahorra 90% con OTLP

Arquitectura de trazado distribuido OpenTelemetry en Cloudflare Workers

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

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:

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:

  1. Workers → Observability → Destinations → Add Destination
  2. Name: axiom-traces
  3. Type: OTLP
  4. Endpoint: https://api.axiom.co/v1/traces
  5. 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:

Reducción de datos:

Monitoreo de costos:

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:

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.