Request Logging & Monitoring
Track all HTTP requests with OpenTelemetry and OpenObserve for audit, analytics, and performance monitoring
Request Logging & Monitoring
ProductReady includes a centralized request logging system powered by OpenTelemetry and OpenObserve. Track every HTTP request (API calls, page views, tRPC, OpenAPI) for audit trails, analytics, and performance monitoring.
Privacy-aware: Logs only metadata (method, path, status, duration) — no request bodies or sensitive data.
Why Request Logging?
Use Cases
- Audit Trails: Track who accessed what and when (GDPR/HIPAA compliance)
- Performance Monitoring: Find slow endpoints and optimize response times
- User Analytics: Understand how users interact with your app
- Security: Detect suspicious activity (failed login attempts, rate limiting)
- Debugging: Trace errors back to specific requests
What Gets Logged
Each request logs:
- User ID (if authenticated) or "anonymous"
- Session ID (from Better Auth)
- IP Address (extracted from Cloudflare/Vercel headers)
- User Agent (browser/device info)
- HTTP Method (GET, POST, PUT, DELETE, etc.)
- URL Path (
/api/users/123) - Status Code (200, 404, 500, etc.)
- Duration (response time in milliseconds)
- Timestamp (ISO 8601 format)
What's Excluded
Auto-filtered paths (no logging):
- Static assets:
/_next/,*.ico,*.png,*.css,*.js - Health checks:
/health,/ping - Auth callbacks:
/api/auth/callback/*
Architecture
┌─────────────┐
│ Request │
└──────┬──────┘
│
▼
┌─────────────────────┐
│ Next.js Middleware │ (proxy.ts → withRequestLogging)
└──────┬──────────────┘
│
┌───┴───┐
│ │
▼ ▼
Traces Logs
(OTel) (HTTP POST)
│ │
▼ ▼
┌─────────────────────┐
│ OpenObserve │ http://localhost:5080
│ (OLAP Database) │ Traces + Logs + Dashboards
└─────────────────────┘Dual pipeline:
- Traces (OTel SDK → OTLP): Distributed tracing, performance spans
- Logs (Direct HTTP →
/_json): Structured request audit logs, searchable
Key benefits:
- ✅ Non-blocking: Logs are buffered and sent asynchronously (<1ms impact)
- ✅ Scalable: Handles 100k+ requests/day
- ✅ Flexible: Switch to Grafana Tempo, Jaeger, or any OTLP endpoint
- ✅ Cost-effective: OLAP storage is cheaper than PostgreSQL for logs
Quick Start
Start OpenObserve
# Start all services including OpenObserve
make db
# Or: docker-compose up -d
# Verify it's running
curl http://localhost:5080/healthz
# {"status":"ok"}Enable Request Logging
In your .env file:
# Enable request logging (disabled by default)
ENABLE_REQUEST_LOGGING="true"
# OpenObserve endpoint
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
# serviceName from appConfig.name, version from package.json — no extra env vars
# OpenObserve authentication
OPENOBSERVE_EMAIL="admin@productready.dev"
OPENOBSERVE_PASSWORD="productready"Restart Your App
pnpm dev
# You should see:
# [OTelLogger] ✅ Initialized (serviceName=productready)View Logs in OpenObserve
- Open: http://localhost:5080
- Login:
admin@productready.dev/productready - Navigate to: Logs → Search
- Select stream:
productready_request_logs
Viewing Logs in OpenObserve
Basic Queries
http_response_status_code > 0Shows all logged requests with their status codes.
http_response_status_code >= 500Filter for server errors (5xx status codes).
http_duration_ms > 1000Find requests taking longer than 1 second.
user_id = 'user_abc123'Track all requests from a specific user.
Advanced Queries
Requests to specific endpoint:
url_path LIKE '/api/users/%'Anonymous vs Authenticated:
user_id = 'anonymous' -- Anonymous users
user_id != 'anonymous' -- Authenticated usersRequests by IP address:
client_address = '203.0.113.42'Time-based filtering:
@timestamp > now() - 1h -- Last hour
@timestamp > now() - 24h -- Last 24 hoursOpenObserve Dashboard
Creating Dashboards
- Go to Dashboards → Create Dashboard
- Add panels with queries:
- Request Count:
count() by http_request_method - Error Rate:
count(http_response_status_code >= 500) / count() * 100 - Avg Response Time:
avg(http_duration_ms) - Top Endpoints:
count() by url_path
- Request Count:
Setting Up Alerts
- Go to Alerts → Create Alert
- Example: Alert on high error rate
count(http_response_status_code >= 500) > 10 - Configure notification (Slack, Email, Webhook)
Configuration
Environment Variables
| Variable | Description | Default | Required |
|---|---|---|---|
ENABLE_REQUEST_LOGGING | Enable/disable logging | false | No |
OTEL_EXPORTER_OTLP_ENDPOINT | OpenObserve endpoint | http://localhost:5080/api/default | Yes |
OPENOBSERVE_EMAIL | OpenObserve login email | admin@productready.dev | Yes |
OPENOBSERVE_PASSWORD | OpenObserve password | productready | Yes |
OPENOBSERVE_ORG_ID | Organization ID | default | No |
Note:
serviceNamefromappConfig.name,serviceVersionfrompackage.json— no extra env vars needed.
Custom Filtering
To exclude additional paths, edit packages/share-domains/observability/logic/otel-logger.ts:
export function shouldLogPath(path: string): boolean {
// Add your custom exclusions
if (path.startsWith("/internal/")) return false;
if (path.startsWith("/webhooks/")) return false;
// Default exclusions...
return true;
}Production Deployment
Option 1: Self-Hosted OpenObserve
Deploy OpenObserve on your own infrastructure:
# docker-compose.prod.yml
services:
openobserve:
image: openobserve/openobserve:v0.14.0
restart: always
ports:
- '5080:5080'
volumes:
- openobserve_data:/data
environment:
ZO_DATA_DIR: /data
ZO_ROOT_USER_EMAIL: ${OPENOBSERVE_EMAIL}
ZO_ROOT_USER_PASSWORD: ${OPENOBSERVE_PASSWORD}
mem_limit: 4gOption 2: OpenObserve Cloud
Use the managed service:
- Sign up at openobserve.ai
- Get your endpoint:
https://api.openobserve.ai/api/default - Update
.env:OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/default" OPENOBSERVE_EMAIL="your-email@example.com" OPENOBSERVE_PASSWORD="your-password"
Option 3: Switch to Grafana Tempo
OpenTelemetry supports any OTLP backend:
# Grafana Cloud
OTEL_EXPORTER_OTLP_ENDPOINT="https://tempo-prod-04-prod-us-east-0.grafana.net/tempo"
# Self-hosted Grafana Tempo
OTEL_EXPORTER_OTLP_ENDPOINT="http://tempo:4318/v1/traces"Performance Impact
Benchmarks
| Metric | Value |
|---|---|
| Logging overhead | <1ms per request (buffered) |
| Max throughput | 100k+ requests/day |
| Memory usage | ~50MB (batch buffers) |
| Log batch flush | 20 records or 5s interval |
| Trace batch flush | 512 spans or 5s interval |
Optimization Tips
-
Batch size: Default is 512 logs per 5 seconds. Adjust in
otel-logger.ts:maxQueueSize: 1024, maxExportBatchSize: 256, scheduledDelayMillis: 3000, -
Sampling: For very high traffic, sample requests:
if (Math.random() > 0.1) return handler(req, ev); // 10% sampling -
Field reduction: Remove unnecessary fields to reduce payload size.
Troubleshooting
Check 1: Is logging enabled?
echo $ENABLE_REQUEST_LOGGING
# Should output: trueCheck 2: Is OpenObserve running?
curl http://localhost:5080/healthz
# Should return: {"status":"ok"}Check 3: Console logs
pnpm dev
# Should see: [OTelLogger] Initialized with endpoint: ...Check 4: Make test request
curl http://localhost:3000/
# Then check OpenObserve UIReduce batch size in otel-logger.ts:
maxQueueSize: 512, // Reduce from 2048
maxExportBatchSize: 128, // Reduce from 512Enable sampling for high-traffic apps:
// Log only 10% of requests
if (Math.random() > 0.1) return;Error: ECONNREFUSED
OpenObserve is not running. Start it with:
make db # or: docker-compose up -dError: 401 Unauthorized
Check OPENOBSERVE_EMAIL and OPENOBSERVE_PASSWORD in .envError: 'Search field not found'
OpenObserve flattens dotted keys to underscores.
Use user_id instead of "user.id" in queries.
Check actual field names in OpenObserve UI → stream schema.Privacy & Compliance
GDPR Compliance
Request logging is opt-in (disabled by default). To comply with GDPR:
- Inform users: Add to your privacy policy
- Retention policy: Delete logs after 30-90 days
- Right to deletion: Provide endpoint to delete user logs
- Anonymize IPs: Hash IP addresses before logging (optional)
Data Minimization
Only essential metadata is logged:
- ❌ No request bodies
- ❌ No response bodies
- ❌ No cookies (except session ID)
- ❌ No passwords or API keys
- ✅ Only: method, path, status, duration, user ID
IP Anonymization
To anonymize IPs, edit ip-utils.ts:
export function extractClientIP(headers: Headers): string | null {
const ip = headers.get("cf-connecting-ip") || headers.get("x-real-ip");
if (!ip) return null;
// Anonymize: Replace last octet with 0
return ip.replace(/\.\d+$/, ".0"); // 203.0.113.42 → 203.0.113.0
}OpenObserve Field Name Mapping
OpenObserve v0.14.0 flattens dotted JSON keys to underscores. Use the flattened names when querying.
| JSON Key (sent by logger) | OpenObserve Column (use in queries) |
|---|---|
http.request.method | http_request_method |
http.response.status_code | http_response_status_code |
url.path | url_path |
http.duration_ms | http_duration_ms |
user.id | user_id |
user.session_id | user_session_id |
client.address | client_address |
user_agent.original | user_agent_original |
service.name | service_name |
Next Steps
Performance Monitoring
Learn how to use request logs for performance optimization
Error Handling
Set up alerts for errors using OpenObserve
Analytics Integration
Combine request logs with user analytics