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 indexedcontent.failed- Content processing failedfeed.done- Feed sync completedfeed.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! 🎉