Studionotizen
Engineering-Notiz

Einheitliche Observability für ein React-Native-, Node.js- und Convex-Monorepo

Wie wir mit @packages/observability konsistentes Logging und Tracing für unsere Expo-App, das Convex-Backend und Node.js-Skripte aufgebaut haben – einschließlich der CORS-Falle im Web.

Veröffentlicht
VonOkike Solutions
ObservabilityMonorepoReact NativeAxiom
Artikelnavigation
  1. 01Das Problem: fragmentierte Observability
  2. 02Die Lösung: ein Monorepo-Paket mit mehreren Einstiegspunkten
  3. 03Breite Ereignisse: ein Ereignis pro Ablauf
  4. 04React Native braucht einen anderen Ansatz
  5. 05Lokale Entwicklung
  6. 06Die CORS-Falle: Web benötigt einen Proxy
  7. 07CSP-Konfiguration
  8. 08Ergebnisse
  9. 09Wichtigste Erkenntnisse
  10. 10Ressourcen

Ausgeliefertes System · Observability-Ablauf

@packages/observability
React Native
Convex
Node.js

breites Ereignis

Ein gemeinsames Schema

request · service · operation · duration · outcome

Ziel

Axiom

Abfragbare Logs über alle Laufzeiten hinweg

≈400Zeilen
3Laufzeiten
<10mgemeldete Lösungszeit
Architekturdiagramm, das zeigt, wie React Native, Convex und Node.js ein gemeinsames Wide-Event-Schema an Axiom senden.

Kurzfassung: Wir haben ein TypeScript-Paket mit rund 400 Zeilen entwickelt, das strukturiertes Logging für React Native (iOS, Android und Web), ein Convex-Backend und Node.js-Skripte bereitstellt. Alle Daten fließen zu Axiom. Die wichtigste Erkenntnis: Browser blockieren direkte Aufrufe an Axiom wegen CORS, weshalb die Web-Plattform einen Proxy benötigt. Dadurch sank unsere mittlere Zeit bis zur Problemlösung von etwa 40 Minuten auf weniger als 10 Minuten.

Bei einem Produkt wie Plan2Meal läuft Code zwangsläufig in verschiedenen Umgebungen: in einer React-Native-App, einem serverlosen Convex-Backend und Node.js-Skripten für die Inhaltsautomatisierung. Jede Umgebung bringt ein eigenes Logging-Modell mit. Fehler über diese Grenzen hinweg zu untersuchen, war entsprechend mühsam.

Unser internes Paket @packages/observability bietet:

  • Strukturiertes Logging in jeder Laufzeit
  • Breite Ereignisse, die den vollständigen Kontext eines Ablaufs erfassen
  • Axiom als zentrale Observability-Plattform
  • Plattformspezifische Transporte, die ohne Sonderlogik im Fachcode funktionieren

Warum braucht das Web einen Proxy? Browser erzwingen CORS. Die Edge-API von Axiom liefert keine passenden Access-Control-Allow-Origin-Header. Native Apps unterliegen dieser Einschränkung nicht.

Das Problem: fragmentierte Observability

Vor diesem Paket bestand unser Logging aus voneinander getrennten console.log-Aufrufen:

// React-Native-App
console.log("User signed in", userId);
 
// Convex-Backend
console.log("[fetch_recipe]", url, "success");
 
// Skript zur Inhaltsautomatisierung
console.log("Generated blog post for", topic);

Es gab keine einheitliche Struktur, keine Korrelation und keine einfache Antwort auf Fragen wie: Was geschah, als Nutzer X versuchte, Rezept Y zu speichern?

Wir brauchten:

  1. Strukturierte, abfragbare Logs
  2. Breite Ereignisse mit vollständigem Operationskontext
  3. Ein gemeinsames Ziel für alle Observability-Daten
  4. Plattformspezifischen Code, der nicht in die Geschäftslogik durchsickert

Die Lösung: ein Monorepo-Paket mit mehreren Einstiegspunkten

Das Paket stellt getrennte Integrationen bereit:

packages/observability/
├── src/
│   ├── index.ts
│   ├── logger.ts
│   ├── wide-event.ts
│   └── integrations/
│       ├── node.ts
│       └── react-native/
│           ├── index.ts
│           └── axiom-transport.ts

Über bedingte Exporte erhält jede Plattform den passenden Einstiegspunkt:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./node": {
      "types": "./dist/integrations/node.d.ts",
      "import": "./dist/integrations/node.js"
    },
    "./react-native": {
      "types": "./src/integrations/react-native/index.ts",
      "import": "./src/integrations/react-native/index.ts"
    }
  }
}

Node.js kann Pino und Streaming-Transporte verwenden, während React Native einen anderen Ansatz benötigt. Die bedingten Exporte halten diese Erkennung aus dem Anwendungscode heraus. React Native verweist bewusst auf TypeScript-Quelldateien: Metro transpiliert sie direkt und vermeidet dadurch zusätzliche Build-Abhängigkeiten im Monorepo.

Breite Ereignisse: ein Ereignis pro Ablauf

Statt viele einzelne Logzeilen zu schreiben, erzeugen wir pro Ablauf ein reichhaltiges Ereignis. Es enthält Eingaben, Ausgaben, Fehler, Dauer und fachlichen Kontext.

import { createWideEvent, emit, flush } from "@packages/observability";
 
async function generateBlogPost(topic: string, keyword: string) {
  const event = createWideEvent({
    service: "content-automation",
    operation: "generate_blog_post",
  });
 
  event.setContext("article", { topic, keyword });
 
  try {
    const content = await callOpenAI(topic);
    event.setContext("result", { chars: content.length });
    event.setOutcome("success");
    return content;
  } catch (error) {
    event.setError(error);
    throw error;
  } finally {
    await emit(event);
  }
}
 
await flush();

Beim Aufruf von emit() wird das Ereignis automatisch abgeschlossen: Die Dauer wird berechnet, langsame Abläufe werden markiert und eine bei der Erstellung erzeugte request_id ermöglicht die Korrelation mit weiteren Logs und Traces.

Breite Ereignisse in Axiom abfragen

Mit APL lassen sich konkrete Fragen stellen, statt Zeitstempel manuell zu vergleichen:

['plan2meal-logs']
| where outcome == "error"
| where _time > ago(1h)
| project timestamp, service, operation, ['error.message'], ['context.user.id']

Langsame Abläufe nach Dienst:

['plan2meal-logs']
| where duration_ms > 5000
| summarize count() by service, operation
| order by count_ desc

Da die Ereignisse eine trace_id enthalten, können sie außerdem mit OpenTelemetry-Traces verbunden werden.

React Native braucht einen anderen Ansatz

Pino setzt Node.js-Streams und Dateisystem-APIs voraus, die in mobilen Laufzeiten fehlen. Deshalb verwenden wir react-native-logs und einen eigenen Axiom-Transport.

export const axiomTransport: transportFunctionType<AxiomTransportOptions> = (
  props,
) => {
  const { rawMsg, level, extension, options } = props;
 
  const event = {
    _time: new Date().toISOString(),
    level: level.text,
    message: typeof rawMsg === "string" ? rawMsg : JSON.stringify(rawMsg),
    namespace: extension || undefined,
    service: options.serviceName || "universal",
    platform: "react-native",
  };
 
  const url = options.webProxyUrl
    ? options.webProxyUrl
    : `${options.endpoint}/v1/ingest/${options.dataset}`;
 
  fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(!options.webProxyUrl && { Authorization: `Bearer ${options.token}` }),
    },
    body: JSON.stringify([event]),
  }).catch(() => {});
};

Logging erfolgt bewusst ohne Warten auf die Antwort. Ein Fehler im Transport darf die App niemals zum Absturz bringen. Namespaces wie AUTH, RECIPE und PAYMENTS machen die spätere Filterung in Axiom einfach.

Lokale Entwicklung

In der Entwicklung priorisieren wir eine schnelle Rückmeldung:

  • React Native: farbige Konsolenlogs; Axiom ist standardmäßig deaktiviert
  • Node.js: lesbare Ausgabe mit pino-pretty statt Versand an Axiom

Das vermeidet Netzwerkaufrufe, hält Entwicklungsdaten aus Produktions-Datensätzen fern und belässt möglicherweise sensible Debug-Informationen lokal.

Die CORS-Falle: Web benötigt einen Proxy

Auf iOS und Android funktionieren direkte HTTP-Aufrufe an Axiom. Im Browser blockiert CORS dieselben Anfragen. Die Lösung ist ein Proxy über eine serverseitige Convex-HTTP-Action:

const logsProxyHandler = httpAction(async (_, request) => {
  const body = await request.text();
 
  const axiomResponse = await fetch(
    `${process.env.AXIOM_ENDPOINT}/v1/ingest/${process.env.AXIOM_LOGS_DATASET}`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
      },
      body,
    },
  );
 
  const origin = request.headers.get("origin");
  return new Response(await axiomResponse.text(), {
    status: axiomResponse.status,
    headers: {
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Allow-Headers": "Content-Type, traceparent, tracestate",
    },
  });
});

Wichtige Details:

  • Der Axiom-Token bleibt auf dem Server.
  • Ein OPTIONS-Handler beantwortet die Preflight-Anfrage des Browsers.
  • traceparent und tracestate müssen erlaubt werden, weil OpenTelemetry diese Header automatisch ergänzt.
  • In Produktion sollte die erlaubte Herkunft auf bekannte Domains beschränkt werden.

React Native erkennt die Web-Plattform und setzt nur dort die Proxy-URL:

function getLogsProxyUrl(): string | undefined {
  if (Platform.OS !== "web") return undefined;
  const convexSiteUrl = API_URL?.replace(".cloud", ".site");
  return `${convexSiteUrl}/logs`;
}

CSP-Konfiguration

Eine Content Security Policy muss die öffentliche Convex-Site ebenfalls in connect-src zulassen. Andernfalls blockiert der Browser den Proxy-Aufruf trotz korrekter CORS-Header.

const csp = [
  "connect-src 'self' https://your-deployment.convex.cloud https://your-deployment.convex.site",
];

Ergebnisse

KennzahlVorherNachher
Mittlere Zeit bis zur Ursache~40 Minuten mit manueller Logsuche~8 Minuten mit einer Axiom-Abfrage
Plattformübergreifendes DebuggingManuelle Korrelationrequest_id verbindet alles
ProduktionssichtbarkeitKonsolenlogs in VercelStrukturiert, abfragbar und alarmierbar
Codeänderungen für LoggingPlattformspezifischÜberall dieselbe API

In der ersten Woche fanden wir unter anderem eine Race Condition beim Speichern von Rezepten, stille Authentifizierungsfehler auf Android und ein Speicherleckmuster in einem Skript zur Inhaltsgenerierung.

Wichtigste Erkenntnisse

  1. Bedingte Exporte trennen plattformspezifischen Code sauber.
  2. Breite Ereignisse sind bei komplexen Abläufen hilfreicher als verstreute Logs.
  3. Browser-CORS ist relevant: Observability-APIs benötigen häufig einen Backend-Proxy.
  4. OpenTelemetry-Header müssen in der CORS-Konfiguration erlaubt sein.
  5. React-Native-Exporte können auf Quelldateien zeigen: Metro übernimmt die Transpilierung.
  6. Entwicklungs- und Produktionsverhalten sollten getrennt sein.

Ressourcen

Wir entwickeln Plan2Meal, eine App zur Essensplanung und Erstellung von Einkaufslisten aus Rezepten. Entwickelt von Okike Solutions.

Weiter entdecken

Entdecken Sie die Arbeit hinter den Texten.

Mit Okike entwickeln

Haben Sie ein Produktproblem, das sich zu lösen lohnt?

Optionale Analysen helfen uns zu verstehen, wie die Website genutzt wird. Sie werden erst nach Ihrer Zustimmung geladen; eine Ablehnung beeinträchtigt keine wesentlichen Funktionen der Website. Mehr über Analyse und Datenschutz erfahren