DEV Community

Felipe Camargo
Felipe Camargo

Posted on

How to Build a Vendor-Agnostic Logger (with a Grafana Implementation)

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:

  1. 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.
  2. 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:

Grafana Dashboard with different log entries


What We’re Building

We’ll create a flexible, extensible logging system for a React Native app that:

  • Works with any backend through a simple adapter pattern
  • Automatically enriches logs with valuable context (device info, network status, app details)
  • Batches logs to optimize network usage
  • Includes retry logic with exponential backoff for reliability
  • Provides readable console output during development
  • Can be extended to work with any observability platform you choose

While this tutorial focuses on React Native (since that’s what I needed at the time), the core pattern is framework-agnostic and it should be easy to adapt to any other front-end technology like React, Vue, Angular, etc.

To demonstrate the pattern, we’ll build an adapter for Grafana Loki (a cost-effective, open-source log aggregation system), but you’ll be able to adapt this to work with any logging backend.


A Critical Security Note

⚠️ Do Not Expose API Keys in Production

The implementation in this tutorial sends logs directly from the client to Grafana Cloud, which requires embedding credentials in your mobile app code. This is NOT secure for production.

For production apps, you must implement a backend proxy: your app sends logs to your own server, which then forwards them to whatever logging service you’re using. This protects your credentials and gives you control over the data flow.

We’ll cover a simple proxy approach at the end of this article to solve this.


Prerequisites

Before we begin, make sure you have:

  • A working React Native development environment (we’ll use Expo in our examples, but the pattern works with bare React Native too)
  • A Grafana Cloud account if you want to follow along with the Grafana adapter (free tier available at grafana.com)

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.

Logger’s Sequence Diagram

Logger’s Sequence Diagram


Step 1: Setting Up Dependencies

First, let’s install the necessary libraries for our logger.

npx expo install react-native-logs @react-native-community/netinfo expo-device expo-application expo-constants
Enter fullscreen mode Exit fullscreen mode
  • react-native-logs: A simple and effective logging library that we'll use for beautiful console output during development.
  • @react-native-community/netinfo: Provides information about the device's network connection.
  • expo-device, expo-application, expo-constants: Expo modules to get rich information about the device and application.

Step 2: Defining the Core Types

Let’s start by defining the TypeScript interfaces that will form the foundation of our logger.

// src/logger/logger.types.ts
import { DeviceType } from 'expo-device';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface NetworkInfo {
  type: string;
  isConnected: boolean | string;
  isInternetReachable: boolean | string;
  cellularGeneration?: string | null;
  strength?: number | null;
}
export interface DeviceInfo {
  brand: string;
  type: DeviceType | string;
  isDevice: boolean;
  model: string;
  buildId: string;
  osName: string;
  osVersion: string;
  totalMemory: number;
}
export interface LogLabels {
  appName: string;
  appVersion: string;
  appId: string;
  environment: string;
  platform: string;
  device: DeviceInfo;
  [key: string]: any;
}
export interface LogEntry {
  timestamp: string;
  level: LogLevel;
  message: string;
  labels: LogLabels;
}
export interface LoggerConfig {
  environment: string;
  batchSize?: number;
  maxRetries?: number;
}
export interface LoggerAdapter {
  log(entry: LogEntry): Promise<void>;
  flush(): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The most important piece here is the LoggerAdapter interface. This is the key to our logger's extensibility. Any class that implements this interface can be used to send logs to a different backend.

Step 3: Creating an Adapter (Example: Grafana Loki)

Now, let’s create our first adapter. Remember, because of our LoggerAdapter interface, you could write this exact same class for Datadog, AWS CloudWatch, or a custom Node server. For this tutorial, we are writing an adapter responsible for sending logs to Grafana Loki.

// src/logger/adapters/grafana.ts
import NetInfo from '@react-native-community/netinfo';
import { LoggerAdapter, LogEntry } from 'src/logger.types';
export interface GrafanaConfig {
  url: string;
  username: string;
  apiKey: string;
  environment: string;
  batchSize: number;
  maxRetries: number;
}
export interface GrafanaStreamValue {
  stream: {
    source: string;
    [key: string]: string;
  };
  values: [string, string][];
}
export class GrafanaAdapter implements LoggerAdapter {
  private queue: LogEntry[] = [];
  private isSending: boolean = false;
  private retryCount: number = 0;
  constructor(private config: GrafanaConfig) {}
  async log(entry: LogEntry): Promise<void> {
    this.queue.push(entry);
    await this.processBatch();
  }
  async flush(): Promise<void> {
    await this.processBatch(true);
  }
  private async processBatch(forceFlush: boolean = false): Promise<void> {
    if (
      this.isSending ||
      (!forceFlush && this.queue.length < this.config.batchSize)
    ) {
      return;
    }
    const networkState = await NetInfo.fetch();
    if (!networkState.isConnected) {
      return;
    }
    this.isSending = true;
    const batch = this.queue.splice(0, this.config.batchSize);
    try {
      const streams: GrafanaStreamValue[] = [
        {
          stream: {
            source: 'react-native',
          },
          values: batch.map((log) => [
            log.timestamp,
            JSON.stringify({
              level: log.level,
              message: log.message,
              ...log.labels,
            }),
          ]),
        },
      ];
      const response = await fetch(this.config.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Basic ${btoa(`${this.config.username}:${this.config.apiKey}`)}`,
        },
        body: JSON.stringify({ streams }),
      });
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      this.retryCount = 0;
    } catch (error) {
      this.queue.unshift(...batch);
      this.retryCount++;
      if (this.retryCount < this.config.maxRetries) {
        const backoffTime = Math.min(
          1000 * Math.pow(2, this.retryCount),
          30000,
        );
        setTimeout(() => this.processBatch(true), backoffTime);
      }
      console.error('Error sending logs:', error);
    } finally {
      this.isSending = false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This adapter buffers log entries in a queue and sends them in batches to optimize network requests. It also includes an exponential backoff retry mechanism to gracefully handle network failures. Note the use of btoa for Basic Authentication.

Understanding the Grafana Log Format

The adapter sends logs to Grafana Loki in a specific format that makes them queryable and filterable (Log Format might vary between providers). Let’s break down how the logs are structured:

const streams: GrafanaStreamValue[] = [
        {
          stream: {
            source: 'react-native',
          },
          values: batch.map((log) => [
            log.timestamp,
            JSON.stringify({
              level: log.level,
              message: log.message,
              ...log.labels,
            }),
          ]),
        },
      ];
Enter fullscreen mode Exit fullscreen mode

Key components:

  1. Stream labels (stream object): These are indexed by Loki and used for filtering. In our case, we use source: 'react-native' to identify all logs from our mobile app. You could add more labels here like environment: 'production' or version: '1.0.0'.
  2. Log entries (values array): Each entry is a tuple of [timestamp, log_line] where:
    • timestamp: Nanosecond precision timestamp (required by Loki)
    • log_line: A JSON string containing the actual log data including level, message, and all enrichment labels

Grafana Dashboard with Logs

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key features:

  • Uses react-native-logs for 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. Log in to your Grafana Cloud account
  2. Navigate to your Loki instance
  3. Find your Instance ID (username) and generate an API Token
  4. Copy your Loki push endpoint URL (it will look like [https://logs-prod-XXX.grafana.net/loki/api/v1/push](https://logs-prod-XXX.grafana.net/loki/api/v1/push))))

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Click on the “Explore” icon in the sidebar.
  2. Select the “Loki” data source at the top.
  3. 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"}.
  4. You can then filter by level, app version, or any other label you’ve sent. For example: {source=”react-native”} | json | level = `error`.

Grafana Query Configuration


Creating Additional Adapters

The cool part of this design is you can easily create adapters for other platforms. Here’s a template for a custom adapter:

import { LoggerAdapter, LogEntry } from 'src/logger/logger.types';
export interface CustomBackendConfig {
  apiUrl: string;
  apiKey: string;
  batchSize: number;
}
export class CustomBackendAdapter implements LoggerAdapter {
  private queue: LogEntry[] = [];
  constructor(private config: CustomBackendConfig) {}
  async log(entry: LogEntry): Promise<void> {
    this.queue.push(entry);

    if (this.queue.length >= this.config.batchSize) {
      await this.flush();
    }
  }
  async flush(): Promise<void> {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0, this.queue.length);
    try {
      await fetch(this.config.apiUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': this.config.apiKey,
        },
        body: JSON.stringify({
          logs: batch.map(entry => ({
            timestamp: entry.timestamp,
            level: entry.level,
            message: entry.message,
            metadata: entry.labels,
          })),
        }),
      });
    } catch (error) {
      // Put logs back on failure
      this.queue.unshift(...batch);
      console.error('Failed to send logs:', error);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then swap it in your LoggerManager:

// Replace GrafanaAdapter with your custom adapter
const adapter = new CustomBackendAdapter({
  apiUrl: 'https://your-backend.com/logs',
  apiKey: 'your-api-key',
  batchSize: 10,
});
Enter fullscreen mode Exit fullscreen mode

Building a Secure Backend Proxy

For production apps, you need a backend proxy to protect your credentials. Here’s a simple Express.js example. While this specific snippet forwards our batched logs to Grafana, you could easily swap the fetch destination in this proxy to forward to any other service without ever touching your frontend code again:

// server.js
const express = require('express');
const fetch = require('node-fetch');
const app = express();
app.use(express.json({ limit: '10mb' }));
// Verify requests are from your app
const authenticateApp = (req, res, next) => {
  const appToken = req.headers['x-app-token'];

  if (appToken !== process.env.APP_SECRET_TOKEN) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  next();
};
app.post('/api/logs', authenticateApp, async (req, res) => {
  try {
    const { streams } = req.body;
    // Optional: Add rate limiting here
    // Optional: Filter or sanitize logs here
    // Optional: Add additional metadata here
    const response = await fetch(process.env.GRAFANA_LOKI_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Basic ${btoa(
          `${process.env.GRAFANA_INSTANCE_ID}:${process.env.GRAFANA_API_KEY}`
        )}`,
      },
      body: JSON.stringify({ streams }),
    });
    if (!response.ok) {
      throw new Error(`Grafana returned ${response.status}`);
    }
    res.status(200).json({ success: true });
  } catch (error) {
    console.error('Failed to forward logs:', error);
    res.status(500).json({ error: 'Failed to process logs' });
  }
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Log proxy server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Then update your GrafanaAdapter to point to your proxy:

private static getConfig(): GrafanaConfig {
  return {
    url: 'https://your-backend.com/api/logs', // Your proxy endpoint
    username: '', // Not needed with proxy
    apiKey: process.env.APP_SECRET_TOKEN, // Shared secret
    environment,
    batchSize: LoggerManager.BATCH_SIZE,
    maxRetries: LoggerManager.MAX_RETRIES,
    flushInterval: LoggerManager.FLUSH_INTERVAL,
  };
}
Enter fullscreen mode Exit fullscreen mode

Next Steps

  • Build a Backend Proxy: As mentioned in the security note, the most important next step for a production app is to create a backend service that receives logs from your app and forwards them to Grafana. This will protect your credentials but also will allow you to scale better.
  • Create More Adapters: Write new LoggerAdapter implementations to send logs to other platforms like Sentry, Datadog, or your own custom logging backend.
  • Offline Persistence: Implement a mechanism to store logs on the device when there’s no network connection and send them once the connection is restored. You could use AsyncStorage or a local database for this.
  • Open Telemetry: 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.).

Top comments (0)