Pain-Point SEO

WebSocket log streaming in Django with Channels

May 1, 2025 BunnyLogs Team

BunnyLogs works by accepting log entries over HTTP and delivering them to every connected browser over WebSocket — typically within a few hundred milliseconds. This post is a technical walkthrough of how that works, using Django Channels, Redis, and ASGI.

The problem

HTTP is request-response: the browser asks, the server answers. For real-time streaming you need the server to push data to the browser unprompted. WebSocket solves this, but Django's WSGI architecture isn't built for long-lived connections. You need ASGI.

The stack

  • Django 5 — the web framework
  • Django Channels — adds WebSocket support to Django via ASGI
  • Redis 7 — the channel layer backend (pub/sub message bus)
  • Uvicorn — ASGI server (replaces Gunicorn)

ASGI setup

The core/asgi.py file uses ProtocolTypeRouter to route HTTP requests to Django and WebSocket connections to Channels:

# core/asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import logs.routing

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(logs.routing.websocket_urlpatterns)
    ),
})

WebSocket routing

The WebSocket URL pattern maps a UUID path segment to the LogsConsumer:

# logs/routing.py
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/logs/(?P<group>[0-9a-f-]+)/$", consumers.LogsConsumer.as_asgi()),
]

The consumer

When a browser connects to ws://bunnylogs.com/ws/logs/<uuid>/, a LogsConsumer instance is created. It joins the Redis channel group for that UUID and waits for messages:

# logs/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class LogsConsumer(AsyncWebsocketConsumer):

    async def connect(self):
        self.group_name = self.scope["url_route"]["kwargs"]["group"]
        await self.channel_layer.group_add(self.group_name, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.group_name, self.channel_name)

    async def chat_message(self, event):
        await self.send(text_data=json.dumps(event["message"]))

The chat_message method is called whenever a message is published to the group — Channels maps the event type (chat.messagechat_message) to the handler automatically.

Ingesting a log entry

When an external service POSTs a log entry to /live/<uuid>, the view publishes it to the Redis channel group:

# logs/views.py (simplified)
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from django.views.decorators.csrf import csrf_exempt

channel_layer = get_channel_layer()

@csrf_exempt
def live(request, group):
    if request.method == "POST":
        message = {
            "message": request.POST.get("message", ""),
            "level": request.POST.get("level", "INFO"),
            "program": request.POST.get("program", "api"),
        }
        async_to_sync(channel_layer.group_send)(
            str(group),
            {"type": "chat.message", "message": message},
        )
        return HttpResponse(status=204)

Redis fans this event out to every consumer in the group. Each consumer calls chat_message, which sends the JSON payload over the open WebSocket to the connected browser.

The full flow

  1. A Python script calls logger.info("processing item 42")
  2. BunnyLogsHandler POSTs to https://bunnylogs.com/live/<uuid>
  3. Django's live() view receives the POST and calls channel_layer.group_send()
  4. Redis delivers the message to every LogsConsumer in the group
  5. Each consumer sends the payload over its open WebSocket
  6. The browser receives the message and appends a log line to the UI

The entire round-trip from logger.info() to the browser typically takes 50–200ms.

Channel layer configuration

# settings.py
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("redis", 6379)],
        },
    },
}

Redis acts as the message bus between the HTTP-handling process and the WebSocket-handling process — they can be different Uvicorn workers or even different machines.

See it live at BunnyLogs →


Related posts