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.
Artikelnavigation
- 01Das Problem: fragmentierte Observability
- 02Die Lösung: ein Monorepo-Paket mit mehreren Einstiegspunkten
- 03Breite Ereignisse: ein Ereignis pro Ablauf
- 04React Native braucht einen anderen Ansatz
- 05Lokale Entwicklung
- 06Die CORS-Falle: Web benötigt einen Proxy
- 07CSP-Konfiguration
- 08Ergebnisse
- 09Wichtigste Erkenntnisse
- 10Ressourcen
Ausgeliefertes System · Observability-Ablauf
@packages/observabilitybreites Ereignis
Ein gemeinsames Schema
request · service · operation · duration · outcome
Ziel
Axiom
Abfragbare Logs über alle Laufzeiten hinweg
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:
- Strukturierte, abfragbare Logs
- Breite Ereignisse mit vollständigem Operationskontext
- Ein gemeinsames Ziel für alle Observability-Daten
- 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_ descDa 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-prettystatt 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. traceparentundtracestatemü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
| Kennzahl | Vorher | Nachher |
|---|---|---|
| Mittlere Zeit bis zur Ursache | ~40 Minuten mit manueller Logsuche | ~8 Minuten mit einer Axiom-Abfrage |
| Plattformübergreifendes Debugging | Manuelle Korrelation | request_id verbindet alles |
| Produktionssichtbarkeit | Konsolenlogs in Vercel | Strukturiert, abfragbar und alarmierbar |
| Codeänderungen für Logging | Plattformspezifisch | Ü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
- Bedingte Exporte trennen plattformspezifischen Code sauber.
- Breite Ereignisse sind bei komplexen Abläufen hilfreicher als verstreute Logs.
- Browser-CORS ist relevant: Observability-APIs benötigen häufig einen Backend-Proxy.
- OpenTelemetry-Header müssen in der CORS-Konfiguration erlaubt sein.
- React-Native-Exporte können auf Quelldateien zeigen: Metro übernimmt die Transpilierung.
- 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