Skip to content

Commit f0ff535

Browse files
added search-service files
1 parent af7a4c4 commit f0ff535

5 files changed

Lines changed: 220 additions & 11 deletions

File tree

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import redis
2-
32
from app.core.config import settings
3+
# 1. Import the metrics you defined
4+
from app.core.metrics import cache_requests_total
5+
6+
redis_client = redis.Redis.from_url(settings.redis_url, decode_responses=True)
47

5-
# decode_responses=True means values come back as str instead of bytes --
6-
# convenient since we're storing JSON strings, not binary data.
7-
redis_client = redis.Redis.from_url(settings.redis_url, decode_responses=True)
8+
def get_from_cache(key: str):
9+
data = redis_client.get(key)
10+
if data:
11+
# 2. Increment the metric on a HIT
12+
cache_requests_total.labels(result="hit").inc()
13+
return data
14+
15+
# 3. Increment the metric on a MISS
16+
cache_requests_total.labels(result="miss").inc()
17+
return None
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Structured logging setup, shared in shape across every FastAPI service in
3+
PriceLens (each service gets its own copy of this file -- there's no
4+
shared Python package across services in this project's architecture, so
5+
duplication here is the same deliberate tradeoff as the near-identical
6+
Dockerfiles from Phase 12: acceptable at this service count, would become
7+
a shared library at a larger scale).
8+
9+
Two things this solves that plain `logging.basicConfig` doesn't:
10+
11+
1. JSON output -- a real log aggregation tool (Loki, CloudWatch, ELK) can
12+
parse and index individual fields (level, service, request_id) instead
13+
of needing to regex-parse a plain text line. This is what makes a query
14+
like "show me every ERROR from search-service in the last hour" fast
15+
and exact rather than approximate.
16+
17+
2. Request correlation ID -- generated once per incoming HTTP request and
18+
attached to every log line produced while handling it, AND propagated
19+
to any outgoing call to another service (via the X-Request-ID header).
20+
This is what lets you grep one ID across auth-service, search-service,
21+
and the Celery worker's logs and see the complete story of one user's
22+
one request, rather than four separate unconnected timelines.
23+
"""
24+
import json
25+
import logging
26+
import time
27+
import uuid
28+
from contextvars import ContextVar
29+
30+
from fastapi import Request
31+
from starlette.middleware.base import BaseHTTPMiddleware
32+
33+
# ContextVar (not a plain global) is what makes this safe under concurrent
34+
# async requests -- each request gets its own isolated value even though
35+
# they're all running in the same process/event loop, unlike a plain
36+
# module-level variable which would leak between concurrent requests.
37+
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
38+
39+
40+
def get_current_request_id() -> str:
41+
"""
42+
Used by outgoing HTTP clients (e.g. analytics-service's TrackingClient)
43+
to read the current request's correlation ID and forward it as an
44+
X-Request-ID header on calls to other services -- this is what makes
45+
the ID actually correlate across service boundaries, not just within
46+
one service's own logs.
47+
"""
48+
return request_id_ctx.get()
49+
50+
51+
class JsonFormatter(logging.Formatter):
52+
def format(self, record: logging.LogRecord) -> str:
53+
payload = {
54+
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
55+
"level": record.levelname,
56+
"logger": record.name,
57+
"message": record.getMessage(),
58+
"request_id": request_id_ctx.get(),
59+
}
60+
if record.exc_info:
61+
payload["exception"] = self.formatException(record.exc_info)
62+
return json.dumps(payload)
63+
64+
65+
def configure_logging(service_name: str) -> None:
66+
handler = logging.StreamHandler()
67+
handler.setFormatter(JsonFormatter())
68+
69+
root_logger = logging.getLogger()
70+
root_logger.handlers = [handler]
71+
root_logger.setLevel(logging.INFO)
72+
73+
# Tag every record with which service produced it -- useful once logs
74+
# from multiple services are aggregated into one stream/dashboard.
75+
old_factory = logging.getLogRecordFactory()
76+
77+
def record_factory(*args, **kwargs):
78+
record = old_factory(*args, **kwargs)
79+
record.service = service_name
80+
return record
81+
82+
logging.setLogRecordFactory(record_factory)
83+
84+
85+
class RequestIdMiddleware(BaseHTTPMiddleware):
86+
"""
87+
Reads X-Request-ID from an incoming request if present (meaning this
88+
request originated from, or passed through, another PriceLens service
89+
that already generated one), or creates a new one if this is the
90+
entry point. Either way, sets it in the ContextVar so every log line
91+
during this request includes it, and echoes it back in the response
92+
header so a client (or another service) can correlate further.
93+
"""
94+
95+
async def dispatch(self, request: Request, call_next):
96+
incoming_id = request.headers.get("X-Request-ID")
97+
req_id = incoming_id or str(uuid.uuid4())
98+
token = request_id_ctx.set(req_id)
99+
100+
start = time.monotonic()
101+
try:
102+
response = await call_next(request)
103+
finally:
104+
request_id_ctx.reset(token)
105+
106+
duration_ms = round((time.monotonic() - start) * 1000, 2)
107+
response.headers["X-Request-ID"] = req_id
108+
109+
logging.getLogger("access").info(
110+
"%s %s -> %s (%sms)",
111+
request.method,
112+
request.url.path,
113+
response.status_code,
114+
duration_ms,
115+
)
116+
return response
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from slowapi import Limiter
2+
from slowapi.util import get_remote_address
3+
4+
from app.core.config import settings
5+
6+
# Reuses the SAME Redis instance already running since Phase 8 -- no new
7+
# infrastructure needed, just a different key namespace within it.
8+
# get_remote_address keys the limit by client IP; this is the standard
9+
# first line of defense against a single client hammering /search, which
10+
# matters specifically here because every cache miss (Phase 8) triggers a
11+
# REAL scrape (Phase 9) -- unrestrained request volume directly translates
12+
# into scrape volume, risking an Amazon/Flipkart IP ban (the exact risk
13+
# Phase 8's caching was originally introduced to reduce).
14+
limiter = Limiter(
15+
key_func=get_remote_address,
16+
storage_uri=settings.redis_url,
17+
)

services/search-service/app/main.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,39 @@
22
Application entrypoint.
33
44
Deliberately thin: this file only wires things together (settings, routers).
5-
It must never contain business logic or database queries.
65
"""
76

87
from fastapi import FastAPI
98
from fastapi.middleware.cors import CORSMiddleware
109
from prometheus_fastapi_instrumentator import Instrumentator
1110

11+
from slowapi.errors import RateLimitExceeded
12+
from slowapi.middleware import SlowAPIMiddleware
13+
from slowapi import _rate_limit_exceeded_handler
14+
1215
from app.api.v1 import health, search
1316
from app.core.config import settings
17+
from app.core.rate_limit import limiter
18+
19+
app = FastAPI(
20+
title=settings.app_name,
21+
debug=settings.debug,
22+
)
23+
24+
# -------------------------
25+
# Rate Limiting
26+
# -------------------------
27+
28+
app.state.limiter = limiter
29+
app.add_exception_handler(
30+
RateLimitExceeded,
31+
_rate_limit_exceeded_handler,
32+
)
33+
app.add_middleware(SlowAPIMiddleware)
1434

15-
app = FastAPI(title=settings.app_name, debug=settings.debug)
35+
# -------------------------
36+
# CORS
37+
# -------------------------
1638

1739
app.add_middleware(
1840
CORSMiddleware,
@@ -22,12 +44,23 @@
2244
allow_headers=["*"],
2345
)
2446

47+
# -------------------------
48+
# Routers
49+
# -------------------------
50+
2551
app.include_router(health.router, prefix=settings.api_v1_prefix)
26-
app.include_router(search.router, prefix=settings.api_v1_prefix)
52+
app.include_router(search.router, prefix=settings.api_v1_prefix)
2753

54+
# -------------------------
55+
# Metrics
56+
# -------------------------
2857

29-
@app.get("/")
30-
async def root() -> dict[str, str]:
31-
return {"service": settings.app_name, "status": "running"}
32-
# Expose Prometheus metrics
3358
Instrumentator().instrument(app).expose(app)
59+
60+
61+
@app.get("/")
62+
async def root():
63+
return {
64+
"service": settings.app_name,
65+
"status": "running",
66+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import logging
2+
from app.celery_app import celery_app
3+
from app.services.scraper_service import ScraperService
4+
# Import your centralized logging config to ensure worker logs match
5+
from app.core.logging_config import configure_logging, request_id_var
6+
7+
# Initialize logging for the worker process
8+
configure_logging()
9+
logger = logging.getLogger(__name__)
10+
11+
@celery_app.task(bind=True)
12+
def run_scrape_task(self, product_id: str, request_id: str = None):
13+
"""
14+
Executes a product scrape task.
15+
16+
:param product_id: The ID of the product to scrape.
17+
:param request_id: Correlation ID propagated from the search-service.
18+
"""
19+
# 1. Set the correlation ID for this worker task
20+
# If no ID was provided (legacy task), fall back to the Celery Task ID
21+
correlation_id = request_id or f"worker-{self.request.id}"
22+
request_id_var.set(correlation_id)
23+
24+
logger.info(f"Starting scrape task for product {product_id}")
25+
26+
try:
27+
scraper = ScraperService()
28+
scraper.scrape(product_id)
29+
logger.info(f"Successfully completed scrape task for {product_id}")
30+
except Exception as e:
31+
logger.error(f"Failed to scrape product {product_id}: {str(e)}")
32+
# Re-raise to allow Celery's retry mechanism to handle it
33+
raise e

0 commit comments

Comments
 (0)