a464e550c9
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
91 lines
3.0 KiB
Python
91 lines
3.0 KiB
Python
"""
|
|
Structured logging primitives.
|
|
|
|
Every log record is emitted as a single JSON object so Logstash can ingest it
|
|
without grok patterns, and every record carries the request id of the HTTP
|
|
request that produced it, which makes a single user action traceable end to end
|
|
in Kibana.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import logging
|
|
import traceback
|
|
from contextvars import ContextVar
|
|
from typing import Any
|
|
|
|
# Propagates the current request id to any code running in the same context,
|
|
# including background threads started per request.
|
|
_request_id: ContextVar[str] = ContextVar("request_id", default="-")
|
|
|
|
# Attributes that ``logging`` puts on every record; anything else the caller
|
|
# passed through ``extra=`` is treated as structured context and serialised.
|
|
_STANDARD_ATTRS = frozenset(
|
|
{
|
|
"args", "asctime", "created", "exc_info", "exc_text", "filename",
|
|
"funcName", "levelname", "levelno", "lineno", "module", "msecs",
|
|
"message", "msg", "name", "pathname", "process", "processName",
|
|
"relativeCreated", "stack_info", "thread", "threadName", "taskName",
|
|
"request_id",
|
|
}
|
|
) # fmt: skip
|
|
|
|
|
|
def set_request_id(value: str) -> None:
|
|
_request_id.set(value)
|
|
|
|
|
|
def get_request_id() -> str:
|
|
return _request_id.get()
|
|
|
|
|
|
class RequestIDFilter(logging.Filter):
|
|
"""Attaches the ambient request id to every record."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
record.request_id = getattr(record, "request_id", None) or get_request_id()
|
|
return True
|
|
|
|
|
|
class JSONFormatter(logging.Formatter):
|
|
"""Renders a ``LogRecord`` as one line of JSON."""
|
|
|
|
def __init__(self, service: str = "app", environment: str = "development") -> None:
|
|
super().__init__()
|
|
self.service = service
|
|
self.environment = environment
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
payload: dict[str, Any] = {
|
|
"@timestamp": dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(),
|
|
"level": record.levelname,
|
|
"logger": record.name,
|
|
"message": record.getMessage(),
|
|
"service": self.service,
|
|
"environment": self.environment,
|
|
"request_id": getattr(record, "request_id", "-"),
|
|
}
|
|
|
|
if record.exc_info:
|
|
exc_type, exc_value, exc_tb = record.exc_info
|
|
payload["error"] = {
|
|
"type": getattr(exc_type, "__name__", str(exc_type)),
|
|
"message": str(exc_value),
|
|
"stack": "".join(traceback.format_exception(exc_type, exc_value, exc_tb))[:8000],
|
|
}
|
|
|
|
for key, value in record.__dict__.items():
|
|
if key not in _STANDARD_ATTRS and not key.startswith("_"):
|
|
payload[key] = _coerce(value)
|
|
|
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
|
|
|
|
def _coerce(value: Any) -> Any:
|
|
"""Keep the payload JSON serialisable without blowing up on odd types."""
|
|
if isinstance(value, str | int | float | bool | type(None) | list | dict):
|
|
return value
|
|
return str(value)
|