Ships GitLab projects, pipelines, jobs, and full job traces into an existing Grafana Loki instance, so CI history stays searchable in Grafana after GitLab's own retention expires.
Three producers feed one collector pool:
- Webhook — a group-level GitLab webhook delivers pipeline and job events in near real time.
- Discovery — every
DISCOVERY_INTERVAL, the configured groups are walked to pick up new projects. - Sweeper — every
SWEEP_INTERVAL, the most recentPIPELINES_PER_PROJECTpipelines of every known project are checked for jobs that were missed. This also performs the initial backfill. Projects are swept concurrently, up toSWEEP_CONCURRENCYat a time.
A SQLite ledger at STATE_PATH records which jobs have shipped, so webhook and
sweep deliveries of the same job collapse into one. It also records when a
settled pipeline's jobs have all shipped, so later sweeps skip re-listing that
pipeline's jobs entirely — this is what keeps steady-state sweeps cheap.
Only jobs in a terminal state (success, failed, canceled, skipped) are
collected — a running job's trace is incomplete.
- Docker with the Compose plugin.
- A reachable Loki instance, and a Grafana with that Loki configured as a datasource.
- A self-managed GitLab instance, and permission to create a group access token and a group webhook.
Group → Settings → Access tokens → Add new token
- Scope:
read_api— nothing more. The service only issues GET requests. - Role: Reporter or above on every group you want collected.
A personal access token works too, but a group token limits the blast radius and does not vanish when someone leaves.
If you configure several groups in GITLAB_GROUPS, the token must be able to
read all of them.
To collect every group instead (see GITLAB_GROUPS=* below), the token must
be able to see every group on the instance. A group access token only sees
its own group, so * generally needs a personal access token with
read_api — or an admin token to cover the whole instance.
git clone git@github.com:kpanuragh/gitlab_logger.git
cd gitlab_logger
cp .env.example .env
$EDITOR .envFive values are required; everything else has a working default:
GITLAB_URL=https://git.example.com
GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
GITLAB_GROUPS=platform,infra # comma-separated, subgroups included
LOKI_URL=http://loki:3100
WEBHOOK_SECRET=$(openssl rand -hex 32)The service refuses to start if any of them is missing, naming the one it
wants. .env is excluded from git and from the Docker build context — keep it
that way.
Collecting every group. Set GITLAB_GROUPS=* instead of a list to collect
every top-level group the token can see, with no other groups allowed
alongside it. The group list is re-resolved every DISCOVERY_INTERVAL
(default 1h), so a group created after startup is picked up automatically —
no config edit, no restart. A group removed or renamed similarly drops out of
collection on the next successful discovery pass; a discovery pass that fails
to even list the groups changes nothing, so a transient outage never wipes
what was already found. Note that * walks groups, so projects living in
personal namespaces (a user's own project, not inside any group) are never
included.
Do this before starting the service. The first sweep backfills history, and
those entries carry their real timestamps — often months old. Loki rejects
anything older than reject_old_samples_max_age (default 168h, one week),
so with stock settings most of your backfill is silently dropped.
In your Loki config:
limits_config:
reject_old_samples: false # or raise reject_old_samples_max_age
max_global_streams_per_user: 20000 # default 5000; see the note below
allow_structured_metadata: true # required; see below
discover_service_name: [] # stop Loki inventing a service_name label
discover_log_levels: false
max_query_length: 4380h # default 30d1h is too short to query CI history
max_query_lookback: 4380h
ingester:
max_chunk_age: 8760h # out-of-order window is half of thismax_query_length counts the window plus the range selector. A 30-day
dashboard using [$__range] costs 60 days, so the 30d1h default rejects a
30-day dashboard with the query time range exceeds the limit.
Two separate age limits, and the second one is the one that bites.
reject_old_samples governs age relative to now. Separately, an entry may
not be written more than max_chunk_age / 2 behind the newest entry already
in its stream. At the 2h default that window is one hour — so as soon as a
project's most recent pipeline is ingested, every older pipeline for that
project is refused with entry too far behind, and the backfill fails even
though reject_old_samples is off. A year of max_chunk_age allows roughly
six months of backfill.
Structured metadata is mandatory, not optional. This service attaches
job_id, pipeline_id, sha and friends as structured metadata, which Loki
only accepts on schema v13 with the tsdb store. On an older schema every
push fails with a 400 and nothing is ingested at all.
Stream count. Job traces are labelled by project, job_name, stage and
status, so the stream count is roughly projects × distinct job names ×
statuses. Several hundred projects can pass Loki's default 5000 limit. If you
would rather not raise it, drop job_name and stage from the labels in
internal/transform/stream.go — they remain queryable as structured metadata.
docker compose up -d --build
docker compose logs -f gitlab-loggerCheck it came up:
curl -s localhost:8080/healthz # "ok" — process is alive
curl -s localhost:8080/readyz # "ready" — GitLab and Loki are both reachableIf /readyz returns 503 it names the dependency that failed. The most common
causes are a wrong LOKI_URL from inside the container, or a token missing
read_api.
Discovery and the first sweep start immediately. Within a minute or so the logs
show discovery complete with a project count, then job shipped lines.
In Grafana, against your Loki datasource:
{kind="job_trace"}
Then a real one — the last hour of failures across everything:
{kind="pipeline_event"} | json | event="job_finished" | status="failed"
The first sweep is the expensive one: it walks up to
PIPELINES_PER_PROJECT pipelines for every project. Depending on how much
history you have, a full backfill can take a while and is bounded by
GITLAB_RPS. Steady state is far cheaper — completed pipelines are recorded in
the ledger and skipped from then on.
Everything works without this — the sweeper collects on its own — but a webhook
cuts collection lag from up to SWEEP_INTERVAL down to seconds.
In GitLab: Group → Settings → Webhooks → Add new webhook
- URL:
http://<host-reachable-from-gitlab>:8080/webhook - Secret token: the exact value of
WEBHOOK_SECRET - Trigger: tick Pipeline events and Job events
Use "Test → Job events" to confirm delivery — GitLab shows the response code
inline. A 401 means the secret does not match. To see the service side, set
LOG_LEVEL=debug and watch for webhook handled, which logs at debug level.
One group-level webhook covers every project in that group, including ones created later.
docker compose up -d # survives reboots via restart: unless-stoppedThe ledger lives in the state volume. Do not delete it casually — it is what
stops the service re-shipping everything it has already collected.
docker-compose.yml above is the production path: it runs the collector
alone, against a Loki you already operate. For evaluating gitlab-logger
without standing up your own Grafana/Loki first, docker-compose.stack.yml
brings up the whole thing in one shot — Loki, Grafana, and the collector —
with Grafana pre-provisioned so CI data is visible with no manual datasource
or dashboard setup.
docker compose -f docker-compose.stack.yml up -d --buildThen open Grafana at http://localhost:3001 (admin / admin — override
the password with GRAFANA_ADMIN_PASSWORD) and look at the "GitLab CI"
dashboard. The stack's Loki is pre-configured with the schema and limits this
service needs (see deploy/loki/loki-config.yaml), so there is nothing to
tune before the first run.
This stack still reads .env for GitLab credentials, but LOKI_URL is
overridden in docker-compose.stack.yml to point at the in-stack Loki
regardless of what .env says. It uses its own named volumes, so it never
shares a ledger with a docker-compose.yml deployment running alongside it.
Note that a GitLab webhook cannot reach a locally-running collector —
GitLab has no route to localhost on your machine — so a local run depends
entirely on the sweeper to pick up data. Give it a few minutes after startup;
see step 5 above for what to expect.
Tear it down (including its data) with:
docker compose -f docker-compose.stack.yml down -v| Symptom | Likely cause |
|---|---|
/readyz 503 naming loki |
LOKI_URL unreachable from inside the container — localhost there is not your host |
/readyz 503 naming gitlab |
Token lacks read_api, or GITLAB_URL is wrong |
| Starts, but no data in Grafana | Backfill rejected as too old — see step 3 |
| Only recent jobs appear | Same cause: reject_old_samples_max_age |
429 warnings in the logs |
Lower GITLAB_RPS; the service backs off and retries on its own |
| Webhook shows 401 in GitLab | WEBHOOK_SECRET mismatch |
| Sweeps never finish | Raise SWEEP_CONCURRENCY and GITLAB_RPS, or lower PIPELINES_PER_PROJECT |
job abandoned after max attempts |
A job failed MAX_ATTEMPTS times. Fix the cause, then DELETE FROM jobs WHERE state='dead'; in the ledger to let it retry |
| Variable | Default | Purpose |
|---|---|---|
GITLAB_URL |
required | Base URL, e.g. https://git.example.com |
GITLAB_TOKEN |
required | Token with read_api scope |
GITLAB_GROUPS |
required | Comma-separated group paths, or * for every group the token can see |
LOKI_URL |
required | e.g. http://loki:3100 |
LOKI_TENANT_ID |
unset | Sets X-Scope-OrgID for multi-tenant Loki |
WEBHOOK_SECRET |
required | Compared against X-Gitlab-Token |
WEBHOOK_ADDR |
:8080 |
Listen address |
PIPELINES_PER_PROJECT |
100 |
Sweep/backfill depth per project |
SWEEP_CONCURRENCY |
8 |
Projects swept in parallel |
DISCOVERY_INTERVAL |
1h |
Group re-walk interval |
SWEEP_INTERVAL |
15m |
Safety-net poll interval |
WORKERS |
4 |
Concurrent job collectors |
GITLAB_RPS |
10 |
Client-side API rate limit |
MAX_TRACE_BYTES |
5242880 |
Trace cap; the middle is elided past this |
MAX_ATTEMPTS |
3 |
Attempts before a job is marked dead (see below) |
RETRY_DELAY |
15s |
Delay before retrying a job collection failure |
STATE_PATH |
/data/state.db |
SQLite ledger |
LOG_LEVEL |
info |
debug, info, warn, error |
The two knobs that matter at this scale are SWEEP_CONCURRENCY and
GITLAB_RPS:
SWEEP_CONCURRENCYcontrols how many projects are swept in parallel. Sweeping is latency-bound — most of the time is spent waiting on GitLab's response, not on CPU — so raising this hides that latency instead of leaving most of a sweep interval idle.GITLAB_RPSis a single client-side rate limiter shared across every goroutine, sweeper and collector alike. RaisingSWEEP_CONCURRENCYtherefore cannot push more requests per second at GitLab thanGITLAB_RPSallows; it only lets more of those requests be in flight at once. RaiseGITLAB_RPSitself if you want a higher ceiling, and check with GitLab about the API rate limit applied to your token first.
With 200+ projects, size SWEEP_INTERVAL so a full sweep round comfortably
finishes within it, and see the first note under Operational notes below: the
first sweep after startup is far more expensive than every one after it.
Read a job's log:
{kind="job_trace", project="platform/api", job_name="unit-tests", status="failed"}
One specific job:
{kind="job_trace", project="platform/api"} | job_id="40231"
Failed jobs per project, last hour:
sum by (project) (
count_over_time({kind="pipeline_event"} | json | event="job_finished" | status="failed" [1h])
)
p95 job duration by job name:
quantile_over_time(0.95,
{kind="pipeline_event"} | json | event="job_finished"
| unwrap duration_sec [1h]
) by (project)
Search all failures for a message:
{kind="job_trace", status="failed"} |= "connection refused"
Labels are deliberately few, because Loki performance depends on keeping stream count low:
- Labels:
kind,gitlab,group,project, plusjob_name,stage, andstatuson job traces. - Structured metadata (filter with
| job_id="…"):job_id,pipeline_id,sha,ref,user,runner,web_url.
If your Loki is shared and tight on stream limits, dropping job_name and
stage from the labels in internal/transform/stream.go reduces stream count
substantially.
go test ./... -race
go build ./...The end-to-end test starts a real Loki container and needs Docker:
go test -tags e2e ./test/e2e/ -v -timeout 10mEvery push and pull request runs .github/workflows/ci.yml:
- test — build,
go vet, agofmt -lcheck, and the full test suite under-race. - e2e — the Docker-dependent end-to-end test above, on a
ubuntu-latestrunner (Docker is preinstalled there). - publish — only after
testande2eboth pass, and only on pushes tomainorv*tags (never on pull requests), a multi-arch (linux/amd64,linux/arm64) image is built and pushed toghcr.io/kpanuragh/gitlab_logger.
Pull the published image instead of building locally:
docker pull ghcr.io/kpanuragh/gitlab_logger:latestservices:
gitlab-logger:
image: ghcr.io/kpanuragh/gitlab_logger:latest
restart: unless-stopped
env_file: .env
ports:
- "8080:8080"
volumes:
- state:/data
volumes:
state:The package defaults to private on its first publish. Make it public once, either from the repo's Packages page or:
gh api --method PATCH /user/packages/container/gitlab_logger -f visibility=public- The first sweep is the expensive one; steady state is cheap. On a fresh deployment (or after a gap), the sweeper lists every pipeline's jobs to backfill history — this is the costly path. Once a settled pipeline's jobs have all shipped, the ledger marks it done and later sweeps skip re-listing its jobs entirely, so steady-state sweeps only re-check pipelines that are still open or new. Expect the first sweep after startup, or after adding a large group, to take noticeably longer and issue far more GitLab API calls than every sweep after it.
- A pipeline with no
created_atand noupdated_atgets no pipeline-level event. It cannot be de-duplicated without a timestamp to key it on, so emitting one would just accumulate duplicate lines in Loki on every sweep. Its jobs and traces are still collected and shipped normally; only thepipeline_finishedrecord for that pipeline is skipped. - A job that fails
MAX_ATTEMPTStimes is markeddeadand stops being retried. Its pipeline, however, is deliberately NOT sealed: a settled pipeline is only marked done once every terminal job has actually shipped, so a pipeline containing a dead job is re-listed on every sweep. This costs one extra job listing per sweep for that pipeline, but it means clearing the dead row from the SQLite ledger (or fixing whatever made the job unrecoverable) lets the next sweep pick it up again, instead of losing it and its pipeline permanently. Dead jobs should be rare; check logs for"job abandoned after max attempts"to find them. - Restarts are safe. The ledger persists in the
/datavolume; work resumes where it stopped. - Delivery is at-least-once. A job is only marked shipped after Loki accepts it, so a crash mid-push results in a re-send. Loki de-duplicates identical entries within a stream.
- Single replica. The SQLite ledger assumes one writer. Scale with
WORKERSandSWEEP_CONCURRENCY, not with replicas.