Skip to content

📱 Push Sender

High-performance push notification delivery service for mobile devices

Overview

The Push Sender service is responsible for delivering push notifications to mobile devices across iOS, Android, and web platforms. It provides high-throughput, reliable delivery with real-time tracking and optimization capabilities.

🏗️ Architecture

Core Responsibilities

  • Push Notification Delivery: Send notifications to iOS (APNs) and Android (FCM) devices
  • Device Token Management: Validate, refresh, and manage device tokens
  • Batch Processing: Efficient batch delivery for high-volume campaigns
  • Delivery Tracking: Real-time delivery status and analytics
  • Rate Limiting: Platform-specific rate limiting and throttling
  • A/B Testing: Support for notification A/B testing and optimization

Technology Stack

  • Node.js: Runtime environment
  • Firebase Cloud Messaging (FCM): Android push notifications
  • Apple Push Notification Service (APNs): iOS push notifications
  • Web Push API: Browser-based push notifications
  • Redis: Token caching and rate limiting
  • MongoDB: Delivery tracking and analytics

🔧 Configuration

Server Details

  • Server: ovh-pushsender
  • Port: 3000 (HTTP), 3001 (HTTPS)
  • Environment: Production, Staging, Development

Environment Variables

# Firebase Configuration
FIREBASE_PROJECT_ID=appgain-push
FIREBASE_PRIVATE_KEY=ask your direct manager for the access
FIREBASE_CLIENT_EMAIL=ask your direct manager for the access

# Apple Push Configuration
APNS_KEY_ID=ask your direct manager for the access
APNS_TEAM_ID=ask your direct manager for the access
APNS_PRIVATE_KEY=ask your direct manager for the access
APNS_BUNDLE_ID=com.appgain.app

# Redis Configuration
REDIS_HOST=ovh-redis
REDIS_PORT=6379
REDIS_PASSWORD=ask your direct manager for the access

# MongoDB Configuration
MONGODB_URI=mongodb://ovh-mongo-master:27017/push_sender

# Performance Configuration
BATCH_SIZE=1000
MAX_CONCURRENT_REQUESTS=100
RATE_LIMIT_PER_SECOND=1000

# Monitoring
SENTRY_DSN=ask your direct manager for the access
PROMETHEUS_ENABLED=true
LOG_LEVEL=info

📱 Platform Support

Android (FCM)

// FCM Configuration
const admin = require('firebase-admin');

const serviceAccount = {
  projectId: process.env.FIREBASE_PROJECT_ID,
  privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
  clientEmail: process.env.FIREBASE_CLIENT_EMAIL
};

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

const messaging = admin.messaging();

iOS (APNs)

// APNs Configuration
const apn = require('apn');

const apnProvider = new apn.Provider({
  token: {
    key: process.env.APNS_PRIVATE_KEY,
    keyId: process.env.APNS_KEY_ID,
    teamId: process.env.APNS_TEAM_ID
  },
  production: process.env.NODE_ENV === 'production'
});

Web Push

// Web Push Configuration
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:notifications@appgain.io',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

📊 API Endpoints

Notification Delivery

# Send Single Notification
POST /api/notifications/send
{
  "deviceToken": "device_token",
  "platform": "ios|android|web",
  "title": "Notification Title",
  "body": "Notification Body",
  "data": {
    "custom_key": "custom_value"
  },
  "options": {
    "badge": 1,
    "sound": "default",
    "priority": "high"
  }
}

# Send Batch Notifications
POST /api/notifications/batch
{
  "notifications": [
    {
      "deviceToken": "token1",
      "platform": "ios",
      "title": "Title 1",
      "body": "Body 1"
    },
    {
      "deviceToken": "token2",
      "platform": "android",
      "title": "Title 2",
      "body": "Body 2"
    }
  ]
}

# Send to Topic
POST /api/notifications/topic
{
  "topic": "news",
  "title": "Breaking News",
  "body": "Latest updates available",
  "data": {
    "news_id": "123"
  }
}

Device Token Management

# Register Device Token
POST /api/tokens/register
{
  "userId": "user_id",
  "deviceToken": "device_token",
  "platform": "ios|android|web",
  "appVersion": "1.0.0",
  "deviceInfo": {
    "model": "iPhone 12",
    "os": "iOS 15.0"
  }
}

# Update Device Token
PUT /api/tokens/:tokenId
{
  "userId": "user_id",
  "platform": "ios",
  "appVersion": "1.1.0"
}

# Delete Device Token
DELETE /api/tokens/:tokenId

# Get User Tokens
GET /api/tokens/user/:userId

Analytics & Reporting

# Get Delivery Statistics
GET /api/analytics/delivery?startDate=2024-01-01&endDate=2024-01-31

# Get Platform Statistics
GET /api/analytics/platforms?period=daily

# Get Error Analysis
GET /api/analytics/errors?platform=ios&limit=10

# Get Performance Metrics
GET /api/analytics/performance?metric=latency&period=hourly

🔄 Delivery Flow

Single Notification Flow

// Notification processing
async function sendNotification(notification) {
  const { deviceToken, platform, title, body, data, options } = notification;

  try {
    // Validate device token
    const isValid = await validateDeviceToken(deviceToken, platform);
    if (!isValid) {
      throw new Error('Invalid device token');
    }

    // Prepare notification payload
    const payload = preparePayload(platform, title, body, data, options);

    // Send notification
    let result;
    switch (platform) {
      case 'ios':
        result = await sendToAPNs(deviceToken, payload);
        break;
      case 'android':
        result = await sendToFCM(deviceToken, payload);
        break;
      case 'web':
        result = await sendToWebPush(deviceToken, payload);
        break;
      default:
        throw new Error(`Unsupported platform: ${platform}`);
    }

    // Track delivery
    await trackDelivery(notification, result);

    return result;
  } catch (error) {
    // Handle errors
    await handleDeliveryError(notification, error);
    throw error;
  }
}

Batch Processing Flow

// Batch notification processing
async function sendBatchNotifications(notifications) {
  const batches = groupByPlatform(notifications);
  const results = [];

  for (const [platform, batch] of Object.entries(batches)) {
    const platformResults = await processBatch(platform, batch);
    results.push(...platformResults);
  }

  return results;
}

async function processBatch(platform, notifications) {
  const batchSize = getBatchSize(platform);
  const batches = chunk(notifications, batchSize);
  const results = [];

  for (const batch of batches) {
    const batchResult = await sendBatchToPlatform(platform, batch);
    results.push(...batchResult);

    // Rate limiting
    await delay(getRateLimitDelay(platform));
  }

  return results;
}

🗄️ Database Schema

Device Tokens Collection

{
  "_id": "ObjectId",
  "token": "device_token_string",
  "userId": "user_id",
  "platform": "ios|android|web",
  "appVersion": "1.0.0",
  "deviceInfo": {
    "model": "iPhone 12",
    "os": "iOS 15.0",
    "manufacturer": "Apple"
  },
  "isActive": true,
  "lastUsed": "2024-01-01T00:00:00Z",
  "createdAt": "2024-01-01T00:00:00Z",
  "updatedAt": "2024-01-01T00:00:00Z"
}

Delivery Logs Collection

{
  "_id": "ObjectId",
  "notificationId": "notification_id",
  "deviceToken": "device_token",
  "platform": "ios|android|web",
  "status": "sent|delivered|failed|invalid_token",
  "errorCode": "error_code_if_failed",
  "errorMessage": "error_message_if_failed",
  "deliveryTime": "2024-01-01T00:00:00Z",
  "responseTime": 150, // milliseconds
  "retryCount": 0,
  "createdAt": "2024-01-01T00:00:00Z"
}

Analytics Collection

{
  "_id": "ObjectId",
  "date": "2024-01-01",
  "platform": "ios|android|web",
  "metrics": {
    "totalSent": 1000,
    "totalDelivered": 950,
    "totalFailed": 50,
    "averageResponseTime": 120,
    "successRate": 0.95
  },
  "hourlyBreakdown": [
    {
      "hour": 0,
      "sent": 100,
      "delivered": 95,
      "failed": 5
    }
  ],
  "createdAt": "2024-01-01T00:00:00Z"
}

📈 Performance & Optimization

Rate Limiting

// Platform-specific rate limits
const RATE_LIMITS = {
  ios: {
    requestsPerSecond: 1000,
    batchSize: 1000,
    retryDelay: 1000
  },
  android: {
    requestsPerSecond: 1000,
    batchSize: 500,
    retryDelay: 500
  },
  web: {
    requestsPerSecond: 100,
    batchSize: 100,
    retryDelay: 2000
  }
};

// Rate limiting implementation
const rateLimiter = new Map();

function checkRateLimit(platform) {
  const now = Date.now();
  const limit = RATE_LIMITS[platform];

  if (!rateLimiter.has(platform)) {
    rateLimiter.set(platform, []);
  }

  const requests = rateLimiter.get(platform);
  const validRequests = requests.filter(time => now - time < 1000);

  if (validRequests.length >= limit.requestsPerSecond) {
    return false;
  }

  validRequests.push(now);
  rateLimiter.set(platform, validRequests);
  return true;
}

Token Validation & Refresh

// Token validation
async function validateDeviceToken(token, platform) {
  const cacheKey = `token:${token}`;

  // Check cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // Validate with platform
  let isValid = false;
  try {
    switch (platform) {
      case 'ios':
        isValid = await validateAPNsToken(token);
        break;
      case 'android':
        isValid = await validateFCMToken(token);
        break;
      case 'web':
        isValid = await validateWebPushToken(token);
        break;
    }
  } catch (error) {
    console.error('Token validation error:', error);
    isValid = false;
  }

  // Cache result
  await redis.setex(cacheKey, 3600, JSON.stringify(isValid));

  return isValid;
}

// Token refresh
async function refreshDeviceToken(oldToken, newToken, platform) {
  // Update database
  await DeviceToken.findOneAndUpdate(
    { token: oldToken },
    {
      token: newToken,
      updatedAt: new Date()
    }
  );

  // Invalidate cache
  await redis.del(`token:${oldToken}`);
  await redis.del(`token:${newToken}`);

  // Update analytics
  await updateTokenRefreshStats(platform);
}

Batch Optimization

// Batch size optimization
function getOptimalBatchSize(platform, notificationCount) {
  const baseSize = RATE_LIMITS[platform].batchSize;
  const maxSize = Math.min(baseSize, notificationCount);

  // Adjust based on current load
  const currentLoad = getCurrentLoad(platform);
  if (currentLoad > 0.8) {
    return Math.floor(maxSize * 0.5);
  }

  return maxSize;
}

// Parallel processing
async function sendBatchParallel(notifications, platform) {
  const batchSize = getOptimalBatchSize(platform, notifications.length);
  const batches = chunk(notifications, batchSize);

  const promises = batches.map(batch =>
    sendBatchToPlatform(platform, batch)
  );

  const results = await Promise.allSettled(promises);
  return results.flatMap(result =>
    result.status === 'fulfilled' ? result.value : []
  );
}

🔍 Monitoring & Analytics

Real-time Metrics

// Prometheus metrics
const prometheus = require('prom-client');

const notificationCounter = new prometheus.Counter({
  name: 'push_notifications_total',
  help: 'Total number of push notifications sent',
  labelNames: ['platform', 'status']
});

const deliveryLatency = new prometheus.Histogram({
  name: 'push_delivery_latency_seconds',
  help: 'Push notification delivery latency',
  labelNames: ['platform'],
  buckets: [0.1, 0.5, 1, 2, 5, 10]
});

// Record metrics
async function sendNotification(notification) {
  const startTime = Date.now();

  try {
    const result = await sendToPlatform(notification);

    // Record success
    notificationCounter.labels(notification.platform, 'success').inc();
    deliveryLatency.labels(notification.platform)
      .observe((Date.now() - startTime) / 1000);

    return result;
  } catch (error) {
    // Record failure
    notificationCounter.labels(notification.platform, 'failed').inc();
    throw error;
  }
}

Health Checks

# Service health check
GET /health

# Response format
{
  "status": "healthy",
  "timestamp": "2024-01-01T00:00:00Z",
  "version": "1.0.0",
  "services": {
    "fcm": "connected",
    "apns": "connected",
    "redis": "connected",
    "mongodb": "connected"
  },
  "metrics": {
    "totalNotifications": 10000,
    "successRate": 0.95,
    "averageLatency": 120
  }
}

Error Tracking

// Error categorization
const ERROR_CATEGORIES = {
  INVALID_TOKEN: 'invalid_token',
  RATE_LIMIT: 'rate_limit',
  NETWORK_ERROR: 'network_error',
  PLATFORM_ERROR: 'platform_error',
  TIMEOUT: 'timeout'
};

// Error handling
async function handleDeliveryError(notification, error) {
  const errorCategory = categorizeError(error);

  // Log error
  console.error('Push delivery error:', {
    notificationId: notification.id,
    deviceToken: notification.deviceToken,
    platform: notification.platform,
    error: error.message,
    category: errorCategory
  });

  // Update analytics
  await updateErrorStats(notification.platform, errorCategory);

  // Handle specific errors
  if (errorCategory === ERROR_CATEGORIES.INVALID_TOKEN) {
    await handleInvalidToken(notification.deviceToken, notification.platform);
  }

  // Send to monitoring
  if (process.env.SENTRY_DSN) {
    Sentry.captureException(error, {
      tags: {
        platform: notification.platform,
        errorCategory
      },
      extra: {
        notification
      }
    });
  }
}

🚀 Deployment

Docker Configuration

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

CMD ["npm", "start"]

Docker Compose

# docker-compose.yml
version: '3.8'
services:
  push-sender:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - REDIS_HOST=redis
      - MONGODB_URI=mongodb://mongo:27017/push_sender
    depends_on:
      - redis
      - mongo
    networks:
      - appgain-net
    deploy:
      replicas: 3

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    networks:
      - appgain-net

  mongo:
    image: mongo:6.0
    volumes:
      - mongo-data:/data/db
    networks:
      - appgain-net

volumes:
  redis-data:
  mongo-data:

networks:
  appgain-net:
    driver: bridge

Kubernetes Deployment

# k8s-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: push-sender
spec:
  replicas: 3
  selector:
    matchLabels:
      app: push-sender
  template:
    metadata:
      labels:
        app: push-sender
    spec:
      containers:
      - name: push-sender
        image: appgain/push-sender:latest
        env:
        - name: NODE_ENV
          value: "production"
        - name: REDIS_HOST
          value: "redis-cluster"
        - name: MONGODB_URI
          value: "mongodb://mongo-cluster:27017/push_sender"
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "1Gi"
            cpu: "1000m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5

🔧 Development

Local Development Setup

# Install dependencies
npm install

# Environment setup
cp .env.example .env
# Edit .env with local configuration

# Start development server
npm run dev

# Run tests
npm test

# Run linting
npm run lint

Testing Push Notifications

// Test notification sending
const testNotification = {
  deviceToken: 'test_device_token',
  platform: 'ios',
  title: 'Test Notification',
  body: 'This is a test notification',
  data: {
    test_key: 'test_value'
  }
};

// Send test notification
const result = await sendNotification(testNotification);
console.log('Test result:', result);

// Test batch sending
const testBatch = Array(10).fill().map((_, i) => ({
  deviceToken: `test_token_${i}`,
  platform: 'android',
  title: `Test ${i}`,
  body: `Test notification ${i}`
}));

const batchResult = await sendBatchNotifications(testBatch);
console.log('Batch result:', batchResult);

🔒 Security

Token Security

// Token encryption
const crypto = require('crypto');

function encryptToken(token) {
  const algorithm = 'aes-256-cbc';
  const key = crypto.scryptSync(process.env.ENCRYPTION_KEY, 'salt', 32);
  const iv = crypto.randomBytes(16);

  const cipher = crypto.createCipher(algorithm, key);
  let encrypted = cipher.update(token, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  return iv.toString('hex') + ':' + encrypted;
}

function decryptToken(encryptedToken) {
  const algorithm = 'aes-256-cbc';
  const key = crypto.scryptSync(process.env.ENCRYPTION_KEY, 'salt', 32);

  const [ivHex, encrypted] = encryptedToken.split(':');
  const iv = Buffer.from(ivHex, 'hex');

  const decipher = crypto.createDecipher(algorithm, key);
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

Input Validation

// Notification validation
const Joi = require('joi');

const notificationSchema = Joi.object({
  deviceToken: Joi.string().required(),
  platform: Joi.string().valid('ios', 'android', 'web').required(),
  title: Joi.string().max(100).required(),
  body: Joi.string().max(2000).required(),
  data: Joi.object().optional(),
  options: Joi.object({
    badge: Joi.number().integer().min(0),
    sound: Joi.string(),
    priority: Joi.string().valid('high', 'normal', 'low')
  }).optional()
});

function validateNotification(notification) {
  const { error, value } = notificationSchema.validate(notification);
  if (error) {
    throw new Error(`Invalid notification: ${error.message}`);
  }
  return value;
}

📞 Support & Resources

Documentation

Monitoring Tools

  • Sentry: Error tracking and performance monitoring
  • Prometheus: Metrics collection and alerting
  • Grafana: Dashboard visualization
  • Custom Analytics: Delivery tracking and reporting

Development Resources

  • GitHub Repository: Source code and issues
  • Postman Collection: API testing
  • Swagger Documentation: Interactive API docs
  • Development Environment: Docker setup

Last updated: January 2024

Ask Chehab GPT