惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

F
Fortinet All Blogs
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
人人都是产品经理
人人都是产品经理
V
Visual Studio Blog
Last Week in AI
Last Week in AI
V
V2EX
博客园_首页
IT之家
IT之家
Jina AI
Jina AI
博客园 - 叶小钗
The Cloudflare Blog
T
Tailwind CSS Blog
腾讯CDC
B
Blog
D
Docker
L
LangChain Blog
博客园 - 司徒正美
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
爱范儿
爱范儿
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
How to Build a Vendor-Agnostic Logger (with a Grafana Imp...
Felipe Camar · 2026-04-24 · via DEV Community

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.).