Building Observability in My Solo Django Project (Trip-Nest) with Structlog, OpenTelemetry, and…
I’m building Trip-Nest as a hobby project (solo), a Django backend for hotel discovery/booking flows. Core features include:
Building Observability in My Solo Django Project (Trip-Nest) with Structlog, OpenTelemetry, and Jaeger
I’m building **Trip-Nest** as a hobby project (solo), a Django backend for hotel discovery/booking flows. Core features include:
- hotel search with filters (city, rating, price, facilities)
- host CRUD flows (create/update/delete)
- hotel stats endpoints
- Redis cache for search responses
As the project grew, plain logs weren’t enough. I wanted to answer:
- Why is this endpoint slow?
- Is Redis actually helping?
- Which user hit this request?
- Where exactly did a failure happen?
So I added observability in this order:
- Structlog (structured logs)
- OpenTelemetry (spans + context)
- Jaeger (trace visualization)
1) Structlog setup in my project
I use structlog as the logger in observability and views.
import structlog
from django.contrib.auth import get_user
from opentelemetry.instrumentation.django import DjangoInstrumentor
logger = structlog.get_logger(__name__)
def _instrument_django():
def response_hook(span, request, response):
user = getattr(request, "user", None)
if user is None and hasattr(request, "session"):
user = get_user(request)
if user and user.is_authenticated:
span.set_attribute("enduser.id", str(user.pk))
email_field = getattr(user, "get_email_field_name", lambda: "email")()
email = getattr(user, email_field, None)
if email:
span.set_attribute("enduser.email", email)
DjangoInstrumentor().instrument(response_hook=response_hook)
logger.info("OpenTelemetry Django initialized with user tracking")
I also keep business logs (example from hotel delete flow):
logger.info(
"hotel_soft_deleted",
hotel_id=hotel_id,
owner_id=owner_id,
actor_id=str(request.user.pk),
)
Structured logs became much easier to query and understand than plain text logs.
2) OpenTelemetry integration (real code)
I initialize instrumentation at WSGI boot:
import os
from django.core.wsgi import get_wsgi_application
from backend import observability
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
observability.instrument()
application = get_wsgi_application()
Then I created a small tracing utility:
from collections.abc import Mapping
from typing import Any
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer(__name__)
def set_span_attributes(span, attributes: Mapping[str, Any]) -> None:
if span is None or not span.is_recording():
return
for key, value in attributes.items():
if value is not None:
span.set_attribute(key, value)
def add_span_event(span, event_name: str, attributes: Mapping[str, Any] | None = None) -> None:
if span is None or not span.is_recording():
return
span.add_event(event_name, attributes or {})
def record_span_exception(span, exc: Exception) -> None:
if span is None or not span.is_recording():
return
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR))
3) Manual spans for meaningful business traces
Django auto-instrumentation gives a root span, but I wanted business-level clarity.
So I manually added nested spans in HotelViewSet.search.
with tracer.start_as_current_span("hotels.search") as span:
set_span_attributes(
span,
{
"enduser.id": str(request.user.pk) if request.user.is_authenticated else None,
"hotel.action": "search",
},
)
cache_key = self.build_search_cache_key(request)
set_span_attributes(span, {"cache.key": cache_key})
add_span_event(span, "search.started")
with tracer.start_as_current_span("hotels.search.cache_get") as cache_get_span:
cache_get_start = perf_counter()
cached = cache.get(cache_key)
cache_get_ms = (perf_counter() - cache_get_start) * 1000
set_span_attributes(
cache_get_span,
{
"cache.system": "redis",
"cache.operation": "get",
"cache.redis.get.ms": round(cache_get_ms, 2),
"cache.hit": bool(cached),
},
)
# filtering + pagination spans...
I also added exception recording in every instrumented endpoint:
except Exception as exc:
record_span_exception(span, exc)
raise
4) Jaeger: what improved immediately
Once exported to Jaeger, traces became actionable:
- clear parent/child flow (
hotels.search-> cache_get -> filtering -> pagination -> cache_set) - Redis timing visible per request
- easy filtering by attributes like
enduser.idorhotel.id - error spans visibly marked instead of hidden in logs
So now, when search is slow, I can quickly tell if it’s cache miss path, filtering, pagination, or cache write.
Final thoughts (solo-dev perspective)
Even for a hobby project, this was worth it.
If I had to repeat it, I’d do the same order:
- Structlog first (clean logs)
- OpenTelemetry second (trace semantics)
- Jaeger third (visual diagnosis)
That combo gave me observability that actually helps while building features.
메타데이터
- post_id
- ebcbb70fa57e
- slug
- building-observability-in-my-solo-django-project-trip-nest-with-structlog-opentelemetry-and-ebcbb70fa57e
- url
- https://medium.com/@manzurulhoque/building-observability-in-my-solo-django-project-trip-nest-with-structlog-opentelemetry-and-ebcbb70fa57e
- canonical_url
- https://medium.com/@manzurulhoque/building-observability-in-my-solo-django-project-trip-nest-with-structlog-opentelemetry-and-ebcbb70fa57e
- author_url
- https://medium.com/@manzurulhoque
- status
- ok
- fetched_at
- 2026-07-10 22:17:52