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

推荐订阅源

V
Vulnerabilities – Threatpost
Google DeepMind News
Google DeepMind News
T
Threatpost
AWS News Blog
AWS News Blog
NISL@THU
NISL@THU
Security Latest
Security Latest
C
Cisco Blogs
Spread Privacy
Spread Privacy
Simon Willison's Weblog
Simon Willison's Weblog
L
LINUX DO - 热门话题
Know Your Adversary
Know Your Adversary
L
LINUX DO - 最新话题
P
Privacy & Cybersecurity Law Blog
SecWiki News
SecWiki News
O
OpenAI News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
H
Hacker News: Front Page
Scott Helme
Scott Helme
C
Check Point Blog
WordPress大学
WordPress大学
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
爱范儿
爱范儿
博客园 - 【当耐特】
H
Heimdal Security Blog
The Hacker News
The Hacker News
罗磊的独立博客
C
CERT Recently Published Vulnerability Notes
美团技术团队
Application and Cybersecurity Blog
Application and Cybersecurity Blog
S
SegmentFault 最新的问题
Last Week in AI
Last Week in AI
Project Zero
Project Zero
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
宝玉的分享
宝玉的分享
The Register - Security
The Register - Security
A
Arctic Wolf
酷 壳 – CoolShell
酷 壳 – CoolShell
G
GRAHAM CLULEY
L
Lohrmann on Cybersecurity
T
The Blog of Author Tim Ferriss
G
Google Developers Blog
Attack and Defense Labs
Attack and Defense Labs
博客园 - 叶小钗
大猫的无限游戏
大猫的无限游戏
Latest news
Latest news
I
InfoQ
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Y
Y Combinator Blog
J
Java Code Geeks
S
Security @ Cisco Blogs

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 Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
How to Build a Vendor-Agnostic Logger (with a Grafana Implementation)
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.).