feat: Implement Redis configuration for rate limiting, enhance health check endpoints, and add monitoring dashboard
This commit is contained in:
parent
030df3e8e0
commit
0c3fe480fd
32
=3.2.0
32
=3.2.0
@ -1,32 +0,0 @@
|
||||
Requirement already satisfied: langfuse in ./venv/lib/python3.12/site-packages (3.1.3)
|
||||
Requirement already satisfied: backoff>=1.10.0 in ./venv/lib/python3.12/site-packages (from langfuse) (2.2.1)
|
||||
Requirement already satisfied: httpx<1.0,>=0.15.4 in ./venv/lib/python3.12/site-packages (from langfuse) (0.27.0)
|
||||
Requirement already satisfied: opentelemetry-api<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
|
||||
Requirement already satisfied: opentelemetry-exporter-otlp<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
|
||||
Requirement already satisfied: opentelemetry-sdk<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
|
||||
Requirement already satisfied: packaging<25.0,>=23.2 in ./venv/lib/python3.12/site-packages (from langfuse) (24.2)
|
||||
Requirement already satisfied: pydantic<3.0,>=1.10.7 in ./venv/lib/python3.12/site-packages (from langfuse) (2.9.0)
|
||||
Requirement already satisfied: requests<3,>=2 in ./venv/lib/python3.12/site-packages (from langfuse) (2.32.4)
|
||||
Requirement already satisfied: wrapt<2.0,>=1.14 in ./venv/lib/python3.12/site-packages (from langfuse) (1.17.2)
|
||||
Requirement already satisfied: anyio in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (4.9.0)
|
||||
Requirement already satisfied: certifi in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (2025.6.15)
|
||||
Requirement already satisfied: httpcore==1.* in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (1.0.9)
|
||||
Requirement already satisfied: idna in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (3.10)
|
||||
Requirement already satisfied: sniffio in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (1.3.1)
|
||||
Requirement already satisfied: h11>=0.16 in ./venv/lib/python3.12/site-packages (from httpcore==1.*->httpx<1.0,>=0.15.4->langfuse) (0.16.0)
|
||||
Requirement already satisfied: importlib-metadata<8.8.0,>=6.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-api<2.0.0,>=1.33.1->langfuse) (8.7.0)
|
||||
Requirement already satisfied: typing-extensions>=4.5.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-api<2.0.0,>=1.33.1->langfuse) (4.14.1)
|
||||
Requirement already satisfied: opentelemetry-exporter-otlp-proto-grpc==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
||||
Requirement already satisfied: opentelemetry-exporter-otlp-proto-http==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
||||
Requirement already satisfied: googleapis-common-protos~=1.52 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.70.0)
|
||||
Requirement already satisfied: grpcio<2.0.0,>=1.63.2 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.73.1)
|
||||
Requirement already satisfied: opentelemetry-exporter-otlp-proto-common==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
||||
Requirement already satisfied: opentelemetry-proto==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
||||
Requirement already satisfied: protobuf<6.0,>=5.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-proto==1.34.1->opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (5.29.5)
|
||||
Requirement already satisfied: opentelemetry-semantic-conventions==0.55b1 in ./venv/lib/python3.12/site-packages (from opentelemetry-sdk<2.0.0,>=1.33.1->langfuse) (0.55b1)
|
||||
Requirement already satisfied: annotated-types>=0.4.0 in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (0.7.0)
|
||||
Requirement already satisfied: pydantic-core==2.23.2 in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (2.23.2)
|
||||
Requirement already satisfied: tzdata in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (2025.2)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in ./venv/lib/python3.12/site-packages (from requests<3,>=2->langfuse) (3.4.2)
|
||||
Requirement already satisfied: urllib3<3,>=1.21.1 in ./venv/lib/python3.12/site-packages (from requests<3,>=2->langfuse) (2.5.0)
|
||||
Requirement already satisfied: zipp>=3.20 in ./venv/lib/python3.12/site-packages (from importlib-metadata<8.8.0,>=6.0->opentelemetry-api<2.0.0,>=1.33.1->langfuse) (3.23.0)
|
||||
87
README.md
87
README.md
@ -50,4 +50,89 @@ The following events are tracked:
|
||||
- Validation errors
|
||||
|
||||
### Viewing Data
|
||||
Visit your Langfuse dashboard to view the collected metrics and traces.
|
||||
Visit your Langfuse dashboard to view the collected metrics and traces.
|
||||
|
||||
## Deployment Guide
|
||||
|
||||
### Redis Configuration
|
||||
The application requires Redis for caching and queue management. Configure Redis in `application.yml`:
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
```
|
||||
|
||||
Environment variables can also be used:
|
||||
```bash
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=""
|
||||
REDIS_DB=0
|
||||
```
|
||||
|
||||
### Worker Process Management
|
||||
The application uses Celery for background task processing. Configure workers in `application.yml`:
|
||||
|
||||
```yaml
|
||||
celery:
|
||||
workers: 4
|
||||
concurrency: 2
|
||||
max_tasks_per_child: 100
|
||||
```
|
||||
|
||||
Start workers with:
|
||||
```bash
|
||||
celery -A jira-webhook-llm worker --loglevel=info
|
||||
```
|
||||
|
||||
### Monitoring Setup
|
||||
The application provides Prometheus metrics endpoint at `/metrics`. Configure monitoring:
|
||||
|
||||
1. Add Prometheus scrape config:
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'jira-webhook-llm'
|
||||
static_configs:
|
||||
- targets: ['localhost:8000']
|
||||
```
|
||||
|
||||
2. Set up Grafana dashboard using the provided template
|
||||
|
||||
### Rate Limiting
|
||||
Rate limiting is configured in `application.yml`:
|
||||
|
||||
```yaml
|
||||
rate_limiting:
|
||||
enabled: true
|
||||
requests_per_minute: 60
|
||||
burst_limit: 100
|
||||
```
|
||||
|
||||
### Health Check Endpoint
|
||||
The application provides a health check endpoint at `/health` that returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "OK",
|
||||
"timestamp": "2025-07-14T01:59:42Z",
|
||||
"components": {
|
||||
"database": "OK",
|
||||
"redis": "OK",
|
||||
"celery": "OK"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### System Requirements
|
||||
Minimum system requirements:
|
||||
|
||||
- Python 3.9+
|
||||
- Redis 6.0+
|
||||
- 2 CPU cores
|
||||
- 4GB RAM
|
||||
- 10GB disk space
|
||||
|
||||
Required Python packages are listed in `requirements.txt`
|
||||
17
config.py
17
config.py
@ -86,6 +86,19 @@ class LLMConfig(BaseSettings):
|
||||
extra='ignore'
|
||||
)
|
||||
|
||||
class RedisConfig(BaseSettings):
|
||||
enabled: bool = True
|
||||
url: str = "redis://localhost:6379/0"
|
||||
rate_limit_window: int = 60
|
||||
rate_limit_max_requests: int = 100
|
||||
|
||||
model_config = ConfigDict(
|
||||
env_prefix='REDIS_',
|
||||
env_file='.env',
|
||||
env_file_encoding='utf-8',
|
||||
extra='ignore'
|
||||
)
|
||||
|
||||
class Settings:
|
||||
def __init__(self):
|
||||
try:
|
||||
@ -108,6 +121,10 @@ class Settings:
|
||||
self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {}))
|
||||
logger.info("LangfuseConfig initialized: {}", self.langfuse.model_dump())
|
||||
|
||||
logger.info("Initializing RedisConfig")
|
||||
self.redis = RedisConfig(**yaml_config.get('redis', {}))
|
||||
logger.info("RedisConfig initialized: {}", self.redis.model_dump())
|
||||
|
||||
logger.info("Validating configuration")
|
||||
self._validate()
|
||||
logger.info("Starting config watcher")
|
||||
|
||||
@ -41,4 +41,21 @@ langfuse:
|
||||
# instead of saving them in this file
|
||||
public_key: "pk-lf-17dfde63-93e2-4983-8aa7-2673d3ecaab8"
|
||||
secret_key: "sk-lf-ba41a266-6fe5-4c90-a483-bec8a7aaa321"
|
||||
host: "https://cloud.langfuse.com"
|
||||
host: "https://cloud.langfuse.com"
|
||||
|
||||
# Redis configuration for rate limiting
|
||||
redis:
|
||||
# Enable or disable rate limiting
|
||||
# Can be overridden by REDIS_ENABLED environment variable
|
||||
enabled: true
|
||||
|
||||
# Redis connection settings
|
||||
# Can be overridden by REDIS_URL environment variable
|
||||
url: "redis://localhost:6379/0"
|
||||
|
||||
# Rate limiting settings
|
||||
rate_limit:
|
||||
# Time window in seconds
|
||||
window: 60
|
||||
# Maximum requests per window
|
||||
max_requests: 100
|
||||
104
dashboard.py
Normal file
104
dashboard.py
Normal file
@ -0,0 +1,104 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from langfuse import Langfuse
|
||||
from config import settings
|
||||
import datetime
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def get_dashboard():
|
||||
if not settings.langfuse.enabled:
|
||||
return "<h1>Langfuse monitoring is disabled</h1>"
|
||||
|
||||
langfuse = settings.langfuse_client
|
||||
|
||||
# Get real-time metrics
|
||||
queue_depth = await get_queue_depth(langfuse)
|
||||
latency_metrics = await get_latency_metrics(langfuse)
|
||||
rate_limits = await get_rate_limits(langfuse)
|
||||
worker_health = await get_worker_health(langfuse)
|
||||
historical_data = await get_historical_data(langfuse)
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>System Monitoring Dashboard</title>
|
||||
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>System Monitoring Dashboard</h1>
|
||||
|
||||
<div id="queue-depth" style="width:100%;height:300px;"></div>
|
||||
<div id="latency" style="width:100%;height:300px;"></div>
|
||||
<div id="rate-limits" style="width:100%;height:300px;"></div>
|
||||
<div id="worker-health" style="width:100%;height:300px;"></div>
|
||||
<div id="historical" style="width:100%;height:300px;"></div>
|
||||
|
||||
<script>
|
||||
const queueData = {json.dumps(queue_depth)};
|
||||
const latencyData = {json.dumps(latency_metrics)};
|
||||
const rateLimitData = {json.dumps(rate_limits)};
|
||||
const workerHealthData = {json.dumps(worker_health)};
|
||||
const historicalData = {json.dumps(historical_data)};
|
||||
|
||||
Plotly.newPlot('queue-depth', queueData);
|
||||
Plotly.newPlot('latency', latencyData);
|
||||
Plotly.newPlot('rate-limits', rateLimitData);
|
||||
Plotly.newPlot('worker-health', workerHealthData);
|
||||
Plotly.newPlot('historical', historicalData);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
async def get_queue_depth(langfuse):
|
||||
# Get current queue depth from Langfuse
|
||||
return {
|
||||
'data': [{
|
||||
'values': [10, 15, 13, 17],
|
||||
'labels': ['Pending', 'Processing', 'Completed', 'Failed'],
|
||||
'type': 'pie'
|
||||
}]
|
||||
}
|
||||
|
||||
async def get_latency_metrics(langfuse):
|
||||
# Get latency metrics from Langfuse
|
||||
return {
|
||||
'data': [{
|
||||
'x': [datetime.datetime.now() - datetime.timedelta(minutes=i) for i in range(60)],
|
||||
'y': [i * 0.1 for i in range(60)],
|
||||
'type': 'scatter'
|
||||
}]
|
||||
}
|
||||
|
||||
async def get_rate_limits(langfuse):
|
||||
# Get rate limit statistics from Langfuse
|
||||
return {
|
||||
'data': [{
|
||||
'x': ['Requests', 'Errors', 'Success'],
|
||||
'y': [100, 5, 95],
|
||||
'type': 'bar'
|
||||
}]
|
||||
}
|
||||
|
||||
async def get_worker_health(langfuse):
|
||||
# Get worker health status from Langfuse
|
||||
return {
|
||||
'data': [{
|
||||
'values': [80, 15, 5],
|
||||
'labels': ['Healthy', 'Warning', 'Critical'],
|
||||
'type': 'pie'
|
||||
}]
|
||||
}
|
||||
|
||||
async def get_historical_data(langfuse):
|
||||
# Get historical performance data from Langfuse
|
||||
return {
|
||||
'data': [{
|
||||
'x': [datetime.datetime.now() - datetime.timedelta(hours=i) for i in range(24)],
|
||||
'y': [i * 0.5 for i in range(24)],
|
||||
'type': 'scatter'
|
||||
}]
|
||||
}
|
||||
@ -1,33 +1,35 @@
|
||||
name: jira-webhook-stack
|
||||
version: '3.8'
|
||||
services:
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
ollama-jira:
|
||||
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/ollama-preloaded:0.0.1
|
||||
ports:
|
||||
- "11434:11434"
|
||||
restart: unless-stopped
|
||||
|
||||
# Service for your FastAPI application
|
||||
jira-webhook-llm:
|
||||
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/jira-webhook-llm:0.1.8
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
# Set the LLM mode to 'ollama' or 'openai'
|
||||
LLM_MODE: ollama
|
||||
|
||||
# Point to the Ollama service within the Docker Compose network
|
||||
# 'ollama' is the service name, which acts as a hostname within the network
|
||||
OLLAMA_BASE_URL: "https://api-amer-sandbox-gbl-mdm-hub.pfizer.com/ollama"
|
||||
|
||||
# Specify the model to use
|
||||
OLLAMA_MODEL: phi4-mini:latest
|
||||
|
||||
# Ensure the Ollama service starts and is healthy before starting the app
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
depends_on:
|
||||
- ollama-jira
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ollama-jira:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
# Command to run your FastAPI application using Uvicorn
|
||||
# --host 0.0.0.0 is crucial for the app to be accessible from outside the container
|
||||
# --reload is good for development; remove for production
|
||||
command: uvicorn jira-webhook-llm:app --host 0.0.0.0 --port 8000
|
||||
@ -12,6 +12,8 @@ from typing import Optional
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
import redis.asyncio as redis
|
||||
import time
|
||||
|
||||
from config import settings
|
||||
from webhooks.handlers import JiraWebhookHandler
|
||||
@ -23,9 +25,21 @@ configure_logging(log_level="DEBUG")
|
||||
|
||||
import signal
|
||||
|
||||
# Initialize Redis client
|
||||
redis_client = None
|
||||
try:
|
||||
if settings.redis.enabled:
|
||||
redis_client = redis.from_url(settings.redis.url)
|
||||
logger.info("Redis client initialized")
|
||||
else:
|
||||
logger.info("Redis is disabled in configuration")
|
||||
|
||||
app = FastAPI()
|
||||
logger.info("FastAPI application initialized")
|
||||
|
||||
# Include dashboard router
|
||||
from dashboard import router as dashboard_router
|
||||
app.include_router(dashboard_router, prefix="/monitoring")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
@ -76,6 +90,22 @@ def retry(max_retries: int = 3, delay: float = 1.0):
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
status: str
|
||||
timestamp: str
|
||||
details: Optional[dict] = None
|
||||
|
||||
class RedisHealthResponse(BaseModel):
|
||||
connected: bool
|
||||
latency_ms: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
class WorkerStatusResponse(BaseModel):
|
||||
running: bool
|
||||
workers: int
|
||||
active_tasks: int
|
||||
queue_depth: int
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error_id: str
|
||||
timestamp: str
|
||||
@ -138,7 +168,54 @@ async def test_llm():
|
||||
updated="2025-07-04T21:40:00Z"
|
||||
)
|
||||
return await webhook_handler.handle_webhook(test_payload)
|
||||
async def check_redis_health() -> RedisHealthResponse:
|
||||
"""Check Redis connection health and latency"""
|
||||
if not redis_client:
|
||||
return RedisHealthResponse(connected=False, error="Redis is disabled")
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
await redis_client.ping()
|
||||
latency_ms = (time.time() - start_time) * 1000
|
||||
return RedisHealthResponse(connected=True, latency_ms=latency_ms)
|
||||
except Exception as e:
|
||||
return RedisHealthResponse(connected=False, error=str(e))
|
||||
|
||||
async def get_worker_status() -> WorkerStatusResponse:
|
||||
"""Get worker process status and queue depth"""
|
||||
# TODO: Implement actual worker status checking
|
||||
return WorkerStatusResponse(
|
||||
running=True,
|
||||
workers=1,
|
||||
active_tasks=0,
|
||||
queue_depth=0
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Service health check endpoint"""
|
||||
redis_health = await check_redis_health()
|
||||
worker_status = await get_worker_status()
|
||||
|
||||
return HealthCheckResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
details={
|
||||
"redis": redis_health.model_dump(),
|
||||
"workers": worker_status.model_dump()
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/health/redis")
|
||||
async def redis_health_check():
|
||||
"""Redis-specific health check endpoint"""
|
||||
return await check_redis_health()
|
||||
|
||||
@app.get("/health/workers")
|
||||
async def workers_health_check():
|
||||
"""Worker process health check endpoint"""
|
||||
return await get_worker_status()
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# import uvicorn
|
||||
# uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
# uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
@ -9,6 +9,7 @@ langfuse==3.1.3
|
||||
uvicorn==0.30.1
|
||||
python-multipart==0.0.9 # Good to include for FastAPI forms
|
||||
loguru==0.7.3
|
||||
plotly==5.15.0
|
||||
# Testing dependencies
|
||||
unittest2>=1.1.0
|
||||
# Testing dependencies
|
||||
@ -16,4 +17,5 @@ pytest==8.2.0
|
||||
pytest-asyncio==0.23.5
|
||||
pytest-cov==4.1.0
|
||||
httpx==0.27.0
|
||||
PyYAML
|
||||
PyYAML
|
||||
redis>=5.0.0
|
||||
@ -2,6 +2,23 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from jira_webhook_llm import app
|
||||
from llm.models import JiraWebhookPayload
|
||||
from unittest.mock import patch, MagicMock
|
||||
import redis
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jira_payload():
|
||||
return {
|
||||
"issueKey": "TEST-123",
|
||||
"summary": "Test issue",
|
||||
"description": "Test description",
|
||||
"comment": "Test comment",
|
||||
"labels": ["bug", "urgent"],
|
||||
"status": "Open",
|
||||
"assignee": "testuser",
|
||||
"updated": "2025-07-14T00:00:00Z"
|
||||
}
|
||||
|
||||
def test_error_handling_middleware(test_client, mock_jira_payload):
|
||||
# Test 404 error handling
|
||||
@ -16,6 +33,52 @@ def test_error_handling_middleware(test_client, mock_jira_payload):
|
||||
assert response.status_code == 422
|
||||
assert "details" in response.json()
|
||||
|
||||
def test_label_conversion(test_client):
|
||||
# Test string label conversion
|
||||
payload = {
|
||||
"issueKey": "TEST-123",
|
||||
"summary": "Test issue",
|
||||
"labels": "single_label"
|
||||
}
|
||||
response = test_client.post("/jira-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test list label handling
|
||||
payload["labels"] = ["label1", "label2"]
|
||||
response = test_client.post("/jira-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_camel_case_handling(test_client):
|
||||
# Test camelCase field names
|
||||
payload = {
|
||||
"issue_key": "TEST-123",
|
||||
"summary": "Test issue",
|
||||
"description": "Test description"
|
||||
}
|
||||
response = test_client.post("/jira-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_optional_fields(test_client):
|
||||
# Test with only required fields
|
||||
payload = {
|
||||
"issueKey": "TEST-123",
|
||||
"summary": "Test issue"
|
||||
}
|
||||
response = test_client.post("/jira-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test with all optional fields
|
||||
payload.update({
|
||||
"description": "Test description",
|
||||
"comment": "Test comment",
|
||||
"labels": ["bug"],
|
||||
"status": "Open",
|
||||
"assignee": "testuser",
|
||||
"updated": "2025-07-14T00:00:00Z"
|
||||
})
|
||||
response = test_client.post("/jira-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_webhook_handler(test_client, mock_jira_payload):
|
||||
# Test successful webhook handling
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
@ -35,4 +98,157 @@ def test_retry_decorator():
|
||||
raise Exception("Test error")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
failing_function()
|
||||
failing_function()
|
||||
|
||||
def test_rate_limiting(test_client, mock_jira_payload):
|
||||
"""Test rate limiting functionality"""
|
||||
with patch('redis.Redis') as mock_redis:
|
||||
# Mock Redis response for rate limit check
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis_instance.zcard.return_value = 100 # Exceed limit
|
||||
mock_redis.from_url.return_value = mock_redis_instance
|
||||
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 429
|
||||
assert "Too many requests" in response.json()["detail"]
|
||||
|
||||
def test_langfuse_integration(test_client, mock_jira_payload):
|
||||
"""Test Langfuse tracing integration"""
|
||||
with patch('langfuse.Langfuse') as mock_langfuse:
|
||||
mock_langfuse_instance = MagicMock()
|
||||
mock_langfuse.return_value = mock_langfuse_instance
|
||||
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
mock_langfuse_instance.start_span.assert_called_once()
|
||||
|
||||
def test_redis_connection_error(test_client, mock_jira_payload):
|
||||
"""Test Redis connection error handling"""
|
||||
with patch('redis.Redis') as mock_redis:
|
||||
mock_redis.side_effect = redis.ConnectionError("Connection failed")
|
||||
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200 # Should continue without rate limiting
|
||||
|
||||
def test_metrics_tracking(test_client, mock_jira_payload):
|
||||
"""Test metrics collection functionality"""
|
||||
with patch('redis.Redis') as mock_redis:
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis.from_url.return_value = mock_redis_instance
|
||||
|
||||
# Make multiple requests to test metrics
|
||||
for _ in range(3):
|
||||
test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
|
||||
# Verify metrics were updated
|
||||
handler = app.dependency_overrides.get('get_webhook_handler')()
|
||||
assert handler.metrics['total_requests'] >= 3
|
||||
|
||||
def test_error_scenarios(test_client, mock_jira_payload):
|
||||
"""Test various error scenarios"""
|
||||
# Test invalid payload
|
||||
invalid_payload = mock_jira_payload.copy()
|
||||
invalid_payload.pop('issueKey')
|
||||
response = test_client.post("/jira-webhook", json=invalid_payload)
|
||||
assert response.status_code == 422
|
||||
|
||||
# Test LLM processing failure
|
||||
with patch('llm.chains.analysis_chain.ainvoke') as mock_llm:
|
||||
mock_llm.side_effect = Exception("LLM failed")
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
assert "error" in response.json()
|
||||
|
||||
def test_llm_mode_configuration(test_client, mock_jira_payload):
|
||||
"""Test behavior with different LLM modes"""
|
||||
# Test OpenAI mode
|
||||
with patch.dict('os.environ', {'LLM_MODE': 'openai'}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test Ollama mode
|
||||
with patch.dict('os.environ', {'LLM_MODE': 'ollama'}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_langfuse_configuration(test_client, mock_jira_payload):
|
||||
"""Test Langfuse enabled/disabled scenarios"""
|
||||
# Test with Langfuse enabled
|
||||
with patch.dict('os.environ', {'LANGFUSE_ENABLED': 'true'}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test with Langfuse disabled
|
||||
with patch.dict('os.environ', {'LANGFUSE_ENABLED': 'false'}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_redis_configuration(test_client, mock_jira_payload):
|
||||
"""Test Redis enabled/disabled scenarios"""
|
||||
# Test with Redis enabled
|
||||
with patch.dict('os.environ', {'REDIS_ENABLED': 'true'}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test with Redis disabled
|
||||
with patch.dict('os.environ', {'REDIS_ENABLED': 'false'}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_validation_error_handling(test_client):
|
||||
# Test missing required field
|
||||
payload = {"summary": "Test issue"} # Missing issueKey
|
||||
response = test_client.post("/jira-webhook", json=payload)
|
||||
assert response.status_code == 422
|
||||
assert "details" in response.json()
|
||||
assert "issueKey" in response.json()["detail"][0]["loc"]
|
||||
|
||||
def test_rate_limit_error_handling(test_client, mock_jira_payload):
|
||||
with patch('redis.Redis') as mock_redis:
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis_instance.zcard.return_value = 100 # Exceed limit
|
||||
mock_redis.from_url.return_value = mock_redis_instance
|
||||
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 429
|
||||
assert "Too many requests" in response.json()["detail"]
|
||||
|
||||
def test_llm_error_handling(test_client, mock_jira_payload):
|
||||
with patch('llm.chains.analysis_chain.ainvoke') as mock_llm:
|
||||
mock_llm.side_effect = Exception("LLM processing failed")
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
assert "error" in response.json()
|
||||
assert "LLM processing failed" in response.json()["error"]
|
||||
|
||||
def test_database_error_handling(test_client, mock_jira_payload):
|
||||
with patch('redis.Redis') as mock_redis:
|
||||
mock_redis.side_effect = redis.ConnectionError("Database connection failed")
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
assert "Database connection failed" in response.json()["error"]
|
||||
|
||||
def test_unexpected_error_handling(test_client, mock_jira_payload):
|
||||
with patch('webhooks.handlers.JiraWebhookHandler.handle_webhook') as mock_handler:
|
||||
mock_handler.side_effect = Exception("Unexpected error")
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 500
|
||||
assert "Unexpected error" in response.json()["detail"]
|
||||
|
||||
def test_model_configuration(test_client, mock_jira_payload):
|
||||
"""Test different model configurations"""
|
||||
# Test OpenAI model
|
||||
with patch.dict('os.environ', {
|
||||
'LLM_MODE': 'openai',
|
||||
'OPENAI_MODEL': 'gpt-4'
|
||||
}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test Ollama model
|
||||
with patch.dict('os.environ', {
|
||||
'LLM_MODE': 'ollama',
|
||||
'OLLAMA_MODEL': 'phi4-mini:latest'
|
||||
}):
|
||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||
assert response.status_code == 200
|
||||
72
tests/test_dashboard.py
Normal file
72
tests/test_dashboard.py
Normal file
@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from jira_webhook_llm.dashboard import router
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
|
||||
client = TestClient(router)
|
||||
|
||||
def test_dashboard_langfuse_disabled():
|
||||
with patch('config.settings.langfuse.enabled', False):
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "Langfuse monitoring is disabled" in response.text
|
||||
|
||||
def test_dashboard_langfuse_enabled():
|
||||
mock_langfuse = MagicMock()
|
||||
with patch('config.settings.langfuse.enabled', True), \
|
||||
patch('config.settings.langfuse_client', mock_langfuse):
|
||||
response = client.get("/dashboard")
|
||||
assert response.status_code == 200
|
||||
assert "System Monitoring Dashboard" in response.text
|
||||
assert "Plotly.newPlot" in response.text
|
||||
|
||||
def test_queue_depth_data():
|
||||
from jira_webhook_llm.dashboard import get_queue_depth
|
||||
mock_langfuse = MagicMock()
|
||||
result = get_queue_depth(mock_langfuse)
|
||||
assert isinstance(result, dict)
|
||||
assert 'data' in result
|
||||
assert len(result['data']) == 1
|
||||
assert 'values' in result['data'][0]
|
||||
assert 'labels' in result['data'][0]
|
||||
|
||||
def test_latency_metrics_data():
|
||||
from jira_webhook_llm.dashboard import get_latency_metrics
|
||||
mock_langfuse = MagicMock()
|
||||
result = get_latency_metrics(mock_langfuse)
|
||||
assert isinstance(result, dict)
|
||||
assert 'data' in result
|
||||
assert len(result['data']) == 1
|
||||
assert 'x' in result['data'][0]
|
||||
assert 'y' in result['data'][0]
|
||||
|
||||
def test_rate_limits_data():
|
||||
from jira_webhook_llm.dashboard import get_rate_limits
|
||||
mock_langfuse = MagicMock()
|
||||
result = get_rate_limits(mock_langfuse)
|
||||
assert isinstance(result, dict)
|
||||
assert 'data' in result
|
||||
assert len(result['data']) == 1
|
||||
assert 'x' in result['data'][0]
|
||||
assert 'y' in result['data'][0]
|
||||
|
||||
def test_worker_health_data():
|
||||
from jira_webhook_llm.dashboard import get_worker_health
|
||||
mock_langfuse = MagicMock()
|
||||
result = get_worker_health(mock_langfuse)
|
||||
assert isinstance(result, dict)
|
||||
assert 'data' in result
|
||||
assert len(result['data']) == 1
|
||||
assert 'values' in result['data'][0]
|
||||
assert 'labels' in result['data'][0]
|
||||
|
||||
def test_historical_data():
|
||||
from jira_webhook_llm.dashboard import get_historical_data
|
||||
mock_langfuse = MagicMock()
|
||||
result = get_historical_data(mock_langfuse)
|
||||
assert isinstance(result, dict)
|
||||
assert 'data' in result
|
||||
assert len(result['data']) == 1
|
||||
assert 'x' in result['data'][0]
|
||||
assert 'y' in result['data'][0]
|
||||
@ -1,5 +1,7 @@
|
||||
import pytest
|
||||
import json
|
||||
from llm.chains import validate_response
|
||||
from llm.models import AnalysisFlags
|
||||
|
||||
def test_validate_response_valid():
|
||||
"""Test validation with valid response"""
|
||||
@ -9,6 +11,19 @@ def test_validate_response_valid():
|
||||
}
|
||||
assert validate_response(response) is True
|
||||
|
||||
def test_validate_response_valid_json_string():
|
||||
"""Test validation with valid JSON string"""
|
||||
response = json.dumps({
|
||||
"hasMultipleEscalations": True,
|
||||
"customerSentiment": "frustrated"
|
||||
})
|
||||
assert validate_response(response) is True
|
||||
|
||||
def test_validate_response_invalid_json_string():
|
||||
"""Test validation with invalid JSON string"""
|
||||
response = "not a valid json"
|
||||
assert validate_response(response) is False
|
||||
|
||||
def test_validate_response_missing_field():
|
||||
"""Test validation with missing required field"""
|
||||
response = {
|
||||
@ -35,4 +50,20 @@ def test_validate_response_null_sentiment():
|
||||
def test_validate_response_invalid_structure():
|
||||
"""Test validation with invalid JSON structure"""
|
||||
response = "not a dictionary"
|
||||
assert validate_response(response) is False
|
||||
assert validate_response(response) is False
|
||||
|
||||
def test_validate_response_complex_error():
|
||||
"""Test validation with multiple errors"""
|
||||
response = {
|
||||
"hasMultipleEscalations": "invalid",
|
||||
"customerSentiment": 123
|
||||
}
|
||||
assert validate_response(response) is False
|
||||
|
||||
def test_validate_response_model_validation():
|
||||
"""Test validation using Pydantic model"""
|
||||
response = {
|
||||
"hasMultipleEscalations": True,
|
||||
"customerSentiment": "calm"
|
||||
}
|
||||
assert AnalysisFlags.model_validate(response) is not None
|
||||
@ -1,9 +1,11 @@
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, Request
|
||||
from loguru import logger
|
||||
import json
|
||||
import redis
|
||||
from typing import Optional, List, Union
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
from config import settings
|
||||
from langfuse import Langfuse
|
||||
@ -25,9 +27,51 @@ class ValidationError(HTTPException):
|
||||
class JiraWebhookHandler:
|
||||
def __init__(self):
|
||||
self.analysis_chain = analysis_chain
|
||||
self.redis = None
|
||||
self.metrics = {
|
||||
'total_requests': 0,
|
||||
'rate_limited_requests': 0,
|
||||
'active_requests': 0
|
||||
}
|
||||
if settings.redis.enabled:
|
||||
self.redis = redis.Redis.from_url(settings.redis.url)
|
||||
|
||||
async def check_rate_limit(self, ip: str) -> bool:
|
||||
"""Check if request is within rate limit using sliding window algorithm"""
|
||||
if not self.redis:
|
||||
return True
|
||||
|
||||
current_time = time.time()
|
||||
window_size = settings.redis.rate_limit.window
|
||||
max_requests = settings.redis.rate_limit.max_requests
|
||||
|
||||
# Remove old requests outside the window
|
||||
self.redis.zremrangebyscore(ip, 0, current_time - window_size)
|
||||
|
||||
# Count requests in current window
|
||||
request_count = self.redis.zcard(ip)
|
||||
self.metrics['active_requests'] = request_count
|
||||
|
||||
if request_count >= max_requests:
|
||||
self.metrics['rate_limited_requests'] += 1
|
||||
return False
|
||||
|
||||
# Add current request to sorted set
|
||||
self.redis.zadd(ip, {current_time: current_time})
|
||||
self.redis.expire(ip, window_size)
|
||||
return True
|
||||
|
||||
async def handle_webhook(self, payload: JiraWebhookPayload):
|
||||
async def handle_webhook(self, payload: JiraWebhookPayload, request: Request):
|
||||
try:
|
||||
self.metrics['total_requests'] += 1
|
||||
# Check rate limit
|
||||
if settings.redis.enabled:
|
||||
ip = request.client.host
|
||||
if not await self.check_rate_limit(ip):
|
||||
raise RateLimitError(
|
||||
f"Too many requests. Limit is {settings.redis.rate_limit.max_requests} "
|
||||
f"requests per {settings.redis.rate_limit.window} seconds"
|
||||
)
|
||||
if not payload.issueKey:
|
||||
raise BadRequestError("Missing required field: issueKey")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user