What You'll Build
In this exercise you will add JSON-structured logging to the CricketPulse FastAPI service, deploy Loki and Promtail via docker-compose with Docker socket log collection, add the Loki datasource to Grafana, run LogQL queries against the collected logs, and verify the split-mode metric-to-log correlation workflow in Grafana Explore. By the end you will have a working three-stack observability setup: metrics in Prometheus, logs in Loki, both visible in Grafana.
Prerequisites
- Lesson 12 stack running (CricketPulse + Prometheus + Grafana) — docker compose ps showing all three Up
- Lessons 13-15 completed — understand Loki architecture, structured logging, and LogQL
- Docker socket accessible — Promtail needs /var/run/docker.sock for container discovery
- python-json-logger installed in the virtual environment
Setup — Add Loki and Promtail to the Stack
Extend the docker-compose.yml with Loki and Promtail services and create their configuration directories. The Docker socket volume mount gives Promtail access to container metadata and log streams without requiring any changes to the application containers.
# Starting from the Lesson 12 stack (Grafana + Prometheus + CricketPulse)
cd cricketpulse-metrics
pip install python-json-logger --break-system-packages
# Add Loki and Promtail to docker-compose.yml
# (append to services section)
cat >> docker-compose.yml << 'YAML'
loki:
image: grafana/loki:latest
ports:
- '3100:3100'
command: -config.file=/etc/loki/local-config.yaml
volumes:
- ./loki/loki-config.yml:/etc/loki/local-config.yaml
- loki-data:/loki
promtail:
image: grafana/promtail:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- ./promtail/promtail-config.yml:/etc/promtail/config.yml
command: -config.file=/etc/promtail/config.yml
volumes:
loki-data:
YAML
mkdir -p loki promtail
Step 1 — Add JSON Structured Logging
Create the logging_config.py module and update main.py to emit structured JSON logs with the mandatory fields (service, levelname, request_id, path, status_code, duration_ms). Test locally that log lines are valid JSON before deploying — the Promtail JSON pipeline stage will silently skip lines that are not valid JSON.
# app/logging_config.py
import logging, json, sys
from pythonjsonlogger import jsonlogger
class CricketPulseFormatter(jsonlogger.JsonFormatter):
def add_fields(self, log_record, record, message_dict):
super().add_fields(log_record, record, message_dict)
log_record['service'] = 'cricketpulse'
log_record['env'] = 'dev'
log_record['levelname'] = record.levelname
def configure_logging():
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(CricketPulseFormatter(
fmt='%(asctime)s %(name)s %(levelname)s %(message)s'
))
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(logging.INFO)
# app/main.py — update imports and add logging middleware
from app.logging_config import configure_logging
import logging, time, uuid, random
configure_logging()
logger = logging.getLogger('cricketpulse.http')
@app.middleware('http')
async def logging_middleware(request: Request, call_next):
request_id = str(uuid.uuid4())[:8]
start = time.time()
try:
response = await call_next(request)
logger.info('request', extra={
'request_id': request_id,
'method': request.method,
'path': str(request.url.path),
'status_code': response.status_code,
'duration_ms': round((time.time() - start) * 1000, 2)
})
return response
except Exception as exc:
logger.error('request_error', extra={
'request_id': request_id,
'path': str(request.url.path),
'error': str(exc)
})
raise
Step 2 — Configure Loki
Create the Loki configuration file defining the schema, storage backend, and ingestion limits. The single-binary mode configuration shown here is appropriate for development and small production deployments. The reject_old_samples_max_age: 168h (7 days) prevents accidentally ingesting historical logs that would cause Loki to allocate index entries outside the active window.
# loki/loki-config.yml
auth_enabled: false
server:
http_listen_port: 3100
ingester:
chunk_idle_period: 5m
max_chunk_age: 1h
schema_config:
configs:
- from: 2020-01-01
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/index
shared_store: filesystem
filesystem:
directory: /loki/chunks
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
chunk_store_config:
max_look_back_period: 0s
table_manager:
retention_deletes_enabled: false
retention_period: 0s
Step 3 — Configure Promtail and Deploy
Create the Promtail configuration to collect logs from all Docker containers via the Docker socket, promote service and levelname to Loki labels via relabelling and pipeline stages, and push to Loki. The Docker service discovery automatically finds all running containers and updates when containers start or stop.
# promtail/promtail-config.yml
server:
http_listen_port: 9080
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)' # strip leading slash
target_label: container
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
pipeline_stages:
- json:
expressions:
levelname: levelname
- labels:
levelname:
# Start the updated stack
docker compose up -d
docker compose logs promtail --tail=20
# Should see: 'Discovered new Docker target: cricketpulse'
Step 4 — Query Logs and Add to Grafana
Verify that logs are flowing into Loki using the API, then run LogQL queries in Grafana Explore to find errors and slow requests. Add the Loki datasource to Grafana provisioning and add a Logs panel to the service health dashboard. Finally, verify the split-mode correlation workflow.
# 1. Generate traffic including some errors
python3 generate_traffic.py
# 2. Query Loki API directly to confirm logs are ingested
curl -G 'http://localhost:3100/loki/api/v1/query' \
--data-urlencode 'query={service="cricketpulse"}' \
--data-urlencode 'limit=5' | python3 -m json.tool
# 3. In Grafana Explore, query the logs:
# {service='cricketpulse'} | json | status_code >= 400
# {service='cricketpulse'} | json | duration_ms > 100
# 4. Add Loki datasource to Grafana provisioning
# (add to grafana/provisioning/datasources/datasources.yml)
cat >> grafana/provisioning/datasources/datasources.yml << 'YAML'
- name: Loki
type: loki
uid: loki
access: proxy
url: http://loki:3100
YAML
docker compose restart grafana
# 5. Add a Logs panel to the existing dashboard:
# Type: Logs
# Datasource: Loki
# Query: {service='cricketpulse', levelname='ERROR'}
# Position: below Row 2 Trends
# 6. Verify split-mode correlation:
# Explore -> Split -> Left=Prometheus error rate, Right=Loki error logs
# Generate traffic -> watch both panels update
Warning: The Promtail Docker socket mount (/var/run/docker.sock:/var/run/docker.sock) gives Promtail root-equivalent access to the Docker daemon. In production, use a more restrictive log collection approach such as Docker log file tailing (/var/lib/docker/containers/*/...-json.log) or a dedicated log collector like Fluent Bit that does not require Docker socket access. Docker socket access allows the container to stop, start, or remove any container on the host.
Extension challenge: Add a LogQL alert rule to Loki's ruler component that fires when the rate of ERROR log lines exceeds 1 per second for any service. This requires adding ruler configuration to loki-config.yml and a rule file under /loki/rules/. Compare the alert firing time against the Prometheus HTTP error rate alert to verify that log-based alerts fire earlier for errors that occur before an HTTP response is returned.
- Install python-json-logger and use a custom Formatter to ensure all log lines are valid JSON.
- Promtail's Docker socket discovery automatically finds new containers without configuration changes.
- The pipeline_stages section promotes JSON fields to Loki labels at collection time.
- Verify Loki ingestion via the API before querying in Grafana — an empty result in Grafana could mean no data or a datasource misconfiguration.
- Split-mode Explore links Prometheus metrics and Loki logs via shared time range selection.
- Docker socket access in Promtail is a security concern — use file-based log tailing in production.