Logging estruturado: JSON logs do Bash para agregação

1. Por que logs estruturados em Bash?

O logging textual tradicional em Bash — mensagens soltas como [INFO] Script iniciado — é frágil para automação. Ferramentas como grep e awk conseguem extrair informações, mas falham quando o formato muda, caracteres especiais aparecem ou campos são adicionados. O parsing torna-se um pesadelo de manutenção.

Logs em JSON resolvem isso. Cada entrada é um objeto estruturado, legível por máquinas, com schema flexível e suporte nativo em agregadores como Elasticsearch, Loki e Splunk. Em pipelines CI/CD, scripts de automação e monitoramento, logs JSON permitem filtrar por nível, extrair métricas e correlacionar eventos sem reescrever parsers.

2. Construindo funções básicas de log JSON

A base é uma função que serializa campos em JSON de forma segura. Usar jq garante escaping correto de caracteres especiais:

#!/bin/bash

log_json() {
    local level="$1"
    local message="$2"
    local timestamp
    timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    jq -n \
        --arg ts "$timestamp" \
        --arg lvl "$level" \
        --arg msg "$message" \
        '{timestamp: $ts, level: $lvl, message: $msg}'
}

# Uso
log_json "INFO" "Serviço iniciado com sucesso"
log_json "ERROR" "Falha na conexão: timeout de 30s"

A saída será:

{"timestamp":"2025-04-09T14:30:00Z","level":"INFO","message":"Serviço iniciado com sucesso"}
{"timestamp":"2025-04-09T14:30:01Z","level":"ERROR","message":"Falha na conexão: timeout de 30s"}

jq -n cria um novo objeto JSON, e --arg trata automaticamente aspas, barras invertidas e caracteres de controle na string.

3. Campos enriquecidos: contexto e metadados

Para rastrear a origem de cada log, adicione metadados do ambiente:

log_json() {
    local level="$1"
    local message="$2"
    local timestamp
    timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    jq -n \
        --arg ts "$timestamp" \
        --arg lvl "$level" \
        --arg msg "$message" \
        --arg script "${0##*/}" \
        --arg pid "$$" \
        --arg host "$(hostname)" \
        --arg user "$USER" \
        '{
            timestamp: $ts,
            level: $lvl,
            message: $msg,
            script_name: $script,
            pid: $pid,
            hostname: $host,
            user: $user
        }'
}

Para rastreamento de execuções, gere um UUID por sessão:

EXECUTION_ID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)

log_json() {
    ...
    --arg exec_id "$EXECUTION_ID" \
    '{
        ...
        execution_id: $exec_id,
        correlation_id: $exec_id
    }'
}

Metadados customizados podem ser passados como argumentos nomeados:

log_json_with_meta() {
    local level="$1"
    local message="$2"
    shift 2
    local extra_args=()
    while [[ $# -gt 0 ]]; do
        extra_args+=(--arg "$1" "$2")
        shift 2
    done
    ...
    jq -n "${extra_args[@]}" \
        --arg ts "$timestamp" \
        --arg lvl "$level" \
        --arg msg "$message" \
        '{
            timestamp: $ts,
            level: $lvl,
            message: $msg,
            service: $service,
            endpoint: $endpoint
        }'
}

4. Níveis de severidade e saídas múltiplas

Mapeie níveis para syslog e direcione stderr/stdout:

declare -A LOG_LEVELS=(
    [DEBUG]=7
    [INFO]=6
    [WARN]=4
    [ERROR]=3
    [FATAL]=2
)

log_json() {
    local level="$1"
    local message="$2"
    local log_num=${LOG_LEVELS[$level]:-6}
    local min_level_num=${LOG_LEVELS[${LOG_LEVEL:-INFO}]:-6}

    [[ $log_num -le $min_level_num ]] || return 0

    local json_output
    json_output=$(build_json "$level" "$message")

    if [[ $log_num -le 4 ]]; then
        echo "$json_output" >&2
    else
        echo "$json_output"
    fi

    # Saída simultânea para arquivo
    echo "$json_output" >> /var/log/meu_script.log 2>/dev/null || true
}

Para rotação segura, use tee e logrotate:

log_json() {
    ...
    echo "$json_output" | tee -a /var/log/meu_script.log >&2
}

5. Integração com agregadores de logs

Formate para Elasticsearch/OpenSearch com campos padrão:

build_json() {
    local level="$1"
    local message="$2"
    jq -n \
        --arg ts "$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")" \
        --arg lvl "$level" \
        --arg msg "$message" \
        '{
            "@timestamp": $ts,
            "@version": 1,
            "level": $lvl,
            "message": $msg,
            "logger_name": "bash_script",
            "thread_name": "main"
        }'
}

Envio direto via HTTP para Logstash ou Loki:

send_to_loki() {
    local json_payload="$1"
    local loki_url="http://loki:3100/loki/api/v1/push"

    curl -s -X POST "$loki_url" \
        -H "Content-Type: application/json" \
        -d "$json_payload" > /dev/null 2>&1 || {
            log_json "WARN" "Falha ao enviar log para Loki"
        }
}

Implemente buffer e retry com backoff exponencial:

buffer_and_send() {
    local log_entry="$1"
    local max_retries=3
    local delay=1

    for ((i=1; i<=max_retries; i++)); do
        if send_to_loki "$log_entry"; then
            return 0
        fi
        sleep $delay
        delay=$((delay * 2))
    done
    echo "$log_entry" >> /tmp/log_buffer.txt
}

6. Performance e boas práticas

Evite subshells desnecessários. printf é mais rápido que echo para strings simples, mas jq é a escolha correta para JSON:

# Ruim (múltiplos subshells)
log_json() {
    local ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    local msg=$(echo "$1" | jq -Rs .)
    echo "{\"timestamp\":\"$ts\",\"message\":$msg}"
}

# Bom (cache de comandos externos)
HOSTNAME=$(hostname)
DATE_FORMAT="+%Y-%m-%dT%H:%M:%SZ"

log_json() {
    local ts
    printf -v ts '%(%Y-%m-%dT%H:%M:%SZ)T' -1
    jq -n --arg ts "$ts" --arg msg "$1" '{timestamp: $ts, message: $msg}'
}

Cacheie valores imutáveis em variáveis globais:

readonly SCRIPT_NAME="${0##*/}"
readonly HOSTNAME=$(hostname)
readonly EXECUTION_ID=$(uuidgen)

Para rotação segura, configure logrotate:

/var/log/meu_script.log {
    daily
    rotate 7
    compress
    delaycompress
    missingok
    notifempty
    create 640 root adm
    postrotate
        systemctl reload meu_script 2>/dev/null || true
    endscript
}

7. Exemplo completo: script de monitoramento com logs JSON

#!/bin/bash

# Configurações
readonly SERVICES=("nginx" "postgresql" "redis")
readonly CHECK_INTERVAL=60
readonly LOG_FILE="/var/log/healthcheck.json"
readonly EXECUTION_ID=$(uuidgen)
readonly HOSTNAME=$(hostname)

log_json() {
    local level="$1"
    local message="$2"
    local timestamp
    timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    jq -n \
        --arg ts "$timestamp" \
        --arg lvl "$level" \
        --arg msg "$message" \
        --arg exec "$EXECUTION_ID" \
        --arg host "$HOSTNAME" \
        '{
            "@timestamp": $ts,
            level: $lvl,
            message: $msg,
            execution_id: $exec,
            hostname: $host,
            logger: "healthcheck"
        }' | tee -a "$LOG_FILE"
}

check_service() {
    local service="$1"
    if systemctl is-active --quiet "$service"; then
        log_json "INFO" "Serviço $service está ativo"
        return 0
    else
        log_json "ERROR" "Serviço $service está inativo"
        return 1
    fi
}

# Loop principal
log_json "INFO" "Monitoramento iniciado"

while true; do
    for svc in "${SERVICES[@]}"; do
        check_service "$svc"
    done
    sleep "$CHECK_INTERVAL"
done

Para extrair métricas dos logs:

# Contagem de erros por serviço
cat /var/log/healthcheck.json | jq -r '
    select(.level == "ERROR") |
    .message |
    capture("Serviço (?<service>[a-z]+) está inativo") |
    .service
' | sort | uniq -c | sort -rn

Integração com alertas via pipe:

tail -f /var/log/healthcheck.json | while read line; do
    level=$(echo "$line" | jq -r '.level')
    if [[ "$level" == "ERROR" ]]; then
        message=$(echo "$line" | jq -r '.message')
        curl -s -X POST "https://hooks.slack.com/services/TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"text\":\"$message\"}"
    fi
done

8. Armadilhas comuns e depuração

Problemas de encoding: logs com caracteres UTF-8 podem quebrar parsers. Use jq -Rs para garantir escaping:

message=$(printf '%s' "$raw_message" | jq -Rs .)

Logs muito grandes: comprima inline com gzip e faça chunking:

log_json() {
    ...
    echo "$json_output" | gzip >> /var/log/compressed.log.gz
}

Testes unitários com bats:

#!/usr/bin/env bats

setup() {
    source ./log_functions.sh
}

@test "log_json gera JSON válido" {
    run log_json "INFO" "teste"
    [ "$status" -eq 0 ]
    echo "$output" | jq empty
}

@test "log_json respeita LOG_LEVEL" {
    LOG_LEVEL=ERROR run log_json "INFO" "deve ser ignorado"
    [ -z "$output" ]
}

Logs JSON em Bash não são apenas uma conveniência — são uma necessidade para sistemas modernos. Com funções bem construídas, integração nativa com agregadores e boas práticas de performance, seus scripts se tornam cidadãos de primeira classe em pipelines de observabilidade.

Referências