Building Unified Observability for a React Native + Node.js + Convex Monorepo
How we built @packages/observability to get consistent logging and tracing across our Expo app, Convex backend, and Node.js scripts. Plus the CORS gotcha that almost broke it.
TL;DR: We built a ~400-line TypeScript package that gives us structured logging across React Native (iOS, Android, Web), Convex backend, and Node.js scripts. Everything flows to Axiom. Key insight: browsers block direct Axiom calls (CORS), so web needs a proxy. This cut our mean-time-to-resolution from ~40 minutes of log hunting to under 10 minutes with a single query.
When you're building a product like Plan2Meal, you inevitably end up with code running in different environments: a React Native app (iOS, Android, Web), a serverless backend (Convex), and Node.js scripts for content automation. Each has its own logging story, and debugging issues across these boundaries is painful.
We built @packages/observability, an internal package that gives us:
- Structured logging everywhere
- Wide events for debugging complex operations
- Axiom as our central observability backend (think: Datadog but with a generous free tier and SQL-like queries)
- Platform-specific transports that "just work"
Here's how it works.
Why the proxy for web? Browsers enforce CORS. Axiom's edge API doesn't return Access-Control-Allow-Origin headers. Native apps don't have this restriction.
The Problem: Fragmented Observability
Before this package, our logging looked like:
// React Native app
console.log("User signed in", userId);
// Convex backend
console.log("[fetch_recipe]", url, "success");
// Content automation script
console.log("Generated blog post for", topic);No structure. No correlation. No way to query "what happened when user X tried to save recipe Y?"
We needed:
- Structured logs that can be queried
- Wide events that capture full operation context
- One destination (Axiom) for all observability data
- Platform-specific code that doesn't leak into business logic
The Solution: A Monorepo Package with Multiple Entry Points
Our @packages/observability package exposes different integrations:
packages/observability/
├── src/
│ ├── index.ts # Core: wide events, emit, flush
│ ├── logger.ts # Pino logger for Node.js
│ ├── wide-event.ts # Wide event builder
│ └── integrations/
│ ├── node.ts # Node.js helpers
│ └── react-native/
│ ├── index.ts # RN logger factory
│ └── axiom-transport.ts # Custom Axiom HTTP transport
The package.json uses conditional exports to provide platform-specific entry points:
{
"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"
}
}
}Why conditional exports? Each platform has different runtime constraints. Node.js can use Pino with its streaming transports, but React Native needs a different approach. Conditional exports let consumers import the right integration without platform detection logic in their code.
Why source files for React Native? The React Native export points to source .ts files, not compiled output. This is intentional. Metro (React Native's bundler) handles TypeScript transpilation natively, and pointing to source avoids build order issues in the monorepo. When you import @packages/observability/react-native, Metro transpiles it on-the-fly, ensuring you always get the latest code without rebuilding the package.
Wide Events: One Event Per Operation
Instead of scattered console.log calls, we use the "wide event" pattern (coined by Charity Majors). The idea is simple: instead of logging multiple times throughout an operation, create one rich event that captures everything. Inputs, outputs, errors, duration, and business context.
Why wide events? Traditional logging creates a trail of breadcrumbs you have to piece together. Wide events give you the full picture in one place. When debugging, you can query for a specific operation and see everything that happened.
The WideEvent class provides a fluent API for building these events:
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);
}
}
// At script exit
await flush();What happens under the hood? When you call emit(), the event is automatically finalized: duration is calculated (from creation time), slow operations are flagged (if duration exceeds 2 seconds), and the event is logged via Pino. The request_id is generated at creation time, so you can correlate this event with other logs or traces.
This produces a single event in Axiom with:
{
"timestamp": "2026-01-06T14:32:00.000Z",
"request_id": "req_abc123",
"service": "content-automation",
"operation": "generate_blog_post",
"duration_ms": 4523,
"outcome": "success",
"context": {
"article": { "topic": "Meal Planning Tips", "keyword": "meal prep" },
"result": { "chars": 12450 }
}
}Querying Wide Events in Axiom
The real power comes when querying. Instead of grepping through logs or correlating timestamps, you can ask specific questions. Axiom uses APL (Axiom Processing Language), which feels like SQL but optimized for time-series data:
Find all errors in the last hour:
['plan2meal-logs']
| where outcome == "error"
| where _time > ago(1h)
| project timestamp, service, operation, ['error.message'], ['context.user.id']Find slow operations by service:
['plan2meal-logs']
| where duration_ms > 5000
| summarize count() by service, operation
| order by count_ descTrack latency trends:
['plan2meal-logs']
| where operation == "fetch_recipe_metadata"
| summarize percentile(duration_ms, 95) by bin(_time, 1h)Correlate with traces: Since we include trace_id in wide events, you can join with OpenTelemetry traces to see the full request flow across services.
React Native: A Different Beast
Pino doesn't work in React Native. It relies on Node.js streams and file system APIs that don't exist in mobile runtimes. We needed a different approach.
The solution: react-native-logs provides a transport-based logging system that works across iOS, Android, and Web. We built a custom Axiom transport that sends logs via HTTP POST to Axiom's ingest API.
Why a custom transport? react-native-logs supports multiple transports (console, file, remote). We created an Axiom transport that formats logs consistently with our Node.js setup, ensuring all logs end up in the same Axiom dataset with the same schema.
Here's the transport implementation:
// packages/observability/src/integrations/react-native/axiom-transport.ts
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(() => {});
};Key design decisions:
- Fire-and-forget: The
.catch(() => {})ensures logging failures never crash the app - Batching: Axiom accepts arrays of events, so we could batch multiple logs (though we send one at a time for simplicity)
- Platform detection: The transport checks for
webProxyUrlto route through our CORS proxy on web
In the app, we create namespaced loggers for different domains:
// apps/universal/lib/logger.ts
import { createRNLogger } from "@packages/observability/react-native";
export const logger = createRNLogger({
axiomToken: AXIOM_TOKEN,
axiomDataset: AXIOM_LOGS_DATASET,
axiomEndpoint: AXIOM_ENDPOINT,
serviceName: "universal",
serviceVersion: getAppVersion(),
});
export const authLogger = logger.extend("AUTH");
export const recipeLogger = logger.extend("RECIPE");
export const paymentsLogger = logger.extend("PAYMENTS");Why namespaced loggers? The .extend() method creates child loggers with a namespace prefix. This makes filtering in Axiom trivial: you can query namespace == "AUTH" to see all authentication-related logs. It also helps with code organization since each module imports its domain-specific logger.
Usage is clean and consistent:
import { authLogger } from "@/lib/logger";
authLogger.info({ userId }, "User signed in");
authLogger.error({ error: err.message }, "Sign in failed");Local Development
In development (__DEV__ is true), the behavior changes to prioritize developer experience:
- React Native: Logs go to console with colors, Axiom transport is disabled (unless explicitly enabled)
- Node.js: Uses
pino-prettyfor readable, colorized console output instead of sending to Axiom
// Logger auto-detects environment
const logger = createRNLogger({
axiomToken: AXIOM_TOKEN, // Only used in production
axiomDataset: AXIOM_DATASET,
devSeverity: "debug", // Show all logs in dev
prodSeverity: "warn", // Only warn+ in prod
});Why disable Axiom in dev?
- Speed: No network calls means faster iteration
- Cost: Avoids polluting production datasets with development noise
- Privacy: Console logs stay local, which is useful when debugging with sensitive data
When you need to test the Axiom integration (e.g., verifying the CORS proxy works), temporarily set __DEV__ to false or use a staging dataset. The transport respects the __DEV__ flag and skips Axiom calls automatically.
The CORS Gotcha: Web Platform Needs a Proxy
Here's where it got interesting. On iOS and Android, the Axiom HTTP calls work fine because native apps aren't subject to browser CORS policies. But on web, browsers enforce CORS, and Axiom's edge API doesn't return Access-Control-Allow-Origin headers.
We got this error:
Access to fetch at 'https://eu-central-1.aws.edge.axiom.co/v1/ingest/...'
from origin 'https://app.plan2meal.com' has been blocked by CORS policy
Why CORS exists: Browsers prevent cross-origin requests unless the server explicitly allows them. This is a security feature, but it means you can't call Axiom's API directly from browser JavaScript unless Axiom adds your domain to their CORS allowlist (which they don't for edge endpoints).
The fix: Proxy through our Convex backend. Convex HTTP actions run server-side, so they're not subject to CORS restrictions. The browser calls our Convex endpoint, which forwards the request to Axiom.
// packages/backend/convex/http.ts
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",
},
});
});
http.route({ path: "/logs", method: "POST", handler: logsProxyHandler });
http.route({ path: "/logs", method: "OPTIONS", handler: corsPreflightHandler });Important details:
- Dynamic origin: We read the
originheader and echo it back inAccess-Control-Allow-Origin. This allows requests from any origin (in production, you might want to whitelist specific domains). - OPTIONS handler: Browsers send a preflight OPTIONS request before POST. We need a handler for that too.
- Token security: The Axiom token stays on the server and is never exposed to the browser.
Then we detect web platform and route accordingly:
// apps/universal/lib/logger.ts
function getLogsProxyUrl(): string | undefined {
if (Platform.OS !== "web") return undefined;
const convexSiteUrl = API_URL?.replace(".cloud", ".site");
return `${convexSiteUrl}/logs`;
}
export const logger = createRNLogger({
// ...
webProxyUrl: getLogsProxyUrl(),
});Platform detection: React Native's Platform.OS tells us if we're on web, iOS, or Android. On web, we pass the proxy URL; on native platforms, the transport uses the direct Axiom endpoint.
Another gotcha: We use OpenTelemetry for tracing, which automatically adds traceparent and tracestate headers to all fetch requests. The CORS preflight was failing because we didn't allow those headers in Access-Control-Allow-Headers. Adding them fixed it and ensures trace correlation works across the proxy boundary.
CSP Configuration
If you're using Content Security Policy (CSP), don't forget to allow your Convex site URL. CSP restricts which URLs your JavaScript can connect to, and the proxy endpoint needs to be whitelisted:
// apps/universal/vercel.ts
const csp = [
// ...
"connect-src 'self' https://your-deployment.convex.cloud https://your-deployment.convex.site ...",
];Why CSP matters: Without this, the browser will block the fetch request to the proxy endpoint, and you'll see CSP violations in the console. The .convex.site domain is Convex's public site URL (different from the .convex.cloud API URL).
Results
After implementing @packages/observability:
| Metric | Before | After |
|---|---|---|
| Mean time to find root cause | ~40 min (grepping logs) | ~8 min (single Axiom query) |
| Cross-platform debugging | Manual correlation | request_id joins everything |
| Production visibility | Console logs in Vercel | Structured, queryable, alertable |
| Code changes for logging | Platform-specific | Same API everywhere |
In the first week, we caught:
- A race condition in recipe saving (spotted via
duration_msoutliers) - Silent auth failures on Android (namespace filter:
AUTH+outcome: error) - A memory leak pattern in our content generation script (wide events showed increasing durations over time)
Key Takeaways
- Use conditional exports for platform-specific code
- Wide events > scattered logs for debugging complex operations
- Browser CORS is real: Proxy through your backend for observability APIs
- Don't forget OpenTelemetry headers in your CORS config (
traceparent,tracestate) - Point RN exports to source files: Let Metro handle transpilation
- Separate dev and prod behavior: Console in dev, Axiom in prod
Resources
- Axiom Documentation – Our observability backend
- react-native-logs – RN logging library we built on
- Wide Events / Canonical Log Lines – Stripe's blog on the pattern
- Pino – High-performance Node.js logger
We're building Plan2Meal, an app that helps you plan meals and generate grocery lists from recipes. Built by Okike Solutions.