effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung
Deutsch

Cloudflare Workers OpenTelemetry: OTLP-Kosten 90% senken

Cloudflare Workers OpenTelemetry Observability Distributed-Tracing-Architektur

Warum Observability am Edge so schwierig ist

In traditionellen Serverumgebungen können Sie Logs in Dateien schreiben, APM-Agenten als Sidecar-Prozesse ausführen und den Kontext über die lange Lebensdauer einer Anfrage hinweg aufrechterhalten. Cloudflare Workers ist jedoch eine völlig andere Welt.

Workers werden in isolierten V8-Kontexten ausgeführt, deren Lebensdauer in Millisekunden gemessen wird. Es gibt kein Dateisystem und keine persistenten Hintergrundprozesse. Wenn Sie mit console.log debuggen und in der Produktion auf einen unerklärlichen Fehler stoßen, gab es bisher außer der Echtzeit-Loganzeige über wrangler tail kaum effektive Möglichkeiten.

Seit Ende 2024 integriert Cloudflare jedoch nativ OpenTelemetry-Unterstützung direkt in Workers. Ohne eine einzige Zeile Code zu ändern, müssen Sie lediglich einige Konfigurationszeilen in der wrangler.jsonc hinzufügen, um verteilte Traces aller Fetch-Anfragen, D1-Abfragen, KV-Lese-/Schreibzugriffe und Durable Object-Aufrufe automatisch zu erfassen.

In diesem Artikel erfahren Sie, wie Sie das native automatische OTel-Tracing konfigurieren, benutzerdefinierte Spans hinzufügen, Daten per OTLP an Axiom und Honeycomb exportieren und Ihre monatlichen Observability-Kosten durch eine effektive Sampling-Strategie um mehr als 90 % senken.

Grundlagen zu den Kernkonzepten von OpenTelemetry

Vor der Konfiguration sollten die drei Kernkonzepte von OTel kurz beleuchtet werden.

Trace
 └── Span: Eine einzelne Arbeitseinheit
      ├── Span: D1-Abfrage (Child Span)
      ├── Span: KV-Lesezugriff (Child Span)
      └── Span: Externer API-Aufruf (Child Span)

Jeder Span enthält Folgendes:
  - Start-/Endzeitpunkt
  - Dauer (duration)
  - Attribute: Key-Value-Metadaten
  - Events: Zeitpunktspezifische Aufzeichnungen
  - Status: OK / Error

Bei Workers visualisiert ein Trace den gesamten Ablauf von der Worker-Anfrage über die D1-Abfrage bis hin zum externen API-Aufruf in einem einzigen Wasserfalldiagramm.

Verstehen der Observability-Kostenstruktur

Vor der Konfiguration sollten Sie zunächst die Kostenstruktur verstehen. Bei falscher Konfiguration können die Kosten für Traces die eigentlichen Ausführungskosten von Workers übersteigen.

Komponente Kostenfreies Kontingent Kostenpflichtig
Cloudflare Workers Dashboard Traces 20 Mio. Events/Monat $0.60 pro weitere Mio.
Axiom-Datenerfassung 500 GB/Monat $1 pro weiteres GB
Grafana Cloud Traces 50 GB/Monat $0.55 pro weiteres GB
Honeycomb 20 Mio. Events/Monat ca. $1 pro weitere Mio.

Hauptproblem: Bei hohem Traffic-Aufkommen können Cloudflare Workers täglich hunderte Millionen Anfragen verarbeiten. Wenn Sie für jede Anfrage einen Trace erfassen, explodiert die Anzahl der monatlichen Events.

Lösung: Head-Sampling. Wenn Sie nur 5 % aller Anfragen samplen, reduzieren Sie das Event-Volumen um 95 % und erhalten dennoch statistisch repräsentative Daten. Konfigurieren Sie das System so, dass Fehler zu 100 % erfasst werden – so bleibt die Debugging-Fähigkeit vollständig erhalten, während die Kosten um mehr als 90 % sinken.

Schritt 1: Natives automatisches Tracing aktivieren

Sie müssen keinen Anwendungscode ändern, sondern lediglich die wrangler.jsonc anpassen.

// wrangler.jsonc
{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "observability": {
    "traces": {
      "enabled": true,
      // Head Sampling: Nur 5 % aller Anfragen samplen
      "head_sampling_rate": 0.05,
      // Auch im Cloudflare Dashboard speichern (7 Tage Aufbewahrung)
      "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"
    }
  ]
}

Mit dieser Konfiguration allein werden folgende Vorgänge automatisch nachverfolgt:

Sobald Sie wrangler deploy ausführen, können Sie die Traces sofort im Cloudflare Dashboard unter WorkersObservability einsehen.

Schritt 2: Anwendungslogik mit benutzerdefinierten Spans nachverfolgen

Während das native automatische Tracing Plattformoperationen abdeckt, erfordert die Nachverfolgung der internen Logik Ihrer Anwendung benutzerdefinierte Spans.

Neu seit 2025: Die native Tracing-API von 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");

  // Benutzerspezifischer Span: Fasst die gesamte Logik zum Abrufen des Beitrags in einem Span zusammen
  return tracing.enterSpan("get-post-by-slug", async (span) => {
    // Business-Kontext-Attribute zum Span hinzufügen
    span.setAttribute("post.slug", slug);
    span.setAttribute("app.feature", "blog");

    const db = drizzle(c.env.DB);

    // KV-Cache-Abfrage (erzeugt automatisch einen Child Span)
    const cacheKey = `post:${slug}`;
    const cached = await c.env.CACHE.get(cacheKey, "json");

    if (cached) {
      span.setAttribute("cache.hit", true);
      // Span-Event: Bestimmten Zeitpunkt aufzeichnen
      span.addEvent("cache-hit", { key: cacheKey });
      return c.json(cached);
    }

    span.setAttribute("cache.hit", false);

    // D1-Abfrage (erzeugt automatisch einen Child Span – inklusive SQL-Statement)
    const post = await db
      .select()
      .from(posts)
      .where(eq(posts.slug, slug))
      .get();

    if (!post) {
      // Fehlerstatus aufzeichnen
      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);

    // KV-Caching (Fire-and-Forget)
    c.executionCtx.waitUntil(
      c.env.CACHE.put(cacheKey, JSON.stringify(post), {
        expirationTtl: 300,
      })
    );

    return c.json(post);
  });
});

export default app;

Verschachtelte Spans: Tracing komplexer Workflows

// Komplexe Anwendungslogik mit mehreren Schritten
async function processOrder(
  env: Env,
  ctx: ExecutionContext,
  orderId: string
) {
  return tracing.enterSpan("process-order", async (orderSpan) => {
    orderSpan.setAttribute("order.id", orderId);

    // Schritt 1: Lagerbestand prüfen
    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");
    }

    // Schritt 2: Zahlungsabwicklung (externe API)
    const payment = await tracing.enterSpan(
      "process-payment",
      async (span) => {
        span.setAttribute("payment.provider", "stripe");
        // Outbound-fetch erzeugt automatisch einen Span
        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() schließt den Span automatisch ab, sobald die Callback-Funktion einen Wert zurückgibt oder das Promise aufgelöst wird. Ein manueller Aufruf von span.end() ist nicht erforderlich.

Schritt 3: OTLP-Integration mit externen Dashboards

Um die Aufbewahrungsfrist von 7 Tagen des Cloudflare Dashboards zu überschreiten, müssen Sie die Daten an einen externen OTLP-Endpunkt exportieren.

Axiom-Integration (kostenfrei bis 500 GB/Monat)

// Destinations in wrangler.jsonc hinzufügen
{
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.05,
      "persist": false,  // Speicherung im Cloudflare Dashboard deaktivieren
      "destinations": ["axiom-traces"]
    },
    "logs": {
      "enabled": true,
      "head_sampling_rate": 0.1,
      "destinations": ["axiom-logs"]
    }
  }
}

Hinzufügen einer Destination im Cloudflare Dashboard:

  1. WorkersObservabilityDestinationsAdd 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

Honeycomb-Integration

Endpoint: https://api.honeycomb.io/v1/traces
Headers:
  x-honeycomb-team: <HONEYCOMB_API_KEY>
  x-honeycomb-dataset: cloudflare-workers

Grafana Cloud-Integration

Endpoint: https://otlp-gateway-prod-<region>.grafana.net/otlp/v1/traces
Headers:
  Authorization: Basic <base64(instanceId:apiToken)>

@microlabs/otel-cf-workers: Anpassung auf Code-Ebene

Wenn Ihnen das native OTel nicht ausreicht und Sie eine feinere Steuerung benötigen:

// src/index.ts – Einsatz von @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();
      }
    });
  },
};

// OTLP-Konfiguration aus Umgebungsvariablen lesen
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);

Schritt 4: Sampling-Strategien – Der Schlüssel zu 90 % Kostenersparnis

Sampling bedeutet nicht einfach nur das pauschale Senken der Rate. Die Kernstrategie besteht darin, reguläre Anfragen zu samplen, während Fehler zu 100 % erfasst werden.

Head-Sampling

Die Entscheidung über das Tracing wird zu Beginn der Anfrage getroffen. Dies ist die einfachste Methode mit minimalem Performance-Overhead.

{
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.05  // 5% 샘플링
    }
  }
}

Nachteil: Anfragen, bei denen Fehler auftreten, können mit einer Wahrscheinlichkeit von 95 % verworfen werden.

Tail-Sampling: 100 % Erhalt von Fehlern

Mit @microlabs/otel-cf-workers und einem benutzerdefinierten Sampler lässt sich ein Tail-Sampling realisieren:

// src/sampling.ts – Fehler zu 100 %, reguläre Anfragen zu 5 % samplen
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 {
    // HTTP-Fehlerstatus immer samplen
    const statusCode = attributes["http.status_code"];
    if (statusCode && statusCode >= 400) {
      return { decision: SamplingDecision.RECORD_AND_SAMPLED };
    }

    // Wenn Fehlerattribute vorhanden sind, immer samplen
    if (attributes["error"] === true) {
      return { decision: SamplingDecision.RECORD_AND_SAMPLED };
    }

    // Restliche Anfragen mit der Wahrscheinlichkeit baseRate samplen
    return {
      decision:
        Math.random() < this.baseRate
          ? SamplingDecision.RECORD_AND_SAMPLED
          : SamplingDecision.NOT_RECORD,
    };
  }

  toString() {
    return `ErrorAlwaysSampler(${this.baseRate})`;
  }
}
// Benutzerdefinierten Sampler in der Instrument-Konfiguration anwenden
const config: ResolveConfigFn = (env: Env) => ({
  exporter: { url: env.OTEL_ENDPOINT },
  service: { name: "my-worker" },
  sampler: new ErrorAlwaysSampler(0.05),
});

Kostensimulation nach Sampling-Rate

Angenommen, ein Worker verarbeitet 1 Milliarde Anfragen pro Monat:

Sampling-Rate Monatliche Events Axiom-Kosten Honeycomb-Kosten
100% (Kein Sampling) 1 Mrd. ~$500/Monat ~$1.000/Monat
10% 100 Mio. ~$50/Monat ~$100/Monat
5% 50 Mio. ~$25/Monat ~$50/Monat
1% 10 Mio. Kostenfrei ~$10/Monat

Durch ein Sampling von nur 5 % reduzieren Sie die Kosten um 95 % und erhalten dennoch statistisch repräsentative Stichproben von tausenden Anfragen pro Sekunde.

Schritt 5: Traces in der Praxis – Reale Produktionsszenarien

Langsame D1-Abfragen identifizieren

Mit der folgenden Abfrage in Axiom finden Sie D1-Abfragen, die länger als 100 ms dauern:

// Axiom APL-Abfrage
['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

Fehler-Muster analysieren

// Nur Traces mit 5xx-Fehlern filtern
['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

P99-Latenz nachverfolgen

// P50/P95/P99-Latenz für einen bestimmten Endpunkt
['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

Schritt 6: Tracing von Durable Objects

Durable Objects besitzen einen komplexeren Lebenszyklus als herkömmliche Workers, weshalb für sie ein gezieltes Tracing erforderlich ist.

Natives automatisches Tracing (Empfohlen)

Wenn Sie in der wrangler.jsonc lediglich observability.traces.enabled = true konfigurieren, werden DO-Aufrufe automatisch als Child Spans des übergeordneten Worker-Traces verknüpft. Paket @microlabs/otel-cf-workers wird hierfür nicht benötigt.

Worker-Anfrage-Trace
  └── fetch handler (root span)
       ├── D1-Abfrage (automatischer Span)
       └── Durable Object-Aufruf (automatischer Child Span)
            ├── DO fetch handler
            └── Interne D1-Abfrage in DO

@microlabs/otel-cf-workers: Manuelle Instrumentierung von DO

// src/rate-limiter.ts — Durable Object with 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");
    });
  }
}

// Durable Object-Klasse mit OTel wrappen
export const RateLimiter = instrumentDO(RateLimiterBase, config);

Schritt 7: Integration in CI/CD und Alerting

Automatische Überprüfung nach dem Deployment in GitHub Actions

# .github/workflows/deploy.yml
- name: Deploy Worker
  run: npx wrangler deploy

- name: Verify Observability (Verifizierung der Trace-Erfassung nach Deployment)
  run: |
    sleep 30  # Warten auf Trace-Erfassung
    # Anzahl der Fehler in den letzten 5 Minuten über die Axiom-API prüfen
    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 "❌ Anstieg von Fehlern nach Deployment: $ERRORS Fehler in 5 Min."
      exit 1
    fi
    echo "✅ Fehleranzahl nach Deployment normal: $ERRORS"

Alert-Konfiguration in Axiom

Slack-Benachrichtigung per Axiom Monitor bei Überschreitung des P99-Latenz-Schwellenwerts:

{
  "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"]
}

Leitfaden zur Auswahl von Anbietern

Szenario Empfohlener Anbieter Grund
Frische Startups, Budgeteinsparung Axiom 500 GB/Monat kostenfrei, integrierte Logs & Traces
Bestehender Grafana-Stack Grafana Cloud Nahtlose Dashboard-Integration, 50 GB/Monat kostenfrei
Komplexe Abfragen/Analysen erforderlich Honeycomb BubbleUp-Anomalieerkennung, fortgeschrittene Abfragen
On-Premise-Anforderungen SigNoz (Self-Hosted) Open-Source, Kubernetes-Deployment
Nur einfache Logs benötigt Cloudflare Dashboard Kostenfrei, keine zusätzliche Konfiguration nötig

Checkliste zur Kostenoptimierung

Liste der Optimierungen für die reale Produktionsumgebung:

Sampling-Strategie:

Datenreduktion:

Kosten-Monitoring:

Migrationspfad: Von wrangler tail zu OTel

Wenn Sie sich bisher ausschließlich auf wrangler tail und console.log verlassen haben, empfiehlt sich eine schrittweise Umstellung:

Woche 1: observability.traces.enabled: true zur wrangler.jsonc hinzufügen und Traces im Cloudflare Dashboard überprüfen
Woche 2: Axiom Destination hinzufügen und 30 Tage Aufbewahrung kostenfrei nutzen
Woche 3: Kern-Business-Logik mit benutzerdefinierten Spans instrumentieren
Woche 4: Sampling-Raten anpassen und Benachrichtigungen konfigurieren

Sie müssen bestehende console.log-Aufrufe nicht entfernen. Workers-Logs können mit observability.logs.enabled: true ebenfalls gemeinsam exportiert werden.

Fazit

Die OpenTelemetry-Unterstützung in Cloudflare Workers hat sich seit Ende 2024 rasant weiterentwickelt. Durch das native automatische Tracing werden alle Plattformoperationen ohne Codeänderungen erfasst, während die tracing.enterSpan()-API von cloudflare:workers die präzise Instrumentierung von Anwendungslogik ermöglicht.

Die Kosten lassen sich durch Sampling vollständig kontrollieren:

Der Wechsel von einer Blackbox-Edge-Funktion zu einem vollständig beobachtbaren verteilten System erfordert lediglich eine Minute, um fünf Zeilen in die wrangler.jsonc einzufügen.

Verwandter Artikel: Im Artikel Cloudflare Workflows und Durable Execution AI-Agenten finden Sie State-Management-Muster für Durable Objects.