Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.vscode
.vscode
.env
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
EXPOSE 8000
COPY . /app/
CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "4", "--log-level", "debug", "--access-logfile", "-", "minicommerce.wsgi"]
CMD ["gunicorn", "minicommerce.wsgi:application", "-c", "gunicorn.conf.py"]
8 changes: 7 additions & 1 deletion backend/env.template
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
SECRET_KEY="your app secret key"
DEBUG=False
SECRET_KEY=testsecurekey
APP_ENV=production
ALLOWED_HOSTS="host1,host2,host3" # only the host
LOGIN_REDIRECT_URL="frontend url to redirect after login with openid"
LOGIN_REDIRECT_URL=/
AT_USERNAME="your africas talking app username"
AT_APIKEY = "your africas talking app api key"
# OIDC provider details for google auth (use any proider)
Expand All @@ -18,3 +20,7 @@ POSTGRES_DB="my db"
POSTGRES_USER="database user"
POSTGRES_PASSWORD="database password"
POSTGRES_HOST="database server url"
OTEL_EXPORTER_OTLP_ENDPOINT=http://172.17.0.3:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
BACKEND_IMAGE_TAG=otel
FRONTEND_IMAGE_TAG=a046499
28 changes: 28 additions & 0 deletions backend/gunicorn.conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import multiprocessing
import os

# ── Tell the app it is managed by Gunicorn ───────────────────────────────────
# This must be set at module level (before workers are forked) so that
# AppConfig.ready() can skip its own tracing init and defer to post_fork.
os.environ["GUNICORN_MANAGED"] = "true"

# ── gunicorn ────────────────────────────────────────────────────────────
bind = os.environ.get("GUNICORN_BIND", "0.0.0.0:8000")
workers = int(os.environ.get("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
worker_class = "sync"
timeout = int(os.environ.get("GUNICORN_TIMEOUT", 30))

# ── Logging ──────────────────────────────────────────────────────────────────
accesslog = "-" # stdout
errorlog = "-" # stdout
loglevel = os.environ.get("GUNICORN_LOG_LEVEL", "info")

# ── OpenTelemetry ─────────────────────────────────────────────────────────────
# Each Gunicorn worker is a *forked* child process.
# The TracerProvider must be initialised AFTER the fork — never before —
# because forking after SDK init causes broken background threads.
def post_fork(server, worker):
"""Called once per worker after fork(). Safe place to init the SDK."""
from minicommerce.telemetry import configure_tracing
configure_tracing()
server.log.info("OTel tracing initialised in worker pid=%s", worker.pid)
1 change: 1 addition & 0 deletions backend/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'minicommerce.settings')

try:
from django.core.management import execute_from_command_line
except ImportError as exc:
Expand Down
18 changes: 18 additions & 0 deletions backend/minicommerce/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import os
from django.apps import AppConfig


class CoreConfig(AppConfig):
name = "minicommerce"
default_auto_field = "django.db.models.BigAutoField"

def ready(self) -> None:
from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()

# Gunicorn initialises tracing per-worker in post_fork (fork-safe).
# Every other runner (manage.py runserver, pytest, celery, etc.)
# gets it here instead.
if not os.environ.get("GUNICORN_MANAGED"):
from minicommerce.telemetry import configure_tracing
configure_tracing()
50 changes: 47 additions & 3 deletions backend/minicommerce/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@
from os import getenv as environ
from dotenv import load_dotenv

load_dotenv()
# Load .env from project BASE_DIR so env vars are available regardless of CWD
# (useful when wrapping with opentelemetry-instrument or running from repo root)
BASE_DIR = Path(__file__).resolve().parent.parent
env_path = BASE_DIR / '.env'
# print(f"Loading environment variables from: {env_path}")
load_dotenv(dotenv_path=env_path)
# load_dotenv(dotenv_path=env_path, override=True)

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# BASE_DIR is already defined above


# Quick-start development settings - unsuitable for production
Expand All @@ -43,9 +49,11 @@
'orders',
'users',
'corsheaders',
'minicommerce.apps.CoreConfig',
]

MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
Expand All @@ -55,6 +63,7 @@
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'mozilla_django_oidc.middleware.SessionRefresh',
'django_prometheus.middleware.PrometheusAfterMiddleware',
]

AUTHENTICATION_BACKENDS = (
Expand Down Expand Up @@ -88,7 +97,7 @@

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'ENGINE': 'django_prometheus.db.backends.postgresql',
'NAME': environ('POSTGRES_DB', 'mydatabase'),
'USER': environ('POSTGRES_USER', 'myuser'),
'PASSWORD': environ('POSTGRES_PASSWORD', 'mypassword'),
Expand Down Expand Up @@ -151,6 +160,41 @@
],
}

# Structured logging to stdout so containers and collectors can capture logs
import logging

LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s %(levelname)s %(name)s %(message)s'
}
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'standard',
}
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
"minicommerce": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
}
}


OIDC_RP_SIGN_ALGO = 'RS256'
OIDC_OP_JWKS_ENDPOINT = environ('OIDC_OP_JWKS_ENDPOINT')
Expand Down
75 changes: 75 additions & 0 deletions backend/minicommerce/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import logging
import os

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
ConsoleSpanExporter,
SimpleSpanProcessor,
)

logger = logging.getLogger(__name__)


def _build_resource() -> Resource:
"""Builds the resource descriptor attached to every span."""
return Resource.create(
{
SERVICE_NAME: os.environ.get("OTEL_SERVICE_NAME", "minicommerce_api"),
SERVICE_VERSION: os.environ.get("APP_VERSION", "1.0.0"),
"deployment.environment": os.environ.get("APP_ENV", "development"),
}
)


def configure_tracing() -> None:
"""
Initialise the global TracerProvider.

Behaviour is driven entirely by environment variables so no code
changes are needed to switch between dev and prod:

APP_ENV=development → ConsoleSpanExporter (stdout, human-readable)
APP_ENV=production → OTLPSpanExporter (gRPC to collector)

Call this ONCE per process (Gunicorn worker post_fork hook handles that).
"""
app_env = os.environ.get("APP_ENV", "development")

provider = TracerProvider(resource=_build_resource())

if app_env == "production":
_attach_otlp_exporter(provider)
else:
_attach_console_exporter(provider)

trace.set_tracer_provider(provider)

logger.info("OpenTelemetry tracing configured [env=%s]", app_env)


def _attach_otlp_exporter(provider: TracerProvider) -> None:
"""Sends spans over gRPC to an OTel Collector / Jaeger / Tempo."""
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://otel-collector:4317")

exporter = OTLPSpanExporter(
endpoint=endpoint,
# insecure=True is fine inside a private Docker network;
# set OTEL_EXPORTER_OTLP_INSECURE=false and provide certs for public endpoints.
insecure=os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "true").lower() == "true",
)

# BatchSpanProcessor buffers & sends in background threads — correct for prod.
provider.add_span_processor(BatchSpanProcessor(exporter))
logger.info("OTLP exporter attached [endpoint=%s]", endpoint)


def _attach_console_exporter(provider: TracerProvider) -> None:
"""Prints spans to stdout — great for local dev / CI."""
# SimpleSpanProcessor flushes synchronously on every span — fine for dev.
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
logger.info("Console span exporter attached")
3 changes: 2 additions & 1 deletion backend/minicommerce/urls.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.contrib import admin
from django.urls import path, include
from mozilla_django_oidc.views import OIDCAuthenticationCallbackView, OIDCAuthenticationRequestView
# from mozilla_django_oidc.views import OIDCAuthenticationCallbackView, OIDCAuthenticationRequestView

urlpatterns = [
path('admin/', admin.site.urls),
path('orders/', include('orders.urls')),
path('user/', include('users.urls')),
path('auth/', include('rest_framework.urls', namespace='rest_framework')),
path('oidc/', include('mozilla_django_oidc.urls')),
path('', include('django_prometheus.urls')),
]
33 changes: 25 additions & 8 deletions backend/orders/sms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import logging
import africastalking
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
from minicommerce.settings import AT_USERNAME, AT_APIKEY

logger = logging.getLogger(__name__)


class SMS:
def __init__(self):
Expand All @@ -13,15 +18,27 @@ def __init__(self):

# Get the SMS service
self.sms = africastalking.SMS
# Tracer for SMS operations
self.tracer = trace.get_tracer(__name__)

def send(self, recipients: list, message: str) -> str:
try:
# send message
response = self.sms.send(message, recipients)['SMSMessageData']['Recipients'][0]['status']
print(f"Order message sent successful with status: {response}")
# return response
except Exception as e:
print(str(e))
def send(self, recipients: list, message: str) -> str | None:
# Create a span that represents the outbound SMS operation
with self.tracer.start_as_current_span("sms.send", attributes={
'sms.recipients': str(recipients),
'sms.message_length': len(message),
}) as span:
try:
# send message
response = self.sms.send(message, recipients)['SMSMessageData']['Recipients'][0]['status']
span.set_attribute('sms.status', str(response))
logger.info('Order message sent successfully', extra={'status': response, 'recipients': recipients})
return response
except Exception as e:
# record exception and mark span as error
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
logger.exception('Failed to send SMS')
return None


send_sms = SMS()
20 changes: 20 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,39 @@ cryptography==43.0.1
distlib==0.3.8
Django==5.1.1
django-cors-headers==4.4.0
django-prometheus==2.5.0
django-rest-framework==0.1.0
djangorestframework==3.15.2
filelock==3.16.1
flake8==7.1.1
googleapis-common-protos==1.75.0
grpcio==1.80.0
gunicorn==23.0.0
idna==3.10
josepy==1.14.0
markdown-it-py==3.0.0
mccabe==0.7.0
mdurl==0.1.2
mozilla-django-oidc==4.0.1
opentelemetry-api==1.42.1
opentelemetry-exporter-otlp==1.42.1
opentelemetry-exporter-otlp-proto-common==1.42.1
opentelemetry-exporter-otlp-proto-grpc==1.42.1
opentelemetry-exporter-otlp-proto-http==1.42.1
opentelemetry-instrumentation==0.63b1
opentelemetry-instrumentation-django==0.63b1
opentelemetry-instrumentation-logging==0.63b1
opentelemetry-instrumentation-wsgi==0.63b1
opentelemetry-proto==1.42.1
opentelemetry-sdk==1.42.1
opentelemetry-semantic-conventions==0.63b1
opentelemetry-util-http==0.63b1
packaging==24.1
pbr==6.1.0
platformdirs==4.3.6
pluggy==1.5.0
prometheus_client==0.25.0
protobuf==6.33.6
psycopg==3.2.3
psycopg-pool==3.2.4
pycodestyle==2.12.1
Expand All @@ -45,6 +63,8 @@ sqlparse==0.5.1
stevedore==5.3.0
tox==4.20.0
typing_extensions==4.12.2
tzdata==2026.2
urllib3==2.2.3
virtualenv==20.26.5
wheel==0.44.0
wrapt==2.2.1
Loading
Loading