Webhooks — Architecture
Four distinct ways data moves through this app: async GitHub ingestion through a queue, direct synchronous handlers for every other source, read-only dashboard queries, and internally triggered background jobs. Each is a real, separate mechanism, not a stage of one pipeline — the map below draws all four side by side so it’s clear what’s shared (nginx, PHP-FPM, the database) and what isn’t (only GitHub goes through a queue).
The four flows
1. GitHub webhooks — async, queued
GitHub deliveries never hit this app’s HTTP endpoints directly. A separate service,
WebhooksHandler, validates the HMAC signature and
publishes onto the Broker’s github-exchange, a fanout bound to two independent Queues — one
publish, two consumers, each with its own Worker and failure domain (see
ADR-0008 and Broker Queues for
the full 12-queue map):
github, consumed by theserviceWorker: writes the raw event to thegithublog table (saveGitHubNotificationToDatabase()inSrc/lib/database.php) and atomically upserts itsgithub_statistics_6hbucket in the same code path (see ADR-0004), then composes and sends the outbound email/push Alert. It never touches an entity table.github-database, consumed by thedatabase-serviceWorker: runsReleaseTrackingHandlerfirst, watchingrelease/workflow_runevents across every repository (not just the GStraccini-bot App the rest of this queue is scoped to) — see ADR-0006; this is the persistence layer the futureprojects-monitorproject will read from. Only then, scoped to GStraccini-bot specifically, does it dispatch per event type to either the OOP pipeline (GitHubEventHandler→Library/*Repository.php, forcreate/delete,discussion,issues,pull_request) or the older procedural handlers inSrc/lib/bot.database.php(check_run, comments, installation, etc.), writing into whichever typedgithub_*table the event maps to.
Deduplication is a unique key on (DeliveryId, HookId, TargetId) — duplicate deliveries are caught
and discarded, not retried. If the Broker is unreachable, a circuit breaker (lib/CircuitBreaker.php)
trips and the payload is written to Src/failed/ instead of being lost.
Several Brokers may be configured ($rabbitMqConnectionStrings), one active at a time. When
WebhooksHandler rotates to another one on its own (its free-tier monthly limit), it tells this app
through POST /api/v1/broker/pending-switch, naming the target by host and virtual host. That only
records a Pending Switch: at the start of every consumer window each Worker checks whether the active
Broker’s four Queues and their -retry siblings are all empty, and the first one to find them so
flips the active Broker. New Events wait on the target Broker until then, so nothing is persisted out
of order. As a safety net, a Worker also takes whatever is left on any non-active Broker before its
window on the active one (see ADR-0012).
2. Every other webhook source — sync, direct HTTP
AppVeyor, CloudAMQP, DeepSource, GitGuardian, GitHub Status, HealthChecks.io, Oracle OCI,
workflow-webhook releases, SonarCloud, and Wise all post straight to this app’s own endpoints
(POST /<name>). nginx rewrites the request to index.php?handler=<name>, and Src/index.php
require_onces the matching flat, procedural script under Src/handlers/ — no queue, no separate
validator service. The handler saves to its table(s) and sends email/push notifications in the same
request, then returns 202.
3. Dashboard API — sync, read-only
GET /api/v1/... routes go through the same nginx → PHP-FPM runtime as flow 2, but to a dedicated
script under Src/api/v1/ instead of a handler. Every endpoint calls validateApiAccess() (checks
an Authorization: token <...> header) and responds via respondJson(), which flushes the response
to the client via fastcgi_finish_request() before any slower logging calls run — this app never
writes through this path, only reads.
4. Background jobs — internally triggered
Four workers (service, cleanup, database-service, maintenance) share one dispatcher,
runJob() (lib/runner.php), reachable three ways: a plain cron invocation, a long-lived
daemon loop under systemd, or a one-shot POST /api/v1/workers/{name}/run HTTP trigger. Whichever
mode fired it, the same runner function executes — e.g. runCleanupJob() calls cleanupTable() to
sweep expired rows per the retention windows in config/config.json, and pings HealthChecks.io as a
heartbeat. See Worker Schedules for each worker’s actual cadence.
Related decisions
- ADR-0003 — Compress
github.Payloadstorage - ADR-0004 —
github_statistics_6hbucketed statistics - ADR-0006 — Release + workflow tracking for
projects-monitor - Full ADR log for every recorded decision, not just the ones referenced above.
- Production VM Setup for how this all actually gets deployed and run.