Docs

Log Consumer Labels in the Access Log

Add a consumer label to the Apache APISIX access log, validate authenticated and rejected requests, and restore the original configuration.

Applies to:Apache APISIX 3.18.0

Deployments:DockerRoles:Platform engineerTime:20 minutesImpact:Changes live traffic

Goal

Add one initialized NGINX variable to the APISIX access-log format and populate it from a Consumer label during the log phase. Prove that an authenticated request records the expected company while a rejected request records unknown, then restore the exact pre-guide configuration.

Consumer labels can contain sensitive business identifiers. Review which labels may enter logs, restrict log access, and apply the required retention and redaction policy before adapting this example.

Before you begin

  • Complete Get APISIX with the Docker quickstart.
  • Install Docker, cURL, and jq.
  • Use a shell that supports Bash or Zsh heredocs.

Set the endpoints, current directory, and secrets without placing the keys in shell history:

export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
export GUIDE_DIR="$(pwd)"
read -rsp "Admin API key (press Enter for an unchanged quickstart): " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo
read -rsp "Temporary consumer key: " CONSUMER_API_KEY && export CONSUMER_API_KEY && echo
test -n "${CONSUMER_API_KEY}"

Protect every fixed resource and local file used by this guide:

for resource in \
  global_rules/consumer-label-guide-rule \
  consumers/consumer-label-guide-user \
  consumers/consumer-label-guide-user/credentials/consumer-label-guide-credential \
  routes/consumer-label-guide-route; do
  test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/${resource}" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 404 || { echo "Stop: ${resource} already exists"; exit 1; }
done
test -z "$(docker ps -aq --filter name=^/consumer-label-guide-upstream$)" || { echo 'Stop: container consumer-label-guide-upstream already exists'; exit 1; }
for file in consumer-label-guide-nginx.conf consumer-label-guide-config.yaml.backup consumer-label-guide-config.yaml.updated; do
  test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }
done

Steps

Step 1: Add the access-log variable safely

Back up the complete APISIX configuration. Stop if a top-level nginx_config mapping already exists, because that configuration requires a reviewed merge rather than a second key:

umask 077
docker cp apisix-quickstart:/usr/local/apisix/conf/config.yaml consumer-label-guide-config.yaml.backup
test "$(grep -Ec '^nginx_config:[[:space:]]*$' consumer-label-guide-config.yaml.backup)" = 0 || { echo 'Stop: merge with the existing top-level nginx_config mapping'; exit 1; }
cp consumer-label-guide-config.yaml.backup consumer-label-guide-config.yaml.updated
cat >> consumer-label-guide-config.yaml.updated <<'YAML'

nginx_config:
  http_server_location_configuration_snippet: |
    set $consumer_company "-";
  http:
    access_log_format: >-
      $remote_addr - $remote_user [$time_local] $http_host "$request" $status $body_bytes_sent $request_time "$http_referer" "$http_user_agent" $upstream_addr $upstream_status $upstream_response_time "$upstream_scheme://$upstream_host$upstream_uri" "$consumer_company"
YAML
docker cp consumer-label-guide-config.yaml.updated apisix-quickstart:/usr/local/apisix/conf/config.yaml
if ! docker exec apisix-quickstart apisix test; then
  docker cp consumer-label-guide-config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
  docker exec apisix-quickstart apisix reload
  exit 1
fi
docker exec apisix-quickstart apisix reload

The variable is initialized for every request so the access-log format never reads an undefined NGINX variable.

What changed: APISIX appends one quoted consumer-company field to each access-log line while preserving every pre-existing configuration field.

Step 2: Populate the variable during the log phase

Create one authenticated fixed-ID Global Rule that writes a Consumer's company label, or unknown when no authenticated Consumer is present:

curl -sS --connect-timeout 3 --max-time 15 \
  "${APISIX_ADMIN_URL}/apisix/admin/global_rules/consumer-label-guide-rule" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H 'Content-Type: application/json' \
  -d '{
    "plugins":{
      "serverless-pre-function":{
        "phase":"log",
        "functions":[
          "return function (conf, ctx) ngx.var.consumer_company = ctx.consumer and ctx.consumer.labels and ctx.consumer.labels.company or \"unknown\" end"
        ]
      }
    }
  }' | jq -e '.value.id == "consumer-label-guide-rule" and .value.plugins["serverless-pre-function"].phase == "log"'

What changed: the log-phase function assigns a deterministic value without changing request routing or authentication.

Step 3: Create a labeled Consumer and credential

Create a fixed Consumer with a non-secret label:

curl -sS --connect-timeout 3 --max-time 15 \
  "${APISIX_ADMIN_URL}/apisix/admin/consumers/consumer-label-guide-user" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H 'Content-Type: application/json' \
  -d '{"username":"consumer-label-guide-user","labels":{"company":"smart-technology"}}' | \
  jq -e '.value.username == "consumer-label-guide-user" and .value.labels.company == "smart-technology"'

Attach the temporary key without embedding it in the documentation or command history:

jq -n --arg key "${CONSUMER_API_KEY}" '{id:"consumer-label-guide-credential",plugins:{"key-auth":{key:$key}}}' | \
curl -sS --connect-timeout 3 --max-time 15 \
  "${APISIX_ADMIN_URL}/apisix/admin/consumers/consumer-label-guide-user/credentials" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H 'Content-Type: application/json' \
  --data-binary @- | \
jq -e '.value.id == "consumer-label-guide-credential" and ((.value.plugins["key-auth"].key | type) == "string") and ((.value.plugins["key-auth"].key | length) > 0)'

What changed: one temporary credential authenticates a Consumer whose company label is safe for this controlled log test.

Step 4: Create a controlled Route

Create a minimal local upstream and wait for it to accept connections:

cat > consumer-label-guide-nginx.conf <<'NGINX'
events {}
http {
  access_log off;
  server {
    listen 80;
    location = /consumer-label-guide { return 200 '{"source":"consumer-label-guide-upstream"}'; }
  }
}
NGINX
docker run -d --name consumer-label-guide-upstream \
  --network apisix-quickstart-net \
  -v "${GUIDE_DIR}/consumer-label-guide-nginx.conf:/etc/nginx/nginx.conf:ro" \
  nginx:1.29.1-alpine
for attempt in {1..15}; do
  docker exec apisix-quickstart bash -c '</dev/tcp/consumer-label-guide-upstream/80' >/dev/null 2>&1 && break
  sleep 1
done
docker exec apisix-quickstart bash -c '</dev/tcp/consumer-label-guide-upstream/80'

Create one authenticated fixed-ID Route:

curl -sS --connect-timeout 3 --max-time 15 \
  "${APISIX_ADMIN_URL}/apisix/admin/routes/consumer-label-guide-route" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H 'Content-Type: application/json' \
  -d '{
    "name":"consumer-label-guide-route",
    "uri":"/consumer-label-guide",
    "methods":["GET"],
    "plugins":{"key-auth":{}},
    "upstream":{"type":"roundrobin","nodes":{"consumer-label-guide-upstream:80":1}}
  }' | jq -e '.value.id == "consumer-label-guide-route" and (.value.plugins["key-auth"] | type == "object")'

What changed: the controlled route requires the temporary key and never sends traffic to a public test service.

Validate

Record a timestamp, send one authenticated and one rejected request with unique query markers, and assert their status and upstream response:

export LOG_SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
curl --fail-with-body -sS --connect-timeout 3 --max-time 10 \
  "${GATEWAY_URL}/consumer-label-guide?case=valid" \
  -H "apikey: ${CONSUMER_API_KEY}" | \
  jq -e '. == {source:"consumer-label-guide-upstream"}'
test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${GATEWAY_URL}/consumer-label-guide?case=missing")" = 401

Wait for both access-log lines, then require the label written on each matching line:

for attempt in {1..15}; do
  docker logs --since "${LOG_SINCE}" apisix-quickstart 2>&1 | grep 'case=valid' | grep -q '"smart-technology"$' && \
  docker logs --since "${LOG_SINCE}" apisix-quickstart 2>&1 | grep 'case=missing' | grep -q '"unknown"$' && break
  sleep 1
done
docker logs --since "${LOG_SINCE}" apisix-quickstart 2>&1 | grep 'case=valid' | grep -q '"smart-technology"$'
docker logs --since "${LOG_SINCE}" apisix-quickstart 2>&1 | grep 'case=missing' | grep -q '"unknown"$'

All four assertions must pass. They prove the upstream response, authentication rejection, labeled Consumer log value, and unauthenticated fallback independently.

Roll back / Clean up

Delete dependents first, restore the complete APISIX configuration, and remove the disposable files and container:

curl --fail-with-body -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/consumer-label-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl --fail-with-body -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/consumer-label-guide-user/credentials/consumer-label-guide-credential" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl --fail-with-body -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/consumer-label-guide-user" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl --fail-with-body -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/global_rules/consumer-label-guide-rule" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
docker cp consumer-label-guide-config.yaml.backup apisix-quickstart:/usr/local/apisix/conf/config.yaml
docker exec apisix-quickstart apisix test
docker exec apisix-quickstart apisix reload
docker rm -f consumer-label-guide-upstream
rm -f consumer-label-guide-nginx.conf consumer-label-guide-config.yaml.backup consumer-label-guide-config.yaml.updated
unset APISIX_ADMIN_URL APISIX_ADMIN_KEY GATEWAY_URL GUIDE_DIR CONSUMER_API_KEY LOG_SINCE

Expected: the fixed Route, credential, Consumer, Global Rule, container, and local files are absent, and APISIX is running with the byte-for-byte pre-guide configuration.

Troubleshoot

SymptomCauseFix
The configuration guard stops at nginx_config.The deployment already customizes NGINX.Merge both required fields into the existing mapping in a reviewed change; preserve its other fields and the complete backup.
apisix test reports an unknown NGINX variable.The access-log format was installed without the initialization snippet, or the names differ.Restore the backup, then add both $consumer_company occurrences with the same spelling before testing again.
The valid request returns 401.The key does not match the temporary credential or the credential write failed.Read the fixed credential through the authenticated Admin API and recreate it from ${CONSUMER_API_KEY}; do not print the secret.
The response assertions pass but no matching log line appears.The container log driver does not expose APISIX access logs or the timestamp excluded the line.Inspect the deployment's configured access-log destination and repeat with a fresh timestamp; do not weaken the label assertion.
A label other than company is needed.The NGINX variable, log-phase function, and log format form one contract.Choose an allowlisted non-sensitive label and update all three together in a reviewed configuration change.