Core Platform18 min read

Production Architecture: Multi-Tenant & Monitoring

Build production-ready applications with multi-tenant isolation, usage monitoring, webhooks, error handling, and scaling patterns.

Taking Graphlit from prototype to production requires understanding multi-tenancy, monitoring, error handling, and cost control. This guide covers production patterns used by real applications serving thousands of users.

What You'll Learn

  • Multi-tenant architecture patterns
  • Usage monitoring and analytics
  • Webhooks for real-time events
  • Error handling and retry strategies
  • Cost monitoring and optimization
  • Scaling patterns
  • Security best practices

Prerequisites: A Graphlit project, production deployment experience.

Developer Note: All Graphlit IDs are GUIDs. Example outputs show realistic GUID format.


Part 1: Multi-Tenant Architecture

Pattern 1: Project Per Tenant (Recommended)

Each customer gets their own Graphlit project. Complete isolation.

// Tenant mapping (store in your database)
interface Tenant {
  tenantId: string;
  graphlitOrganizationId: string;
  graphlitEnvironmentId: string;
  graphlitProjectId: string;
}

// Initialize Graphlit client per tenant
function getGraphlitForTenant(tenant: Tenant) {
  return new Graphlit(
    tenant.graphlitOrganizationId,
    tenant.graphlitEnvironmentId,
    tenant.graphlitProjectId
  );
}

// In your API
app.post('/api/search', async (req, res) => {
  const tenantId = req.user.tenantId;
  const tenant = await db.getTenant(tenantId);
  
  const graphlit = getGraphlitForTenant(tenant);
  const results = await graphlit.queryContents({ search: req.body.query });
  
  res.json(results);
});

Benefits:

  • Complete data isolation
  • Per-tenant scaling
  • Independent billing
  • Tenant deletion is simple

Drawbacks:

  • More projects to manage
  • Slightly higher overhead

Pattern 2: Collections Per Tenant

All tenants share one project, separated by collections.

// Tenant → Collection mapping
const tenantCollections = new Map<string, string>();

async function getTenantCollection(tenantId: string) {
  if (!tenantCollections.has(tenantId)) {
    const collection = await graphlit.createCollection(`Tenant_${tenantId}`);
    tenantCollections.set(tenantId, collection.createCollection.id);
  }
  return tenantCollections.get(tenantId)!;
}

// Ingest content to tenant's collection
async function ingestForTenant(tenantId: string, uri: string) {
  const collectionId = await getTenantCollection(tenantId);
  
  return graphlit.ingestUri(
    uri,
    undefined,
    undefined,
    undefined,
    [{ id: collectionId }]
  );
}

// Query tenant's content only
async function searchForTenant(tenantId: string, query: string) {
  const collectionId = await getTenantCollection(tenantId);
  
  return graphlit.queryContents({
    search: query,
    filter: {
      collections: [{ id: collectionId }]
    }
  });
}

Benefits:

  • Single project to manage
  • Shared infrastructure

Drawbacks:

  • Less isolation (accidental cross-tenant queries possible)
  • Harder to delete tenant data
  • All tenants on same plan

Recommendation: Use Pattern 1 (project per tenant) unless you have strong reasons otherwise.


Part 2: Usage Monitoring

Track API Calls

import { createLogger } from './logger';

const logger = createLogger('GraphlitAPI');

// Wrap Graphlit calls with monitoring
async function monitoredQuery(tenantId: string, query: string) {
  const startTime = Date.now();
  
  try {
    const results = await graphlit.queryContents({ search: query });
    
    // Log successful call
    const duration = Date.now() - startTime;
    logger.log('Search successful', {
      tenantId,
      query,
      resultCount: results.contents.results.length,
      duration
    });
    
    // Track usage in database
    await db.trackUsage({
      tenantId,
      operation: 'search',
      timestamp: new Date(),
      duration,
      status: 'success'
    });
    
    return results;
  } catch (error) {
    logger.error('Search failed', { tenantId, query, error });
    
    await db.trackUsage({
      tenantId,
      operation: 'search',
      timestamp: new Date(),
      duration: Date.now() - startTime,
      status: 'error'
    });
    
    throw error;
  }
}

Usage Analytics Dashboard

// Generate usage report
async function getUsageReport(tenantId: string, dateRange: { from: Date; to: Date }) {
  const usage = await db.getUsage({
    tenantId,
    from: dateRange.from,
    to: dateRange.to
  });
  
  return {
    totalSearches: usage.filter(u => u.operation === 'search').length,
    totalIngestions: usage.filter(u => u.operation === 'ingest').length,
    totalConversations: usage.filter(u => u.operation === 'conversation').length,
    averageLatency: usage.reduce((sum, u) => sum + u.duration, 0) / usage.length,
    errorRate: usage.filter(u => u.status === 'error').length / usage.length
  };
}

Graphlit Project Usage API

// Get usage from Graphlit
const usage = await graphlit.queryProjectUsage({
  from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
  to: new Date().toISOString()
});

console.log('Usage:', {
  totalQueries: usage.project.usage.totalQueries,
  totalIngestions: usage.project.usage.totalIngestions,
  totalTokens: usage.project.usage.totalTokens,
  estimatedCost: usage.project.usage.estimatedCost
});

Part 3: Webhooks

Configure Webhooks

// Set up webhook when creating feeds
const feed = await graphlit.createFeed({
  name: 'My Feed',
  type: FeedServiceTypes.Rss,
  rss: { uri: 'https://example.com/feed' },
  webhookUrl: 'https://yourapp.com/webhooks/graphlit'
});

Webhook Handler (Express.js)

import express from 'express';
import crypto from 'crypto';

const app = express();
app.use(express.json());

// Verify webhook signature
function verifySignature(req: express.Request): boolean {
  const signature = req.headers['x-graphlit-signature'] as string;
  const body = JSON.stringify(req.body);
  
  const hmac = crypto.createHmac('sha256', process.env.GRAPHLIT_WEBHOOK_SECRET!);
  const expectedSignature = hmac.update(body).digest('hex');
  
  return signature === expectedSignature;
}

// Webhook endpoint
app.post('/webhooks/graphlit', async (req, res) => {
  // Verify signature
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid signature');
  }
  
  const event = req.body;
  
  switch (event.type) {
    case 'content.done':
      // Content finished processing
      console.log('Content ready:', event.contentId);
      await onContentReady(event.contentId);
      break;
      
    case 'content.failed':
      // Content processing failed
      console.error('Content failed:', event.contentId, event.error);
      await onContentFailed(event.contentId, event.error);
      break;
      
    case 'feed.done':
      // Feed finished syncing
      console.log('Feed synced:', event.feedId);
      await onFeedSynced(event.feedId);
      break;
  }
  
  // Always return 200 quickly
  res.sendStatus(200);
});

async function onContentReady(contentId: string) {
  // Fetch content details
  const content = await graphlit.getContent(contentId);
  
  // Index in your database
  await db.indexContent({
    id: contentId,
    name: content.content.name,
    type: content.content.type,
    createdAt: content.content.creationDate
  });
  
  // Trigger notifications
  await notifyUsers(`New content: ${content.content.name}`);
}

Webhook event types:

  • content.done - Content indexed
  • content.failed - Content processing failed
  • feed.done - Feed sync completed
  • feed.failed - Feed sync failed

Part 4: Error Handling

Retry with Exponential Backoff

async function retryOperation<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error: any) {
      if (attempt === maxRetries) throw error;
      
      // Check if error is retryable
      if (error.message?.includes('rate limit')) {
        const delay = Math.pow(2, attempt) * 1000;
        console.log(`Rate limited, retrying in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
      } else {
        // Non-retryable error
        throw error;
      }
    }
  }
  throw new Error('Max retries exceeded');
}

// Usage
const results = await retryOperation(() => 
  graphlit.queryContents({ search: 'query' })
);

Circuit Breaker Pattern

class CircuitBreaker {
  private failures = 0;
  private lastFailTime = 0;
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
  
  constructor(
    private threshold = 5,
    private timeout = 60000
  ) {}
  
  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailTime > this.timeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }
    
    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  private onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }
  
  private onFailure() {
    this.failures++;
    this.lastFailTime = Date.now();
    
    if (this.failures >= this.threshold) {
      this.state = 'OPEN';
      console.error('Circuit breaker opened after', this.failures, 'failures');
    }
  }
}

// Usage
const breaker = new CircuitBreaker();
const results = await breaker.execute(() => 
  graphlit.queryContents({ search: 'query' })
);

Part 5: Cost Monitoring

Track Model Usage

// Track conversation costs
async function monitoredPrompt(prompt: string, conversationId?: string) {
  const response = await graphlit.promptConversation(prompt, conversationId);
  
  const tokens = response.promptConversation?.message?.tokens || 0;
  const estimatedCost = calculateCost(tokens, 'gpt-4o');
  
  await db.trackCost({
    operation: 'conversation',
    tokens,
    estimatedCost,
    model: 'gpt-4o',
    timestamp: new Date()
  });
  
  return response;
}

function calculateCost(tokens: number, model: string): number {
  const costs: Record<string, { input: number; output: number }> = {
    'gpt-4o': { input: 2.5, output: 10 },  // per 1M tokens
    'gpt-4o-mini': { input: 0.15, output: 0.6 },
    'claude-3-5-sonnet': { input: 3, output: 15 }
  };
  
  // Assuming 80% input, 20% output (rough estimate)
  const inputTokens = tokens * 0.8;
  const outputTokens = tokens * 0.2;
  
  const modelCost = costs[model];
  return (inputTokens * modelCost.input + outputTokens * modelCost.output) / 1_000_000;
}

Budget Alerts

async function checkBudget(tenantId: string) {
  const usage = await db.getTenantUsage(tenantId, {
    from: new Date(new Date().setDate(1))  // Month start
  });
  
  const totalCost = usage.reduce((sum, u) => sum + u.estimatedCost, 0);
  const tenant = await db.getTenant(tenantId);
  
  if (totalCost > tenant.monthlyBudget * 0.8) {
    // 80% of budget used
    await sendAlert({
      tenantId,
      message: `80% of monthly budget used ($${totalCost} / $${tenant.monthlyBudget})`,
      severity: 'warning'
    });
  }
  
  if (totalCost > tenant.monthlyBudget) {
    // Budget exceeded
    await sendAlert({
      tenantId,
      message: `Monthly budget exceeded! ($${totalCost} / $${tenant.monthlyBudget})`,
      severity: 'critical'
    });
    
    // Optionally disable tenant
    await db.disableTenant(tenantId);
  }
}

Part 6: Security Best Practices

API Key Management

// DON'T: Expose API keys in frontend
const badClient = new Graphlit(orgId, envId, projectId);  // ❌

// DO: Proxy through your backend
app.post('/api/search', async (req, res) => {
  // Verify user authentication
  const user = await verifyAuth(req.headers.authorization);
  
  // Get tenant's Graphlit credentials from secure storage
  const tenant = await db.getTenant(user.tenantId);
  
  // Initialize Graphlit with tenant credentials
  const graphlit = getGraphlitForTenant(tenant);
  
  // Execute query
  const results = await graphlit.queryContents({ search: req.body.query });
  res.json(results);
});

Rate Limiting

import rateLimit from 'express-rate-limit';

// Per-tenant rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,  // 100 requests per window per tenant
  keyGenerator: (req) => req.user.tenantId
});

app.use('/api/', limiter);

Input Validation

import { z } from 'zod';

// Validate search queries
const searchSchema = z.object({
  query: z.string().min(1).max(500),
  limit: z.number().int().min(1).max(100).optional()
});

app.post('/api/search', async (req, res) => {
  try {
    const { query, limit } = searchSchema.parse(req.body);
    
    const results = await graphlit.queryContents({
      search: query,
      limit: limit || 10
    });
    
    res.json(results);
  } catch (error) {
    if (error instanceof z.ZodError) {
      return res.status(400).json({ error: 'Invalid input', details: error.errors });
    }
    throw error;
  }
});

Part 7: Scaling Patterns

Caching

import { createClient } from 'redis';

const redis = createClient();

// Cache search results
async function cachedSearch(query: string, ttl = 300) {
  const cacheKey = `search:${query}`;
  
  // Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Query Graphlit
  const results = await graphlit.queryContents({ search: query });
  
  // Cache for 5 minutes
  await redis.setEx(cacheKey, ttl, JSON.stringify(results));
  
  return results;
}

Connection Pooling

// Reuse Graphlit clients per tenant
const clientPool = new Map<string, Graphlit>();

function getOrCreateClient(tenant: Tenant): Graphlit {
  const key = `${tenant.organizationId}-${tenant.environmentId}-${tenant.projectId}`;
  
  if (!clientPool.has(key)) {
    clientPool.set(key, new Graphlit(
      tenant.organizationId,
      tenant.environmentId,
      tenant.projectId
    ));
  }
  
  return clientPool.get(key)!;
}

Background Jobs

import Bull from 'bull';

// Queue for async ingestion
const ingestionQueue = new Bull('ingestion');

ingestionQueue.process(async (job) => {
  const { tenantId, uri } = job.data;
  
  const tenant = await db.getTenant(tenantId);
  const graphlit = getGraphlitForTenant(tenant);
  
  await graphlit.ingestUri(uri);
});

// Add jobs from API
app.post('/api/ingest', async (req, res) => {
  await ingestionQueue.add({
    tenantId: req.user.tenantId,
    uri: req.body.uri
  });
  
  res.json({ message: 'Ingestion queued' });
});

What's Next?

You now understand production architecture. You've completed all 8 Core Platform guides!

Next:

  • Explore Specialized Deep Dive guides for specific use cases
  • Review Advanced Topics for optimization strategies

Congrats on completing the Core Platform guides! 🎉

Ready to Build with Graphlit?

Start building AI-powered applications with our API-first platform. Free tier includes 100 credits/month — no credit card required.

No credit card required • 5 minutes to first API call

Production Architecture: Multi-Tenant & Monitoring | Graphlit Developer Guides