Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/

## [Unreleased]

### Added

- Added `GITHUB_APP["LOG_ALL_EVENTS"]` setting to control webhook event logging. When `False`, only events with registered handlers are stored in the database.

## [0.6.1]

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ GITHUB_APP = {
"AUTO_CLEANUP_EVENTS": True,
"CLIENT_ID": "",
"DAYS_TO_KEEP_EVENTS": 7,
"LOG_ALL_EVENTS": True,
"NAME": "",
"PRIVATE_KEY": "",
"WEBHOOK_SECRET": "",
Expand Down Expand Up @@ -552,6 +553,24 @@ The GitHub App's client ID. Obtained when registering your GitHub App.

Number of days to retain webhook events before cleanup. Used by both automatic cleanup (when [`AUTO_CLEANUP_EVENTS`](#auto_cleanup_events) is `True`) and the `EventLog.objects.acleanup_events` manager method.

### `LOG_ALL_EVENTS`

> **Optional** | `bool` | Default: `True`

Controls whether all webhook events are stored in the database, or only events that have registered handlers.

When `True` (default), all webhook events sent to your webhook endpoint are stored as `EventLog` entries, providing a complete audit trail. This is useful for debugging and compliance purposes.

When `False`, only events that have registered handlers (via `@router.event()` decorators) are stored. This can significantly reduce database usage for high-traffic GitHub Apps, especially those receiving many events they don't process (e.g., the numerous pull request sub-events like "labeled", "unlabeled", etc.).

Example:
```python
GITHUB_APP = {
# ... other settings ...
"LOG_ALL_EVENTS": False, # Only store events with handlers
}
```

### `NAME`

> 🔴 **Required** | `str`
Expand Down
1 change: 1 addition & 0 deletions src/django_github_app/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class AppSettings:
AUTO_CLEANUP_EVENTS: bool = True
CLIENT_ID: str = ""
DAYS_TO_KEEP_EVENTS: int = 7
LOG_ALL_EVENTS: bool = True
NAME: str = ""
PRIVATE_KEY: str = ""
WEBHOOK_SECRET: str = ""
Expand Down
42 changes: 25 additions & 17 deletions src/django_github_app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,11 @@ def get_github_api(self, installation: Installation | None) -> GitHubAPIType:
installation_id = getattr(installation, "installation_id", None)
return self.github_api_class(requester, installation_id=installation_id)

def get_response(self, event_log: EventLog) -> JsonResponse:
return JsonResponse(
{
"message": "ok",
"event_id": event_log.id,
}
)
def get_response(self, event_log: EventLog | None) -> JsonResponse:
response_data: dict[str, int | str] = {"message": "ok"}
if event_log:
response_data["event_id"] = event_log.id
return JsonResponse(response_data)

@property
def router(self) -> GitHubRouter:
Expand All @@ -80,12 +78,17 @@ async def post(self, request: HttpRequest) -> JsonResponse:
if app_settings.AUTO_CLEANUP_EVENTS:
await EventLog.objects.acleanup_events()

event_log = await EventLog.objects.acreate_from_event(event)
installation = await Installation.objects.aget_from_event(event)
found_callbacks = self.router.fetch(event)

async with self.get_github_api(installation) as gh:
await gh.sleep(1)
await self.router.adispatch(event, gh)
event_log = None
if app_settings.LOG_ALL_EVENTS or found_callbacks:
event_log = await EventLog.objects.acreate_from_event(event)

if found_callbacks:
installation = await Installation.objects.aget_from_event(event)
async with self.get_github_api(installation) as gh:
await gh.sleep(1)
await self.router.adispatch(event, gh)

return self.get_response(event_log)

Expand All @@ -100,11 +103,16 @@ def post(self, request: HttpRequest) -> JsonResponse: # pragma: no cover
if app_settings.AUTO_CLEANUP_EVENTS:
EventLog.objects.cleanup_events()

event_log = EventLog.objects.create_from_event(event)
installation = Installation.objects.get_from_event(event)
found_callbacks = self.router.fetch(event)

event_log = None
if app_settings.LOG_ALL_EVENTS or found_callbacks:
event_log = EventLog.objects.create_from_event(event)

with self.get_github_api(installation) as gh:
time.sleep(1)
self.router.dispatch(event, gh)
if found_callbacks:
installation = Installation.objects.get_from_event(event)
with self.get_github_api(installation) as gh:
time.sleep(1)
self.router.dispatch(event, gh)

return self.get_response(event_log)
1 change: 1 addition & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
("AUTO_CLEANUP_EVENTS", True),
("CLIENT_ID", ""),
("DAYS_TO_KEEP_EVENTS", 7),
("LOG_ALL_EVENTS", True),
("NAME", ""),
("PRIVATE_KEY", ""),
("WEBHOOK_SECRET", ""),
Expand Down
127 changes: 109 additions & 18 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ def _make_request(
if body is None:
body = {}

body_json = json.dumps(body).encode("UTF-8")
hmac_obj = hmac.new(
secret.encode("UTF-8"),
msg=json.dumps(body).encode("UTF-8"),
msg=body_json,
digestmod="sha256",
)
signature = f"sha256={hmac_obj.hexdigest()}"
Expand All @@ -59,7 +60,7 @@ def _make_request(

request = rf.post(
"/webhook/",
data=body,
data=body_json,
content_type="application/json",
**headers,
)
Expand Down Expand Up @@ -184,8 +185,9 @@ async def test_csrf_exempt(self, webhook_request):

assert response.status_code != HTTPStatus.FORBIDDEN

async def test_event_log_created(self, webhook_request):
request = webhook_request()
async def test_event_log_created(self, webhook_request, aregister_webhook_event):
aregister_webhook_event("push")
request = webhook_request(event_type="push")
view = AsyncWebhookView()

response = await view.post(request)
Expand Down Expand Up @@ -237,15 +239,59 @@ async def test_router_dispatch(self, aregister_webhook_event, webhook_request):
assert isinstance(webhook_data["gh"], AsyncGitHubAPI)

async def test_router_dispatch_unhandled_event(
self, aregister_webhook_event, webhook_request
self, monkeypatch, aregister_webhook_event, override_app_settings
):
aregister_webhook_event("push", should_fail=True)
request = webhook_request(event_type="issues", body={"action": "opened"})
view = AsyncWebhookView()
with override_app_settings(LOG_ALL_EVENTS=False):
aregister_webhook_event("push", should_fail=True)
view = AsyncWebhookView()

response = await view.post(request)
data = {"action": "opened"}
event = sansio.Event(data, event="issues", delivery_id="12345")

assert response.status_code == HTTPStatus.OK
monkeypatch.setattr(view, "get_event", lambda request: event)

response = await view.post(None)

assert response.status_code == HTTPStatus.OK
assert json.loads(response.content) == {"message": "ok"}

async def test_unhandled_event_log_creation_with_log_all(
self, monkeypatch, aregister_webhook_event, override_app_settings
):
with override_app_settings(LOG_ALL_EVENTS=True):
aregister_webhook_event("push", should_fail=True)
view = AsyncWebhookView()

data = {"action": "opened"}
event = sansio.Event(data, event="issues", delivery_id="12345")

monkeypatch.setattr(view, "get_event", lambda request: event)

count_before = await EventLog.objects.acount()

await view.post(None)

count_after = await EventLog.objects.acount()
assert count_after - count_before == 1

async def test_unhandled_event_log_creation_without_log_all(
self, monkeypatch, aregister_webhook_event, override_app_settings
):
with override_app_settings(LOG_ALL_EVENTS=False):
aregister_webhook_event("push", should_fail=True)
view = AsyncWebhookView()

data = {"action": "opened"}
event = sansio.Event(data, event="issues", delivery_id="12345")

monkeypatch.setattr(view, "get_event", lambda request: event)

count_before = await EventLog.objects.acount()

await view.post(None)

count_after = await EventLog.objects.acount()
assert count_after - count_before == 0


class TestSyncWebhookView:
Expand All @@ -266,8 +312,9 @@ def test_csrf_exempt(self, webhook_request):

assert response.status_code != HTTPStatus.FORBIDDEN

def test_event_log_created(self, webhook_request):
request = webhook_request()
def test_event_log_created(self, webhook_request, register_webhook_event):
register_webhook_event("push")
request = webhook_request(event_type="push")
view = SyncWebhookView()

response = view.post(request)
Expand Down Expand Up @@ -305,12 +352,56 @@ def test_router_dispatch(self, register_webhook_event, webhook_request):
assert isinstance(webhook_data["gh"], SyncGitHubAPI)

def test_router_dispatch_unhandled_event(
self, register_webhook_event, webhook_request
self, monkeypatch, register_webhook_event, override_app_settings
):
register_webhook_event("push", should_fail=True)
request = webhook_request(event_type="issues", body={"action": "opened"})
view = SyncWebhookView()
with override_app_settings(LOG_ALL_EVENTS=False):
register_webhook_event("push", should_fail=True)
view = SyncWebhookView()

response = view.post(request)
data = {"action": "opened"}
event = sansio.Event(data, event="issues", delivery_id="12345")

assert response.status_code == HTTPStatus.OK
monkeypatch.setattr(view, "get_event", lambda request: event)

response = view.post(None)

assert response.status_code == HTTPStatus.OK
assert json.loads(response.content) == {"message": "ok"}

def test_unhandled_event_log_creation_with_log_all(
self, monkeypatch, register_webhook_event, override_app_settings
):
with override_app_settings(LOG_ALL_EVENTS=True):
register_webhook_event("push", should_fail=True)
view = SyncWebhookView()

data = {"action": "opened"}
event = sansio.Event(data, event="issues", delivery_id="12345")

monkeypatch.setattr(view, "get_event", lambda request: event)

count_before = EventLog.objects.count()

view.post(None)

count_after = EventLog.objects.count()
assert count_after - count_before == 1

def test_unhandled_event_log_creation_without_log_all(
self, monkeypatch, register_webhook_event, override_app_settings
):
with override_app_settings(LOG_ALL_EVENTS=False):
register_webhook_event("push", should_fail=True)
view = SyncWebhookView()

data = {"action": "opened"}
event = sansio.Event(data, event="issues", delivery_id="12345")

monkeypatch.setattr(view, "get_event", lambda request: event)

count_before = EventLog.objects.count()

view.post(None)

count_after = EventLog.objects.count()
assert count_after - count_before == 0