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.
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 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)
),
})
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()),
]
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.message → chat_message) to the handler automatically.
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.
logger.info("processing item 42")BunnyLogsHandler POSTs to https://bunnylogs.com/live/<uuid>live() view receives the POST and calls channel_layer.group_send()LogsConsumer in the groupThe entire round-trip from logger.info() to the browser typically takes 50–200ms.
# 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.
April 15, 2025
April 8, 2025