DEV Community

Cover image for 🦊 GitLab CI Job Logs: The Art of Self-Documenting Pipelines
Benoit COUETIL πŸ’« for Zenika

Posted on

🦊 GitLab CI Job Logs: The Art of Self-Documenting Pipelines

The best CI/CD engineer is one who becomes unnecessary. If developers constantly ping us to understand why a pipeline failed, our pipelines are not doing their job. With the right logging strategy, every failure becomes a self-service debugging experience β€” and we get to work on actually interesting problems.

Initial thoughts

We have all been there: a developer opens a ticket saying "the pipeline is broken", and after 15 minutes of scrolling through a wall of undifferentiated text, we find that one missing failing test buried in line 847. 99% of the time, after pipelines have been stabilised, the culprit is the developer's code. Else is momentary and/or infrastructure problem.

The goal is simple: a developer should be able to diagnose and fix 90% of pipeline failures without ever pinging the CI/CD team. This is not about writing less code or cutting corners. It is about treating CI/CD logs as a user interface β€” one that developers interact with daily.

After years of maintaining pipelines across dozens of projects and as discussed in GitLab CI: 10+ Best Practices to Avoid Widespread Anti-Patterns, we have distilled a collection of practices that consistently make the difference between "I need help" and "I already fixed it".

Prerequisite: clear stage and job names

Self-documenting logs make even more sense on top of a pipeline that is already readable at a glance. Stage and job names must be clear, specific, and kept to a just-right count β€” enough to tell the story of what CI/CD is doing when we watch it run, not so many that the pipeline graph becomes noise.

On a typical three-tier app β€” frontend, BFF, and API β€” deployed to Kubernetes, an MR pipeline already reads like a table of contents:

Diagram

Four stages, eleven jobs β€” a developer glancing at the pipeline UI knows exactly where they are: package, test, deploy to K8s, validate. The logging practices below assume this foundation is in place.

1. Enable GitLab's built-in logging features

Before writing a single echo, GitLab already offers several feature flags and variables that dramatically improve log readability. These quick wins cost nothing β€” just a few lines in our top-level variables: block:

variables:
  # prepend ISO 8601 timestamps to every line
  FF_TIMESTAMPS: "true"
  # auto-wrap each script command in a collapsible section
  FF_SCRIPT_SECTIONS: "true"
  # smoother real-time log streaming
  FF_USE_DYNAMIC_TRACE_FORCE_SEND_INTERVAL: "true"
  # convention honored by npm, jest, webpack, dotnet...
  FORCE_COLOR: 1
Enter fullscreen mode Exit fullscreen mode

FF_TIMESTAMPS gives us millisecond-precision timing on every line β€” invaluable for spotting slow steps without manually adding timers. FF_SCRIPT_SECTIONS automatically wraps each script line in a collapsible section, giving structure for free. And FORCE_COLOR: 1 is a widely adopted convention that tells tools to output color even when they detect a non-TTY environment β€” without it, most CI output falls back to monochrome.

Timestamps, color, and structure β€” before we even start customizing. Welcome to the "free improvements" club.

2. Choose the right CI log level β€” and stick to it

The most common mistake is binary logging: either we print everything, or we print nothing. Both are equally useless.

A CI job should follow the same log level discipline as any application:

Level When to use Example
TRACE debug data, usually hidden Variable expansion: $DEPLOY_ENV β†’ staging
INFO Key steps the developer needs to follow Deploying service user-api to staging...
WARNING Something unexpected but non-blocking Cache miss for node_modules, full install triggered
ERROR Something failed that needs attention Deployment failed: health check timeout after 120s

In a real deploy job, the four levels read like a 10-second story (more on how to do this later) :

[TRACE] Variable expansion: $DEPLOY_ENV β†’ staging
[INFO] Deploying service user-api to staging...
[WARNING] Cache miss for node_modules, full install triggered
[ERROR] Deployment failed: health check timeout after 120s

The golden rule: a successful pipeline should produce a few lines of INFO, not 500. If we need more detail, we wrap verbose output in collapsible sections (see section 4).

The key is a centralized log function shared across all jobs. Write it once in a sourced helper script (source ci/utils.sh), and every job gets consistent formatting for free:

log() {
  local level="${1:-INFO}"; local message="$2"
  declare -A color_map
  color_map["TRACE"]="\033[38;5;250m"     # gray
  color_map["INFO"]="\033[1;94m"          # bright blue
  color_map["WARNING"]="\033[1;38;5;214m" # bold orange
  color_map["ERROR"]="\033[48;5;1m"       # background red
  printf "${color_map[$level]}[%s] %s\033[0m\n" "$level" "$message"
}
Enter fullscreen mode Exit fullscreen mode

No need to repeat color codes in every .gitlab-ci.yml. A simple source ci/utils.sh in before_script and the entire team speaks the same logging language.

Too much logging is noise. Too little is silence. The sweet spot is a concise narrative that a developer can read in 10 seconds and understand exactly what happened β€” like a well-written commit message, but for runtime.

orange fox engineer handing a glowing instruction manual to a group of curious developers, anime style, warm lighting, CI/CD pipeline hologram in the background

3. Bring color to your logs

Colors used by GitLab Runner β€” avoid these for custom messages, as they blend with the runner's own output:

Color Shell code Hex example Description
Bold Green \033[1;32m #5cf759 Runner command echoes
Bold Cyan \033[1;36m #00bdbd Runner generic messages
Bold White \033[1;37m #ffffff Runner neutral outputs
Bold Red \033[1;31m #ff6161 Runner error messages

In a real job log, the runner's four colors look like this:

Using docker executor with image node:20-alpine
$ docker compose build --parallel
$ docker compose up -d
  #1 [web] building with "buildx"
  #2 [api] building with "buildx"
Cleaning up project directory and file based variables
ERROR: Job failed: exit status 1

Suggested colors for custom log messages:

Color Shell code Hex example Description
Gray \033[38;5;250m #bcbcbc TRACE β€” verbose/debug output, present but discreet
Bold Bright Blue \033[1;94m #5797ff INFO β€” normal flow narrative and informational steps
Bold Orange \033[1;38;5;214m #ffaf00 WARNING β€” alternative for bolder warnings
Background Red \033[48;5;1m #ff6161 ERROR β€” stands out from GitLab's own bold red errors
Bold Bright Magenta \033[1;95m #8e44ad Situational
Bold Bright Yellow \033[1;93m #f4d03f Situational
TRACE β€” verbose/debug output, present but discreet
INFO β€” normal flow narrative and informational steps
WARNING β€” alternative for bolder warnings
ERROR β€” stands out from GitLab's own bold red errors
Situational
Situational

A monochrome wall of text is hostile to human eyes. Color creates a visual hierarchy that lets developers spot errors in seconds instead of reading every line.

GitLab job logs support ANSI escape codes natively. Combined with FORCE_COLOR: 1 from section 1, most tools will also output color automatically. For our custom messages, the centralized log function from section 2 already handles this.

Why background red instead of bold red for errors? Because when a job fails, GitLab's runner already prints its own error in plain bold red β€” and those messages are almost never helpful:

  #3 [api 3/4] RUN go build -o /app ./cmd/server
  #3 ERROR: process "go build" did not complete successfully
  > [api 3/4] RUN go build -o /app ./cmd/server:
  cmd/server/main.go:42:18: undefined: handlers.NewRouter
[10:15:22] Build failed for service 'api', please fix above error(s)
Cleaning up project directory and file based variables
ERROR: Job failed: exit status 1

By using background red for our own errors, the developer can instantly distinguish our actionable messages from the runner's generic noise. The white-on-red line is our message β€” it says what failed and what to fix. Everything else is the runner repeating "something threw an exception somewhere". Thanks, runner. Very helpful.

Consistency matters more than aesthetics. Once developers learn that background red means "read this first" and the runner's plain red means "ignore unless desperate", they navigate logs instinctively.

4. Structure your output with collapsible sections

GitLab supports native collapsible sections in job logs. This is our most powerful weapon against log noise. A deployment job with 800 lines of raw output becomes this scannable overview:

> Installing dependencies
β–Ό Database migration
  Applying migration '20240115_AddUserPreferences'...
  Applying migration '20240122_IndexOptimization'...
  [...]
  Done. 250 migrations applied.
β–Ό Deploying to staging
  Syncing build artifacts to server...
  [...]
  Restarting application pool...
[INFO] Health check passed

The > indicates a collapsed section β€” the developer can click to expand it if needed. The β–Ό sections are expanded by default, showing the core output immediately. An 800-line log that reads like a 10-line summary? That is not logging β€” that is user experience design.

The implementation uses two helper functions:

start_section() {
  local id=$1; local title=$2; local collapsed=${3:-""}
  local opts=""
  [ "$collapsed" = "collapsed" ] && opts="[collapsed=true]"
  echo -e "\e[0Ksection_start:$(date +%s):${id}${opts}\r\e[0K\e[1;36m${title}\e[0m"
}

end_section() {
  local id=$1
  echo -e "\e[0Ksection_end:$(date +%s):${id}\r\e[0K"
}
Enter fullscreen mode Exit fullscreen mode

Not all output deserves the same treatment. We use three categories:

No section needed β€” simple one-liner messages (log INFO ...) do not need to be wrapped in a section. They are already short and scannable.

Collapsed by default β€” verbose but predictable output (dependency install, Docker pull, cache restore). The developer only opens these when something goes wrong:

script:
  - start_section "npm_install" "Installing dependencies" collapsed
  - npm ci
  - end_section "npm_install"
Enter fullscreen mode Exit fullscreen mode

Collapsible, open by default β€” the core action of the job. This is what the developer came to see: migration output, test results, deployment logs. It is wrapped in a section for structure, but expanded so the output is immediately visible:

script:
  - start_section "db_migrate" "Database migration"
  - npx prisma migrate deploy
  - end_section "db_migrate"
Enter fullscreen mode Exit fullscreen mode

5. Synchronize and validate before moving on

A deployment is not done when the artifact lands on the server. It is done when we have proof it works. One of the trickiest CI/CD debugging situations is when an asynchronous operation fails after the pipeline has moved on β€” the error appears in the wrong context, or worse, the pipeline reports success while the actual deployment is still rolling out.

The rule is straightforward: if a step produces a result we depend on later, we wait for confirmation before continuing. This means waiting for rollouts to complete, then actively validating the result.

On success, the developer sees a clear narrative:

$ ./ci/deploy.sh staging
  Pushing image user-api:e4f5g6h to registry...
  Done.
[INFO] Deploying user-api to staging...
$ kubectl rollout status deployment/user-api -n staging --timeout=300s
  Waiting for deployment "user-api" rollout to finish: 0 of 3 updated replicas are available...
  Waiting for deployment "user-api" rollout to finish: 2 of 3 updated replicas are available...
  deployment "user-api" successfully rolled out
[INFO] Validating deployment...
[TRACE] Attempt 1/30: got version 'a1b2c3d', expected 'e4f5g6h'
[TRACE] Attempt 2/30: got version 'e4f5g6h', expected 'e4f5g6h'
[INFO] Deployment validated: version e4f5g6h is live

On failure, the error is impossible to miss β€” whether the rollout itself fails:

$ ./ci/deploy.sh staging
  Pushing image user-api:e4f5g6h to registry...
  Done.
[INFO] Deploying user-api to staging...
$ kubectl rollout status deployment/user-api -n staging --timeout=300s
  Waiting for deployment "user-api" rollout to finish: 0 of 3 updated replicas are available...
  Waiting for deployment "user-api" rollout to finish: 1 of 3 updated replicas are available...
  error: deployment "user-api" exceeded its progress deadline
[ERROR] Rollout failed or timed out for user-api in staging β€” run: kubectl
describe pod -l app=user-api -n staging
Cleaning up project directory and file based variables
ERROR: Job failed: exit status 1

Or the validation catches the wrong version:

$ ./ci/deploy.sh staging
[INFO] Validating deployment...
[TRACE] Attempt 29/30: got version 'a1b2c3d', expected 'e4f5g6h'
[TRACE] Attempt 30/30: got version 'a1b2c3d', expected 'e4f5g6h'
[ERROR] Deployment validation failed after 5 minutes β€” expected version
e4f5g6h, last received a1b2c3d, health endpoint
https://staging.example.com/api/health
Cleaning up project directory and file based variables
ERROR: Job failed: exit status 1

Good validation goes beyond "is it running?":

  • Version check: confirm the exact commit is deployed, not a cached old version.
  • Health endpoint: verify the service can reach its dependencies (database, cache, external APIs).
  • Smoke tests: run a minimal request that exercises the critical path.
  • Rollback trigger: if validation fails, automatically roll back and log the reason.

This applies to more than just Kubernetes deployments β€” database migrations, infrastructure provisioning, async API calls, DNS propagation. When a deployment validation fails, the developer knows immediately β€” not 30 minutes later when a user reports a bug. SchrΓΆdinger's deployment: simultaneously succeeded and failed until someone actually checks.

orange fox engineer handing a glowing instruction manual to a group of curious developers, anime style, warm lighting, CI/CD pipeline hologram in the background

6. Use warning jobs as early signals

Not every issue deserves a pipeline failure. Some problems β€” like linter violations in legacy code, or a growing list of TODO comments β€” are real but not urgent. Blocking the pipeline on day one would just train developers to ignore the warnings (or worse, remove the job entirely).

Instead, we add the check as a non-blocking job using allow_failure: true. The job runs, reports its findings, and shows as an orange warning in the pipeline. It does not block the merge, but it stays visible β€” a gentle, persistent reminder that something needs attention:

eslint:
  stage: quality
  script:
    - npx eslint src/ --format stylish
  allow_failure: true
Enter fullscreen mode Exit fullscreen mode

The orange icon acts as a soft nudge. Sooner or later, a developer picks up the task, fixes the violations, and the job turns green. At that point, we remove allow_failure: true and the check becomes mandatory β€” without any drama or big-bang migration.

This works for any gradual adoption pattern: stricter TypeScript settings, new security rules, accessibility checks, or dependency update policies. The pipeline documents the target standard before it enforces it.

Warning jobs also shine for detecting slow-moving disasters β€” things that are fine today but will explode next month. We can use allow_failure: exit_codes to turn specific exit codes into warnings:

for drive in C D; do
  usage=$(df -m /$drive | awk 'NR==2{printf "%.0f", $5}')
  if [ "$usage" -gt 90 ]; then
    log WARNING "Disk $drive: usage is ${usage}% (> 90%) β€” ask infra to clean up old releases"
    exit 99
  fi
  log TRACE "Disk $drive: usage is ${usage}%"
done
Enter fullscreen mode Exit fullscreen mode
deploy:
  script: ...
  allow_failure:
    exit_codes: [99]  # disk space
Enter fullscreen mode Exit fullscreen mode

The job shows as a warning (orange) in the pipeline instead of a hard failure. The developer sees the problem, but the pipeline is not blocked. This pattern works well for any ticking clock: disk space, certificate expiry, security scan thresholds, or GitLab Pages size limits approaching the quota.

7. Make warnings and errors actionable

The difference between a good log and a great log is the "what to do next" part. Every warning and error should include a remediation hint β€” a specific action the developer can take.

A bare error leaves the developer stranded:

[ERROR] Build artifacts not found at dist/api β€” sync cannot proceed
Cleaning up project directory and file based variables
ERROR: Job failed: exit status 1

The same error with a remediation hint becomes self-service:

[ERROR] Build artifacts not found at dist/api and fallback dist/api-main
[WARNING] add 'force-build-api' label on your MR and launch a new pipeline,
or run a manual pipeline on the target branch to create a fallback
Cleaning up project directory and file based variables
ERROR: Job failed: exit status 1

One line, all the context. Here are more patterns from real-world pipelines:

[ERROR] Pre/Production deployment requires Maintainer privileges (level >=
40), current user jdupont has level 30 β€” ask a project Owner to promote you
in Settings > Members

[ERROR] https://recette3.example.com/health failed with HTTP 503 β€”
localhost fallback returned "Cannot find module '@prisma/client'" β€” check
application logs on app-rec-03

[ERROR] Current branch is behind main or has conflicts β€” E2E tests would be
meaningless, please rebase or back-merge before retrying

[WARNING] E2E test results found in cache (18.3h old), skipping tests β€” to
force a full run, delete the cache or add the 'force-e2e' label

[WARNING] Kafka consumer service configured as Manual startup (GroupId is
empty) β€” set a GroupId in the environment config to enable automatic
startup

The pattern is always the same: what happened + why + what to do about it, all on a single line. Long lines scroll horizontally in GitLab's log viewer β€” and a developer can copy-paste one line into Slack and the recipient has the full picture without follow-up questions.

A developer reading this at 6 PM on a Friday should not need to ask anyone for help.

8. Log the variable, path, and reason behind every decision

Most CI logs describe what is happening. Great CI logs explain why a branch was taken β€” and with enough context to reconstruct the decision without opening the YAML: the variable or label that triggered it, the file or cache key that was found or missing, the path that was created or skipped. This is the single most effective way to reduce support tickets.

Caching is the #1 source of "why" questions. When a job is slow, the first thing a developer wonders is: "did it use the cache?" Without explicit logging, the only way to find out is to read the YAML and hope the runner logs are detailed enough. Instead, we log the decision:

[INFO] Cache found: node_modules checksum matches (a7f3b2c)
[INFO] Skipping npm ci
[WARNING] Cache miss: node_modules checksum changed (a7f3b2c β†’ e91d4f8)
[INFO] Running full npm ci
[INFO] E2E test results found in cache (less than 24h old)
[INFO] Skipping E2E tests for this module
[WARNING] E2E test results expired (cache is 36h old, max: 24h)
[INFO] Running full E2E suite

Depending on the conditions, the developer sees exactly why tests were skipped β€” or why they were not:

[WARNING] Skipping tests (SKIP_TESTS=true)
[INFO] Running full test suite (no skip conditions met)

When a pipeline behaves unexpectedly, the "why" logs are the first thing developers look for. Without them, they are reverse-engineering our CI logic from YAML β€” a slow and error-prone process that almost certainly ends in a support ticket.

9. Know when NOT to log

The temptation after reading this article is to wrap every command in log(). Resist it. Over-logging is almost as bad as under-logging β€” it buries the signal in noise.

Here is what an over-logged npm ci looks like:

$ cd front && npm ci --prefer-offline
[INFO] Starting npm ci...
[INFO] Running: npm ci --prefer-offline
[INFO] Working directory: /builds/project/front
[INFO] Node version: 20.11.0
[INFO] npm version: 10.2.4
npm warn deprecated svgo@1.3.2: ...
added 1,847 packages in 42s
[INFO] npm ci completed successfully

And here is the same step, done right:

$ cd front && npm ci --prefer-offline
npm warn deprecated svgo@1.3.2: ...
added 1,847 packages in 42s

No custom logging at all. npm ci already tells us everything we need to know: what it installed, how long it took, and whether it succeeded (via its exit code). Adding log INFO around it is pure noise.

The rule of thumb: log when the pipeline makes a decision, not when it executes a well-known command. Specifically, we add logging for:

  • Ambiguous operations β€” cache hit or miss? skip or run? which environment was selected?
  • Operations that can fail silently β€” a sync that might succeed partially, a health check with a timeout.
  • Decisions with side effects β€” "deploying to production" is worth logging; "running npm ci" is not.

Standard tools like npm, go build, docker, kubectl produce their own output. Our job is to add context around them, not on top of them. If we find ourselves logging "Starting npm ci" right before npm ci, we have achieved the logging equivalent of a meeting that could have been an email.

Wrapping up

Self-documenting pipelines are an investment that pays off immediately. From GitLab's built-in features, through structured log levels, meaningful colors, and collapsible sections, to synchronization barriers, deployment validation, context-rich errors, actionable remediation, decision-context logging, and knowing when to stay silent β€” we transform CI/CD logs from a debugging nightmare into a developer self-service tool.

The ultimate test: when a pipeline fails at 2 AM, can the on-call developer fix it without escalating to the CI/CD team? If yes, we have done our job. The real metric is not code coverage or deployment frequency β€” it is the number of CI/CD support tickets trending toward zero.

orange fox engineer handing a glowing instruction manual to a group of curious developers, anime style, warm lighting, CI/CD pipeline hologram in the background

Illustrations generated locally by Draw Things using Flux.1 [Schnell] model

Further reading


This article was enhanced with the assistance of an AI language model to ensure clarity and accuracy in the content, as English is not my native language.

Top comments (0)