The Problem: Why I Built My Own Logger
If you’ve ever tried to implement proper logging in a frontend application whether it’s React, React Native, Vue, or any other framework, you might feel the frustration. You start searching for solutions and quickly realize you have two options:
- Pay for a managed platform like Sentry, Datadog, or LogRocket. These are great tools, but they come with monthly costs that scale with your usage. For side projects, small startups, or apps that aren’t generating revenue yet, the free tier is great, but when the app starts to scale and your app to grow you will find yourself in a vendor lock-in.
- Use an “open-source” solution like SigNoz or Grafana. These seem perfect at first: free, powerful, and self-hostable. But here’s the catch: their SDKs and integrations often tie you to their specific data formats, APIs, and ecosystem. You’re still locked in, just in a different way. We will actually use Grafana later in this tutorial, but as a destination, not a dependency.
This isn’t just a React Native problem, it’s a frontend problem. Web apps, mobile apps, desktop apps, they all face the same dilemma. The backend world has standardized logging libraries (Winston, Pino, Log4j), but frontend? We’re stuck choosing between expensive SaaS or vendor-specific SDKs.
What I really wanted was simple: a logger that I control. Something that could send logs wherever I decide, whether that’s Grafana today, a custom backend tomorrow, or a different platform next year. I wanted the flexibility to switch observability platforms without rewriting my entire logging infrastructure.
I couldn’t find a library that fit this need, so I built one. And in this tutorial, I’m going to show you how to build it too.
The end result?
Context-rich and nicely formatted logs sent from our vendor-agnostic logger library to any destination you choose. To show this in action, here is what our custom logs look like when routed to Grafana:
)
System Design
Here’s a high-level overview of how our logging system will work. The application calls our Logger, which formats the log entry and passes it to an Adapter. The Adapter is responsible for batching the logs and sending them to your chosen observability platform.
To prove how plug-and-play this is, we'll implement an adapter for Grafana Cloud's Loki endpoint for this tutorial, but the underlying system doesn't know or care that Grafana is on the receiving end.
This structure is what allows you to filter logs in Grafana by level, user ID, network type, or any custom label you add. The level field embedded in the JSON log line enables the color-coded visualization you see in the Grafana UI (red for errors, yellow for warnings, green for info).
Step 4: Building the Main Logger Class
Next, let’s create the Logger class itself. This class will format log messages and use the adapter to send them.
// src/logger/logger.ts
import NetInfo, {
NetInfoState,
NetInfoStateType,
} from '@react-native-community/netinfo';
import { logger as reactNativeLogs, consoleTransport } from 'react-native-logs';
import {
LoggerAdapter,
LogLevel,
LogEntry,
LogLabels,
NetworkInfo,
} from '@/src/types/logger.types';
export class Logger {
public static userId: string = 'unknown';
public static consoleLog = reactNativeLogs.createLogger({
severity: __DEV__ ? 'debug' : 'error',
enabled: __DEV__ ? true : false,
transport: consoleTransport,
transportOptions: {
colors: {
debug: 'default',
info: 'blueBright',
warn: 'yellowBright',
error: 'redBright',
},
},
printLevel: true,
});
constructor(
private adapter: LoggerAdapter,
private defaultLabels: LogLabels,
) {}
private async createEntry(
level: LogLevel,
message: string,
labels: Record<string, any> = {},
): Promise<LogEntry> {
const network = await Logger.getNetworkInfo();
return {
timestamp: (new Date().getTime() * 1000000).toString(),
level,
message,
labels: {
network,
userId: Logger.userId,
...this.defaultLabels,
...labels,
},
};
}
async log(
message: string,
labels?: Record<string, any>,
level: LogLevel = 'info',
): Promise<void> {
const logEntry = await this.createEntry(level, message, labels);
Logger.consoleLog[level](logEntry);
// To avoid sending logs for debug level or localhost development
if (__DEV__ || level === 'debug') return;
return this.adapter.log(logEntry);
}
async debug(message: string, labels?: Record<string, any>): Promise<void> {
return this.log(message, labels, 'debug');
}
async info(message: string, labels?: Record<string, any>): Promise<void> {
return this.log(message, labels, 'info');
}
async warn(message: string, labels?: Record<string, any>): Promise<void> {
return this.log(message, labels, 'warn');
}
async error(message: string, labels?: Record<string, any>): Promise<void> {
return this.log(message, labels, 'error');
}
async flush(): Promise<void> {
await this.adapter.flush();
}
private static async getNetworkInfo(): Promise<NetworkInfo> {
const state: NetInfoState = await NetInfo.fetch();
const networkInfo: NetworkInfo = {
type: state.type,
isConnected: state.isConnected ?? 'unknown',
isInternetReachable: state.isInternetReachable ?? 'unknown',
};
if (state.type === NetInfoStateType.cellular && state.details) {
networkInfo.cellularGeneration = state.details.cellularGeneration;
}
if (state.type === NetInfoStateType.wifi && state.details) {
networkInfo.strength = state.details.strength;
}
return networkInfo;
}
}
Key features:
- Uses
react-native-logsfor nice console output during development
LOG 15:00:47 | ERROR :
{
"timestamp": "1760882447585000000",
"level": "error",
"message": "Undefined date",
"labels": {
"network": {
"type": "wifi",
"isConnected": true,
"isInternetReachable": true,
"strength": 99
},
"userId": "41234",
"appName": "One Pocket",
"appVersion": "1.0.0",
"appId": "com.onepocket.app",
"environment": "development",
"platform": "android",
"device": {
"brand": "google",
"type": 1,
"buildId": "BP3A.251005.004.B1",
"isDevice": true,
"model": "Pixel 8",
"osName": "Android",
"osVersion": "16",
"totalMemory": 7925166080
}
}
}
- Automatically enriches every log with network information
- Has proper error handling to prevent logging failures from crashing your app
- Skips sending logs to external services in development mode
- Provides a convenient way to track user IDs
Note: __DEV__ is a global variable provided by React Native that's true in development and false in production builds.
Note: The timestamp format (new Date().getTime() * 1000000).toString() converts milliseconds to nanoseconds, which is what Grafana Loki expects.
Step 5: Managing the Logger Instance
To make our logger easily accessible throughout the app, we’ll create a singleton manager. This manager will be responsible for instantiating and configuring the logger.
// src/logger/logger-manager.ts
import * as Application from 'expo-application';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { DeviceInfo } from 'src/loger/logger.types';
import { GrafanaAdapter, GrafanaConfig } from 'src/logger/adapters/grafana';
import { Logger } from 'src/logger/logger';
export class LoggerManager {
public static readonly BATCH_SIZE = 10;
public static readonly MAX_RETRIES = 3;
private static instance: Logger | null = null;
static getInstance(): Logger {
if (!this.instance) {
const config = LoggerManager.getConfig();
const device = LoggerManager.getDeviceInfo();
const adapter = new GrafanaAdapter(config);
this.instance = new Logger(adapter, {
appName: Application.applicationName ?? 'unknown',
appVersion: Application.nativeApplicationVersion ?? 'unknown',
appId: Application.applicationId ?? 'unknown',
environment: config.environment,
platform: Platform.OS,
device,
});
}
return this.instance;
}
private static getConfig(): GrafanaConfig {
const environment =
Constants.expoConfig?.extra?.environment || 'development';
const instanceId: string | undefined =
Constants.expoConfig?.extra?.grafanaInstanceId;
const apiKey: string | undefined =
Constants.expoConfig?.extra?.grafanaApiKey;
if (!instanceId) {
throw new Error('Grafana instance ID is not defined');
}
if (!apiKey) {
throw new Error('Grafana API key is not defined');
}
return {
url: 'https://logs-prod-012.grafana.net/loki/api/v1/push',
username: instanceId,
apiKey: apiKey,
environment,
batchSize: environment === 'development' ? 1 : LoggerManager.BATCH_SIZE,
maxRetries: environment === 'development' ? 1 : LoggerManager.MAX_RETRIES,
};
}
public static getDeviceInfo(): DeviceInfo {
return {
brand: Device.brand ?? 'unknown',
type: Device.deviceType ?? 'unknown',
buildId: Device.osBuildId ?? 'unknown',
isDevice: Device.isDevice,
model: Device.modelName ?? 'unknown',
osName: Device.osName ?? 'unknown',
osVersion: Device.osVersion ?? 'unknown',
totalMemory: Device.totalMemory ?? 0,
};
}
}
export const logger = LoggerManager.getInstance();
This manager:
- Pulls configuration from Expo Constants
- Gathers device and application metadata
- Creates enriched default labels for all logs
- Exports a singleton instance for convenience
- Adjusts batching behavior for development vs production
Step 6: Configuring Grafana Credentials
Now we need to provide your Grafana credentials to the app:
- Log in to your )
Add these to your Expo config file (app.json or app.config.js):
{
"expo": {
"name": "your-app-name",
"slug": "your-app-slug",
"extra": {
"environment": "production",
"grafanaInstanceId": "YOUR_INSTANCE_ID",
"grafanaApiKey": "YOUR_API_TOKEN"
}
}
}
Important: Update the grafanaUrl in LoggerManager.getConfig() with your actual Loki endpoint URL (replace logs-prod-012 with your specific instance)
Step 7: Using the Logger
With everything set up, using the logger is straightforward:
import { logger } from 'src/logger/logger-manager';
import { useEffect, useState } from 'react';
import { Button, View, Text, StyleSheet } from 'react-native';
const UserProfileScreen = ({ userId }: { userId: string }) => {
const [userData, setUserData] = useState(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Set user ID for all subsequent logs
logger.setUserId(userId);
// Log screen view
logger.info('User viewed profile screen', {
screen: 'UserProfile',
});
loadUserData();
}, [userId]);
const loadUserData = async () => {
try {
logger.debug('Fetching user data', { userId });
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setUserData(data);
logger.info('User data loaded successfully', {
dataSize: JSON.stringify(data).length,
});
} catch (error) {
logger.error('Failed to load user data', {
error: error.message,
stack: error.stack,
});
setError(error.message);
}
};
const handleLogout = async () => {
logger.info('User initiated logout', {
screen: 'UserProfile',
});
try {
await performLogout();
logger.info('User logged out successfully');
} catch (error) {
logger.error('Logout failed', {
error: error.message,
});
}
};
const handleDeleteAccount = () => {
logger.warn('User initiated account deletion', {
requiresReview: true,
});
// ... show confirmation dialog
};
if (error) {
return (
<View style={styles.container}>
<Text>Error: {error}</Text>
<Button title="Retry" onPress={loadUserData} />
</View>
);
}
return (
<View style={styles.container}>
<Text>User Profile</Text>
<Button title="Logout" onPress={handleLogout} />
<Button
title="Delete Account"
onPress={handleDeleteAccount}
color="red"
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
});
export default UserProfileScreen;
Viewing Your Logs (Grafana Example)
If you followed along and implemented the Grafana adapter, head over to your Grafana Cloud dashboard after running your app and triggering some logs to see your agnostic logger in action:
- Click on the “Explore” icon in the sidebar.
- Select the “Loki” data source at the top.
- In the query editor, you can start querying your logs. For example, to see all logs from your app, you can use the query:
{source="react-native"}. - You can then filter by level, app version, or any other label you’ve sent. For example:
{source=”react-native”} | json | level = `error`.
: By instrumenting your logs using the OTel APIs, your application is decoupled from any specific monitoring or observability backend (like Datadog, Splunk, Jaeger, Prometheus, etc.).
SOCIAL SHARE CARD GENERATOR