Hoja de Ruta

Contexto sobre el bundle ZPA

Cuando descargas un Support Bundle desde Zscaler ZPA (desde la app del cliente o desde la consola de administracion), obtienes un ZIP que contiene decenas de archivos de log. Estos logs cubren multiples componentes del cliente y pueden remontarse varios dias atras. El volumen puede ser abrumador.

El objetivo no es leer todo. Es encontrar rapidamente donde esta el problema.


FASE 1: Conocer la estructura del bundle

Estructura tipica de un Support Bundle ZPA

Al descomprimir el ZIP, te encuentras con una carpeta que contiene algo asi:

Zscaler-2026-05-11-13-13-21/
├── logs/
│   ├── zsa-tunnel.log              ← Motor de tuneles (CRITICO)
│   ├── zsa-service.log             ← Servicio Windows (CRITICO)
│   ├── zsa-tray.log                ← Interfaz de usuario (SECUNDARIO)
│   ├── zsa-dns.log                 ← Cache DNS de ZPA (UTIL)
│   ├── zsa-proxy.log               ← Proxy local dgwip.exe (UTIL)
│   ├── zsa-wfp.log                 ← Driver WFP kernel (AVANZADO)
│   ├── zsa-diag.log                ← Diagnostico integrado (MUY UTIL)
│   ├── zsa-update.log              ← Actualizaciones del cliente (BAJO PRIORIDAD)
│   ├── zsa-install.log             ← Instalacion (SOLO si hubo problemas de instalacion)
│   └── zsa-misc.log                ← Miscelanea (DESCARTABLE normalmente)
├── config/
│   ├── zpa_config.json             ← Configuracion del cliente (UTIL)
│   └── policy_config.json          ← Politicas cacheadas (UTIL)
├── network/
│   ├── netsh_wfp_show_filters.xml  ← Filtros WFP (AVANZADO)
│   ├── netsh_advfirewall.log       ← Firewall Windows (CONTEXTUAL)
│   └── ipconfig.txt                ← Estado de red (UTIL)
└── system/
    ├── systeminfo.txt              ← Info del sistema (UTIL)
    ├── tasklist.txt                ← Procesos activos (UTIL)
    └── drivers.txt                 ← Drivers cargados (UTIL)

Nota: La estructura exacta varia segun la version del cliente y la plataforma. En Linux/Mac la estructura es similar pero con paths distintos.


FASE 2: Priorizar - Que mirar primero

Nivel 0 - Visibilidad rapida (5 minutos)

Antes de leer nada en detalle, haz una pasada visual para entender la escala:

# En PowerShell, dentro de la carpeta del bundle
# Ver que archivos hay y cuanto miden
Get-ChildItem -Recurse -File | Sort-Object Length -Descending | 
    Select-Object @{N='SizeKB';E={[math]::Round($_.Length/1KB,1)}}, 
    @{N='SizeMB';E={[math]::Round($_.Length/1MB,2)}}, 
    FullName | Format-Table -AutoSize
# En Linux/Mac
find . -type f -exec ls -lhS {} + | head -30

Lo que buscas: Los archivos mas grandes son los que mas actividad tienen. Si zsa-tunnel.log tiene 50MB y zsa-tray.log tiene 200KB, ya sabes donde esta la accion.


Nivel 1 - Los 3 archivos que siempre miras primero (15 minutos)

En orden de prioridad:

1. zsa-tunnel.log - EL ARCHIVO MAS IMPORTANTE

Por que: Aqui se registran las conexiones de los micro-tuneles: establecimiento, errores, fallos DTLS, timeouts, desconexiones. Si algo falla en ZPA, se refleja aqui.

Que buscar de forma inmediata:

Errores criticos:
  - "error" (case insensitive)
  - "fail"
  - "timeout"
  - "disconnect"
  - "BRK_MT_" (codigos de eventos de micro-tuneles)

Eventos de micro-tuneles:
  - "BRK_MT_SETUP" (establecimiento de micro-tunel)
  - "BRK_MT_CLOSED" (cierre de micro-tunel)
  - "BRK_MT_SETUP_FAIL" (fallo de micro-tunel)
  - "tag" (identificador de cada micro-tunel)

Protocolo:
  - "DTLS" (busca si esta funcionando o fallando)
  - "TLS" (fallback - indica que DTLS no funciona)
  - "fallback" (cambio de DTLS a TLS)

Conexion con Brokers:
  - "broker" (estados de conexion)
  - "87.58" / "165.225" (IPs de Brokers observadas)
  - "connected" / "disconnected"

2. zsa-service.log - EL SERVICIO WINDOWS

Por que: Gestiona el ciclo de vida del cliente, sesiones de usuario, y eventos del sistema. Si el servicio se cuelga, se reinicia, o pierde la sesion, aqui lo veras.

Que buscar:

Eventos de sesion:
  - "session" (creacion, destruccion)
  - "login" / "logout"
  - "user"

Estados del servicio:
  - "start" / "stop" / "restart"
  - "crash" / "exception"
  - "timeout"

Politicas:
  - "policy" (carga, actualizacion, errores)
  - "FQDN" (validacion de hostname)
  - "FQDN_NO_MATCH" (hostname no valido - CRITICO)
  - "register" / "deregister"

3. zsa-diag.log - EL DIAGNOSTICO

Por que: Este archivo contiene los resultados del diagnostico interno de ZPA. Incluye tests de conectividad, estado de tuneles, y problemas detectados automaticamente.

Que buscar:

- "PASS" / "FAIL" (resultados de tests)
- "connectivity" (tests de conexion)
- "dtls" / "tls" (estado del protocolo)
- "dns" (resolucion DNS)
- "wfp" (estado del driver)
- "sublayer" (conflictos con otros drivers)

Nivel 2 - Archivos de contexto (10 minutos)

Despues de entender los errores, necesitas contexto:

ArchivoQue te diceQue buscar
zsa-dns.logComo ZPA resuelve dominios internosDominios que fallan, TTLs, IPs virtuales 100.64.x.x
zsa-proxy.logTrafico a traves del proxy local 127.0.0.1:3128Conexiones rechazadas, errores de proxy
ipconfig.txtEstado de red del equipoAdaptadores VPN conflictivos, DNS servers, IPs
zpa_config.jsonConfiguracion activa del clienteenableSplitVpnTN, enrollmentCert, versiones
tasklist.txtProcesos activos en el momentoOtros agentes VPN (Ivanti, Cisco, etc.), antivirus
drivers.txtDrivers cargados en kernelzsawdrv.sys (ZPA), drivers VPN de terceros

Nivel 3 - Archivos avanzados (solo si es necesario)

ArchivoCuando lo mirasComplejidad
netsh_wfp_show_filters.xmlCuando sospechas conflicto WFP con otro agenteAlta - XML con miles de filtros
zsa-wfp.logCuando los errores indican fallos del driver WFPAlta - requiere conocimiento de kernel
zsa-tray.logCuando el problema es visual (el icono no muestra estado correcto)Baja
zsa-update.logSolo si sospechas que una actualizacion causo el problemaBaja
zsa-install.logSolo si el problema es la instalacion mismaBaja

Nivel 4 - Archivos que normalmente descartas

zsa-misc.log          → Informacion residual, rara vez util
zsa-install.log       → Solo relevante si el problema es la instalacion
*.bak files           → Copias de seguridad de versiones anteriores
*.tmp files           → Archivos temporales de escritura

FASE 3: Filtrado manual con herramientas

3.1 Notepad++ (Windows)

Notepad++ es probablemente la herramienta mas accesible para analisis manual de logs.

Busqueda basica: Ctrl+F → Find in Files

Atajo: Ctrl + Shift + F

Find what:       error
Directory:       C:\ruta\al\bundle\logs
Filters:         *.log
Search Mode:     Normal
☑ Match case:    NO (primera pasada)

Esto te da todas las lineas que contienen “error” en todos los archivos de log.

Busqueda avanzada: Find in Files con regex

Cambiar Search Mode a “Regular expression”:

# Buscar cualquier tipo de error o fallo
Find what:  (?i)(error|fail|timeout|exception|crash)

# Buscar codigos de evento de micro-tuneles
Find what:  BRK_MT_\w+

# Buscar IPs de red (formato x.x.x.x)
Find what:  \b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b

# Buscar timestamps para una hora especifica (ej: entre 11:00 y 11:15)
Find what:  11:0[0-9]:\d{2}.*(?:error|fail|timeout)

# Buscar errores de DTLS
Find what:  (?i)dtls.*(?:fail|error|timeout|fallback)

# Buscar eventos de FALLBACK a TLS (indica que DTLS no funciona)
Find what:  (?i)tls.*fallback

# Buscar codigos de error numericos ZPA
Find what:  (?:error|code)\s*[=:]\s*\d{4,5}

# Buscar todo lo relacionado con un tag/micro-tunel especifico
Find what:  tag\s*65539

Paso a paso en Notepad++

Paso 1: Vista panoramica del archivo

Abre el archivo zsa-tunnel.log y usa View → Document Map (atajo: Alt + V, luego Document Map). Esto te muestra una miniatura del archivo donde puedes ver visualmente las zonas con mucho texto (errores suelen ser lineas largas).

Paso 2: Marcar todas las ocurrencias

Ctrl + F → pestaña “Mark” → escribe la palabra clave → “Mark All”. Esto colorea todas las lineas que coinciden. Luego puedes navegar entre ellas con los botones “Next” y “Previous”.

Paso 3: Extraer solo las lineas que interesan

Search → Find in Files → marca la opcion “Bookmark Line”. Despues, Search → Bookmark → Copy Bookmarked Lines. Esto copia solo las lineas relevantes a un nuevo documento para analisis separado.

Paso 4: Comparar dos versiones del mismo log

Si tienes un bundle de cuando funcionaba y otro de cuando falla: Plugins → Compare → Compare (necesitas el plugin Compare, instalable via Plugin Manager).

Paso 5: Colorear palabras clave automaticamente

Settings → Style Configurator no permite keywords personalizadas, pero puedes usar el plugin “EnhanceAnyLexer” o simplemente usar el modo de lenguaje y configurar User Defined Language con colores para palabras clave como error, fail, timeout, BRK_MT_.


3.2 Visual Studio Code (alternativa superior a Notepad++)

VS Code es mejor que Notepad++ para archivos de log grandes porque:

  • Abre archivos de cualquier tamano sin colgarse
  • Busqueda regex rapida en multiples archivos
  • Permite colapsar secciones por timestamps
  • Tiene extensiones especificas para logs

Extension recomendada

Instala “Log File Highlighter” de emilast. Automaticamente colorea:

  • Lineas con ERROR/error en rojo
  • Lineas con WARN/warning en amarillo
  • Lineas con INFO en verde
  • Timestamps en azul

Busqueda en VS Code

Ctrl + Shift + F  (busqueda en todos los archivos)

# En el campo de busqueda:
error|fail|timeout|BRK_MT_.*FAIL

# Activa el icono de regex (.*)
# Activa "files to include" y escribe: *.log

# Para buscar en un rango de tiempo:
# Primero busca el timestamp, luego usa el panel de resultados

Multi-cursor para analisis rapido

Si necesitas extraer todos los valores de un campo (por ejemplo, todos los tag IDs):

Ctrl + F → busca "tag \d+"
Alt + Enter (selecciona todas las coincidencias)
Ctrl + C (copia todas)

3.3 PowerShell: Busqueda programatica

Para cuando necesitas filtrado mas sofisticado del que Notepad++ permite:

# === BUSQUEDA BASICA ===
 
# Buscar "error" en todos los .log del bundle
Get-ChildItem -Recurse -Filter "*.log" | 
    Select-String -Pattern "error" -CaseSensitive:$false |
    Select-Object Filename, LineNumber, Line
 
# === BUSQUEDA POR RANGO TEMPORAL ===
 
# Solo errores entre 11:00 y 11:15
Get-Content .\logs\zsa-tunnel.log | 
    Where-Object { $_ -match "11:0[0-9]:" -and $_ -match "(?i)(error|fail|timeout)" }
 
# === EXTRAER TODOS LOS EVENTOS BRK_MT_ ===
 
Get-Content .\logs\zsa-tunnel.log | 
    Where-Object { $_ -match "BRK_MT_" } |
    ForEach-Object { 
        if ($_ -match "(\d{2}:\d{2}:\d{2}).*?(BRK_MT_\w+).*?(?:tag\s*(\d+))?") {
            [PSCustomObject]@{
                Hora = $matches[1]
                Evento = $matches[2]
                Tag = if($matches[3]){$matches[3]}else{"N/A"}
            }
        }
    } | Sort-Object Hora | Format-Table -AutoSize
 
# === CONTAR ERRORES POR TIPO ===
 
Get-Content .\logs\zsa-tunnel.log | 
    Where-Object { $_ -match "BRK_MT_" } |
    ForEach-Object { 
        if ($_ -match "(BRK_MT_\w+)") { $matches[1] } 
    } | Group-Object | Sort-Object Count -Descending |
    Select-Object Count, Name | Format-Table -AutoSize
 
# === BUSCAR TODOS LOS CODIGOS DE ERROR ===
 
Get-Content .\logs\zsa-tunnel.log |
    Where-Object { $_ -match "(?i)error.*?(\d{4,5})" } |
    ForEach-Object { 
        if ($_ -match "(\d{4,5})") { $matches[1] } 
    } | Group-Object | Sort-Object Count -Descending |
    Select-Object Count, Name | Format-Table -AutoSize
 
# === BUSCAR CONFLICTOS WFP ===
 
Select-String -Path ".\logs\zsa-wfp.log" -Pattern "(?i)(sublayer|weight|conflict|interfere)" |
    Select-Object LineNumber, Line
 
# === EXTRAER TODAS LAS IPs OBSERVADAS ===
 
Select-String -Path ".\logs\*.log" -Pattern "\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b" -AllMatches |
    ForEach-Object { $_.Matches.Value } | 
    Sort-Object -Unique
 
# === BUSCAR FQDN_NO_MATCH ===
 
Select-String -Path ".\logs\*.log" -Pattern "FQDN" |
    Select-Object Filename, LineNumber, Line

3.4 Linux/Mac: grep, awk y sed

# === BUSQUEDA BASICA ===
 
# Buscar errores en todos los logs
grep -rni "error\|fail\|timeout" logs/*.log | head -50
 
# === BUSQUEDA POR TIPO DE EVENTO ===
 
# Todos los eventos de micro-tuneles
grep -n "BRK_MT_" logs/zsa-tunnel.log
 
# Solo fallos de micro-tuneles
grep -n "BRK_MT_.*FAIL" logs/zsa-tunnel.log
 
# Solo cierres de micro-tuneles
grep -n "BRK_MT_CLOSED" logs/zsa-tunnel.log
 
# === BUSQUEDA POR RANGO TEMPORAL ===
 
# Errores entre 11:00 y 11:15
grep -n "11:0[0-9]:" logs/zsa-tunnel.log | grep -i "error\|fail\|timeout"
 
# === CONTEO DE ERRORES ===
 
# Cuantos errores de cada tipo hay
grep "BRK_MT_" logs/zsa-tunnel.log | awk -F'BRK_MT_' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -rn
 
# === EXTRAER IPs ===
 
grep -oE "\b([0-9]{1,3}\.){3}[0-9]{1,3}\b" logs/*.log | sort -u
 
# === BUSQUEDA EN MULTIPLES ARCHIVOS ===
 
grep -rni "DTLS" logs/ --include="*.log"
 
# Buscar fallback DTLS -> TLS
grep -ni "fallback\|dtls.*tls" logs/zsa-tunnel.log
 
# === EXTRAER LINEAS POR TAG ESPECIFICO ===
 
grep "tag.*65539" logs/zsa-tunnel.log
 
# === AWK: Extraer errores con timestamp ===
 
awk '/11:0[0-9]:/ && /(error|fail|timeout)/' logs/zsa-tunnel.log
 
# === BUSQUEDA AVANZADA: Timeline de eventos ===
 
# Extraer todos los eventos en orden cronologico
grep -n "BRK_MT_\|error\|fail\|connected\|disconnected\|DTLS\|TLS\|fallback" \
    logs/zsa-tunnel.log | head -100

3.5 chrome://net-internals y brave://net-internals

Pregunta directa: sirve para analizar logs ZPA?

Respuesta corta: NO. Estas herramientas sirven para otra cosa.

Que hace net-internals:

  • chrome://net-internals (o brave://net-internals en Brave) es una herramienta de diagnostico del navegador. Muestra eventos de red del navegador en tiempo real: DNS, sockets, proxy, certificados, etc.
  • Es util para depurar problemas de conectividad web desde el navegador.
  • NO lee archivos de log de ZPA ni de ninguna otra aplicacion.

Cuando si seria util en el contexto de ZPA:

Si sospechas que ZPA esta afectando al trafico del navegador (por ejemplo, el proxy local dgwip.exe en 127.0.0.1:3128 esta interceptando trafico HTTP/HTTPS):

  1. Abre brave://net-internals/#events
  2. Haz clic en “Start logging to disk”
  3. Reproduce el problema (navega a la web que falla)
  4. Para de grabar
  5. Revisa los eventos en la pestana Events

Esto te mostraria si el proxy de ZPA esta bloqueando o retrasando las conexiones del navegador. Pero es complementario al analisis de los logs de ZPA, no un reemplazo.

Herramientas del navegador mas utiles para diagnostico de red:

brave://net-internals/#dns      → Ver la cache DNS del navegador
brave://net-internals/#events   → Eventos de red en tiempo real
brave://net-export/             → Exportar un log completo de red a JSON

brave://net-internals/#proxy    → Ver la configuracion de proxy activa
                                   (aqui verias si ZPA esta redirigiendo)

Herramienta real de Chrome mas util para esto:

chrome://net-export/ (o brave://net-export/) permite exportar todo el trafico de red del navegador a un archivo JSON que luego puedes analizar en:

Esto es mucho mas util que net-internals para analisis post-mortem.


FASE 4: Flujo de trabajo completo paso a paso

Paso 1: Preparar el terreno (2 minutos)

1. Descomprime el bundle en una carpeta dedicada
2. Abre una terminal (PowerShell o bash) en esa carpeta
3. Identifica los archivos con el comando de nivel 0
4. Toma nota del rango de fechas de los logs

Paso 2: Pasada de choque (10 minutos)

1. Abre zsa-tunnel.log en VS Code (o Notepad++ si es pequeno)
2. Busca "error" → anota los codigos que aparecen
3. Busca "BRK_MT_.*FAIL" → anota cuantos fallos hay y de que tipo
4. Busca "DTLS" → mira si funciona o falla
5. Busca "fallback" → mira si hubo degradacion DTLS → TLS

Paso 3: Contextualizar (10 minutos)

1. Abre zsa-service.log
2. Busca "FQDN" → ¿el registro del cliente fue exitoso?
3. Busca "session" → ¿hay sesiones que se crean y destruyen?
4. Abre zsa-diag.log
5. Busca "FAIL" → ¿que tests de diagnostico fallan?
6. Abre ipconfig.txt → ¿hay otros adaptadores VPN activos?
7. Abre tasklist.txt → ¿hay otros agentes VPN corriendo?

Paso 4: Correlacionar (15 minutos)

1. Si hubo fallos DTLS, busca en zsa-wfp.log si hay conflictos WFP
2. Si hubo fallos de registro (FQDN), busca en zsa-service.log el FQDN del equipo
3. Si hubo desconexiones (BRK_MT_CLOSED), busca que paso justo antes
4. Busca los timestamps de los errores criticos y busca que otros eventos
   ocurrieron en ese mismo minuto
5. Revisa drivers.txt buscando drivers VPN de terceros

Paso 5: Documentar (5 minutos)

Crea un documento con:
- Fecha y hora del problema
- Errores encontrados (con codigos y timestamps)
- Correlacion con otros eventos
- Hipotesis de causa raiz
- Evidencia que respalda o refuta la hipotesis

FASE 5: Glosario rapido de codigos de evento ZPA

Estos codigos aparecen en zsa-tunnel.log y son la clave para entender que paso:

Codigos BRK_MT_ (Broker Micro-Tunnel)

CodigoSignificadoGravedadQue hacer
BRK_MT_SETUP_SUCCESSMicro-tunel establecido correctamenteInfoNada, es normal
BRK_MT_CLOSED_FROM_ASSISTANTCierre normal (iniciado por el cliente)InfoNada, es normal
BRK_MT_SETUP_FAIL_NO_POLICY_FOUND (5011)No hay politica para esa appWarnRevisar App Segments en ZPA Admin
BRK_MT_SETUP_FAIL_TIMEOUTTimeout al establecer micro-tunelErrorRevisar conectividad con Brokers
BRK_MT_SETUP_FAIL_AUTHFallo de autenticacionErrorRevisar certificado de enrollamiento
BRK_MT_CLOSED_FROM_BROKEREl Broker cerro el micro-tunelInfo/WarnPuede ser normal o indicar politica cambiada
BRK_MT_CLOSED_FROM_NETWORKCierre por perdida de redErrorRevisar conectividad de red
BRK_MT_CLOSED_FROM_TIMEOUTCierre por timeout de actividadWarnNormal si no hay trafico

Codigos de protocolo

PatronSignificado
DTLS aparece como estado activoZPA opera con DTLS (optimo)
TLS aparece sin DTLS previoZPA opera en TLS (posible fallback permanente)
DTLS seguido de TLS fallbackDTLS fallo, se degrado a TLS
DTLS failure count NContador de intentos DTLS fallidos
MTU 1300 / MTU 1200ZPA reduciendo el tamano de paquete para evitar fragmentacion
error 10060Timeout de conexion (Winsock)
error 10054Conexion reiniciada por el par

Codigos de servicio

PatronSignificado
FQDN_NO_MATCHEl hostname del equipo no coincide con las politicas
DeviceTokenToken de dispositivo usado para autenticacion
UPM Hard v28 / Soft v912Version del protocolo de comunicacion con el Broker
SplitVpnTnIndica si la coexistencia con VPN esta habilitada

FASE 6: Ejemplo practico resumido

Situacion: “El usuario dice que no puede acceder a la aplicacion interna app1.ad.bbva.com desde ZPA.”

Flujo de diagnostico:

PASO 1: Buscar el dominio en zsa-tunnel.log
  → Busca: "app1.ad.bbva.com" o "app1"
  → Si no aparece: ZPA ni siquiera intenta conectar. Revisar politicas.
  → Si aparece: mira el tag asignado.

PASO 2: Buscar el tag del micro-tunel
  → Busca: "tag XXXXX" (el tag del paso anterior)
  → Mira si el micro-tunel se establece o falla.
  → Si BRK_MT_SETUP_SUCCESS: el tunel esta activo, el problema esta en el backend.
  → Si BRK_MT_SETUP_FAIL: anota el codigo de error.

PASO 3: Analizar el codigo de error
  → 5011 (NO_POLICY_FOUND): no hay politica para esa app. Revisar ZPA Admin.
  → Timeout: problema de conectividad con el Broker o el App Connector.
  → Auth: problema de certificado o expiracion de sesion.

PASO 4: Verificar el estado general
  → Busca en zsa-tunnel.log si hay otros micro-tuneles activos.
  → Si TODOS fallan: problema general (red, servicio, WFP).
  → Si solo UNO falla: problema especifico de esa app o politica.

PASO 5: Verificar DTLS/TLS
  → Si hay fallback a TLS activo: posible degradacion de rendimiento
  → Si DTLS funciona: el problema no es el protocolo.

Resumen visual de prioridades

IMPORTANCIA ALTA (siempre mirar)
├── zsa-tunnel.log     → Tuneles, errores, DTLS/TLS
├── zsa-service.log    → Servicio, sesiones, registro
└── zsa-diag.log       → Tests de diagnostico automaticos

IMPORTANCIA MEDIA (contexto)
├── zsa-dns.log        → Resolucion de dominios internos
├── zsa-proxy.log      → Proxy local
├── ipconfig.txt       → Estado de red
├── tasklist.txt       → Procesos (detectar conflictos)
└── zpa_config.json    → Configuracion activa

IMPORTANCIA BAJA (solo si lo anterior no da respuesta)
├── zsa-wfp.log        → Driver WFP
├── netsh_wfp_show_filters.xml → Filtros WFP (miles de lineas)
├── zsa-tray.log       → Interfaz de usuario
└── zsa-update.log     → Actualizaciones

DESCARTABLE normalmente
├── zsa-install.log    → Solo si hay problemas de instalacion
├── zsa-misc.log       → Residuo
└── *.bak / *.tmp      → Temporales

Conversion de Logs ZPA a JSON: Herramientas y Scripts

Por que convertir a JSON

Los logs de ZPA son texto plano con formato semi-estructurado: tienen timestamps, niveles de severidad, codigos de evento, y campos clave-valor, pero cada linea es un string libre. Esto dificulta:

  • Filtrar por campo (por ejemplo, “todos los errores del tag 65538”)
  • Contar ocurrencias por tipo de evento
  • Crear timelines ordenados
  • Visualizar en herramientas como Kibana, Grafana o Power BI
  • Cruzar datos entre distintos logs (tunnel + service + dns)

JSON transforma cada linea en un objeto consultable:

// ANTES (texto plano)
"2026-05-11 11:08:01 [Tunnel] [INFO] [BRK_MT_SETUP_FAIL] tag=65538 dest=crl.igrupobbva error=10060"
 
// DESPUES (JSON)
{
  "timestamp": "2026-05-11T11:08:01",
  "source": "Tunnel",
  "level": "INFO",
  "event": "BRK_MT_SETUP_FAIL",
  "tag": 65538,
  "destination": "crl.igrupobbva",
  "error_code": 10060
}

FASE 0: Entender el formato real de tus logs

Antes de escribir cualquier parser, necesitas saber exactamente como lucen tus lineas. Los logs ZPA no tienen un formato unico documentado publicamente. El formato varia entre versiones del cliente y entre componentes.

Como inspeccionar el formato

# PowerShell: ver las primeras 30 lineas de cada log
Get-ChildItem -Recurse -Filter "*.log" | ForEach-Object {
    Write-Host "`n=== $($_.Name) ===" -ForegroundColor Cyan
    Get-Content $_.FullName -Head 30
}
# Linux/Mac
for f in logs/*.log; do
    echo "=== $(basename $f) ==="
    head -30 "$f"
    echo ""
done

Formatos tipicos observados en ZPA 4.8.x

FORMATO 1 (zsa-tunnel.log):
  2026-05-11 11:08:01 [Tunnel] [INFO] [BRK_MT_SETUP_FAIL] tag=65538 dest=crl.igrupobbva error=10060

FORMATO 2 (zsa-service.log):
  2026-05-11 11:09:15 [Service] [ERROR] [SessionManager] FQDN_NO_MATCH hostname=DESKTOP-XYZ123

FORMATO 3 (zsa-dns.log):
  2026-05-11 11:10:30 [DnsCache] [DEBUG] resolve domain=v8128svc008.ad.bbva.com ip=100.64.1.1 ttl=180

FORMATO 4 (zsa-diag.log):
  2026-05-11 11:11:00 [Diag] [INFO] dtls_connectivity FAIL reason=timeout attempt=5/5

FORMATO 5 (multi-campo):
  2026-05-11 11:11:21 [Tunnel] [INFO] [BRK_MT_CLOSED_FROM_ASSISTANT] tag=65539 
    broker=87.58.87.247 app_connector=10.0.1.50 duration=342s bytes_in=1024 bytes_out=512

Accion inmediata: Abre tus logs reales con head -30 o equivalente y anota el patron exacto de cada archivo. Los scripts de abajo usan regex que puedes ajustar a tu formato concreto.


FASE 1: PowerShell (Windows) - Conversion directa

PowerShell es la herramienta mas accesible en un entorno Windows con ZPA, porque ya esta instalada.

Script basico: Parsear un solo log a JSON

# ============================================================
# Convert-ZPALogToJson.ps1
# Convierte un archivo de log ZPA a JSON
# Uso: .\Convert-ZPALogToJson.ps1 -InputFile "zsa-tunnel.log" -OutputFile "tunnel.json"
# ============================================================
 
param(
    [Parameter(Mandatory=$true)]
    [string]$InputFile,
 
    [Parameter(Mandatory=$true)]
    [string]$OutputFile,
 
    [string]$Pattern = '^\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+\[(\w+)\]\s+\[(\w+)\]\s+\[([^\]]+)\]\s*(.*)'
)
 
$entries = [System.Collections.ArrayList]::new()
$lineNum = 0
 
foreach ($line in Get-Content $InputFile) {
    $lineNum++
 
    # Intentar extraer campos con el patron regex
    if ($line -match $Pattern) {
        $timestamp = "$($matches[1])T$($matches[2])"
        $source    = $matches[3]   # Tunnel, Service, DnsCache, Diag...
        $level     = $matches[4]   # INFO, ERROR, WARN, DEBUG
        $event     = $matches[5]   # BRK_MT_SETUP_FAIL, FQDN_NO_MATCH...
        $detail    = $matches[6]   # Resto de la linea
 
        # Extraer campos clave=valor del detalle
        $fields = @{}
        if ($detail -match 'tag=(\d+)')            { $fields['tag']          = [int]$matches[1] }
        if ($detail -match 'dest=([^\s]+)')        { $fields['destination']  = $matches[1] }
        if ($detail -match 'error=(\d+)')          { $fields['error_code']   = [int]$matches[1] }
        if ($detail -match 'broker=([^\s]+)')      { $fields['broker']       = $matches[1] }
        if ($detail -match 'hostname=([^\s]+)')    { $fields['hostname']     = $matches[1] }
        if ($detail -match 'domain=([^\s]+)')      { $fields['domain']       = $matches[1] }
        if ($detail -match 'ip=(\d+\.\d+\.\d+\.\d+)') { $fields['ip']      = $matches[1] }
        if ($detail -match 'ttl=(\d+)')            { $fields['ttl']          = [int]$matches[1] }
        if ($detail -match 'attempt=(\d+)/(\d+)')  {
            $fields['attempt']    = [int]$matches[1]
            $fields['max_attempt'] = [int]$matches[2]
        }
        if ($detail -match 'reason=([^\s]+)')      { $fields['reason']       = $matches[1] }
        if ($detail -match 'duration=(\d+)s')      { $fields['duration_sec'] = [int]$matches[1] }
        if ($detail -match 'bytes_in=(\d+)')       { $fields['bytes_in']     = [int]$matches[1] }
        if ($detail -match 'bytes_out=(\d+)')      { $fields['bytes_out']    = [int]$matches[1] }
        if ($detail -match 'app_connector=([^\s]+)') { $fields['app_connector'] = $matches[1] }
        if ($detail -match 'weight=(\d+)')         { $fields['weight']       = [int]$matches[1] }
        if ($detail -match 'MTU\s*(\d+)')          { $fields['mtu']          = [int]$matches[1] }
        if ($detail -match 'FailureCount\s*(\d+)') { $fields['failure_count'] = [int]$matches[1] }
 
        # Mantener el detalle original por si acaso
        $fields['raw_detail'] = $detail.Trim()
 
        $entry = [ordered]@{
            timestamp = $timestamp
            source    = $source
            level     = $level
            event     = $event
            fields    = $fields
            line_number = $lineNum
            raw_line  = $line.Trim()
        }
 
        [void]$entries.Add($entry)
    }
    else {
        # Linea que no coincide con el patron (continuacion, multi-linea, etc.)
        if ($entries.Count -gt 0) {
            $last = $entries[$entries.Count - 1]
            $last['fields']['continuation'] += "`n$($line.Trim())"
            $last['raw_line'] += "`n$($line.Trim())"
        }
    }
}
 
# Exportar a JSON
$entries | ConvertTo-Json -Depth 5 | Out-File -Encoding UTF8 $OutputFile
 
Write-Host "Convertidas $entries.Count lineas de $InputFile a $OutputFile" -ForegroundColor Green

Uso:

.\Convert-ZPALogToJson.ps1 -InputFile "logs\zsa-tunnel.log" -OutputFile "tunnel.json"

Script avanzado: Parsear TODO el bundle

# ============================================================
# Convert-ZPABundleToJson.ps1
# Convierte TODOS los logs del bundle a un unico JSON
# Uso: .\Convert-ZPABundleToJson.ps1 -BundlePath "C:\bundle\logs" -OutputPath "bundle.json"
# ============================================================
 
param(
    [Parameter(Mandatory=$true)]
    [string]$BundlePath,
 
    [string]$OutputPath = "zpa_bundle_parsed.json",
 
    [string]$Pattern = '^\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+\[(\w+)\]\s+\[(\w+)\]\s+\[([^\]]+)\]\s*(.*)'
)
 
$allEntries = [System.Collections.ArrayList]::new()
 
Get-ChildItem -Path $BundlePath -Filter "*.log" | ForEach-Object {
    $file = $_.Name
    $lineNum = 0
 
    foreach ($line in Get-Content $_.FullName) {
        $lineNum++
        if ($line -match $Pattern) {
            $timestamp = "$($matches[1])T$($matches[2])"
            $detail = $matches[6]
 
            # Extraer campos clave=valor comunes
            $fields = @{}
            $keyValuePairs = [regex]::Matches($detail, '(\w[\w_]*)=([^\s,]+)')
            foreach ($kv in $keyValuePairs) {
                $key = $kv.Groups[1].Value
                $value = $kv.Groups[2].Value
                # Convertir a numero si parece numerico
                if ($value -match '^\d+$') {
                    $fields[$key] = [long]$value
                } else {
                    $fields[$key] = $value
                }
            }
 
            # Extraer IPs
            $ips = [regex]::Matches($line, '\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b')
            if ($ips.Count -gt 0) {
                $fields['ips_found'] = $ips | ForEach-Object { $_.Value }
            }
 
            $entry = [ordered]@{
                timestamp  = $timestamp
                source_file = $file
                source     = $matches[3]
                level      = $matches[4]
                event      = $matches[5]
                fields     = $fields
                line_number = $lineNum
            }
 
            [void]$allEntries.Add($entry)
        }
    }
}
 
# Ordenar por timestamp
$allEntries = $allEntries | Sort-Object { $_.timestamp }
 
# Exportar
$allEntries | ConvertTo-Json -Depth 5 | Out-File -Encoding UTF8 $OutputPath
 
Write-Host "Total: $($allEntries.Count) eventos de $(Get-ChildItem $BundlePath -Filter *.log | Measure-Object).Count archivos" -ForegroundColor Green
Write-Host "Exportado a: $OutputPath" -ForegroundColor Green
 
# Estadisticas rapidas
Write-Host "`n--- Resumen ---" -ForegroundColor Cyan
$allEntries | Group-Object { $_.level } | 
    Sort-Object Count -Descending |
    ForEach-Object { Write-Host "  $($_.Name): $($_.Count)" }
 
$allEntries | Group-Object { $_.event } | 
    Sort-Object Count -Descending | Select-Object -First 15 |
    ForEach-Object { Write-Host "  $($_.Name): $($_.Count)" }

Uso:

.\Convert-ZPABundleToJson.ps1 -BundlePath "logs\" -OutputPath "bundle.json"

Consultar el JSON generado con PowerShell

Una vez tienes el JSON, puedes hacer consultas rapidas:

# Cargar el JSON
$events = Get-Content "bundle.json" | ConvertFrom-Json
 
# === FILTRAR POR NIVEL ===
$errors = $events | Where-Object { $_.level -eq "ERROR" }
$errors | Select-Object timestamp, event, source, 
    @{N='Tag';E={$_.fields.tag}}, 
    @{N='Error';E={$_.fields.error_code}} | 
    Format-Table -AutoSize
 
# === FILTRAR POR EVENTO ===
$mtFails = $events | Where-Object { $_.event -match "FAIL" }
$mtFails | Format-Table timestamp, event, source -AutoSize
 
# === FILTRAR POR RANGO TEMPORAL ===
$rango = $events | Where-Object { 
    $_.timestamp -ge "2026-05-11T11:00:00" -and 
    $_.timestamp -le "2026-05-11T11:15:00" 
}
$rango | Format-Table timestamp, event, level, source -AutoSize
 
# === CONTAR EVENTOS POR TIPO ===
$events | Group-Object event | Sort-Object Count -Descending | 
    Select-Object Count, Name | Format-Table -AutoSize
 
# === FILTRAR POR TAG ===
$tag65539 = $events | Where-Object { $_.fields.tag -eq 65539 }
$tag65539 | Format-Table timestamp, event, source -AutoSize
 
# === BUSCAR ERRORES ESPECIFICOS ===
$dtlsErrors = $events | Where-Object { 
    $_.event -match "DTLS" -or 
    $_.fields.reason -match "timeout" -or
    $_.fields.error_code -eq 10060
}
$dtlsErrors | Format-Table timestamp, event, source -AutoSize
 
# === EXTRAER TODAS LAS IPs UNICAS ===
$events | ForEach-Object { $_.fields.ips_found } | 
    Where-Object { $_ } | Sort-Object -Unique
 
# === EXPORTAR SUBCONJUNTO A CSV ===
$errors | Select-Object timestamp, source_file, event, level, 
    @{N='Tag';E={$_.fields.tag}}, 
    @{N='Dest';E={$_.fields.destination}}, 
    @{N='ErrorCode';E={$_.fields.error_code}}, 
    @{N='RawLine';E={$_.raw_line}} |
    Export-Csv -NoTypeInformation -Encoding UTF8 "errores.csv"
 
# === TIMELINE DE UNA APLICACION ===
$events | Where-Object { $_.fields.destination -eq "crl.igrupobbva" } |
    Select-Object timestamp, event, @{N='Tag';E={$_.fields.tag}}, level |
    Sort-Object timestamp | Format-Table -AutoSize

FASE 2: Python (multiplataforma) - El parser mas flexible

Python es la mejor opcion si necesitas un parser robusto y reutilizable. Funciona en Windows, Linux y Mac.

Script completo con argparse

#!/usr/bin/env python3
"""
zpa_log_to_json.py
Convierte logs ZPA de texto plano a JSON estructurado.
 
Uso:
  python zpa_log_to_json.py --input zsa-tunnel.log --output tunnel.json
  python zpa_log_to_json.py --input logs/ --output bundle.json
  python zpa_log_to_json.py --input logs/ --output bundle.json --filter-level ERROR
  python zpa_log_to_json.py --input logs/ --output bundle.json --filter-event BRK_MT
  python zpa_log_to_json.py --input logs/ --output bundle.json --filter-tag 65539
  python zpa_log_to_json.py --input logs/ --output bundle.json --filter-time 11:00 11:15
"""
 
import argparse
import json
import re
import os
import sys
from pathlib import Path
from datetime import datetime
from collections import OrderedDict
 
 
# ============================================================
# PATRON REGEX - ADAPTAR A TU FORMATO REAL
# ============================================================
# Patron generico para logs ZPA:
#   YYYY-MM-DD HH:MM:SS [Source] [Level] [Event] <detalle>
#
# Si tus logs tienen un formato distinto, modifica esta regex.
 
ZPA_LINE_PATTERN = re.compile(
    r'^\s*'
    r'(?P<date>\d{4}-\d{2}-\d{2})\s+'
    r'(?P<time>\d{2}:\d{2}:\d{2})\s*'
    r'(?:\[(?P<source>[^\]]+)\])?\s*'
    r'(?:\[(?P<level>[^\]]+)\])?\s*'
    r'(?:\[(?P<event>[^\]]+)\])?\s*'
    r'(?P<detail>.*)'
)
 
# Patron para campos clave=valor
KEY_VALUE_PATTERN = re.compile(r'(\w[\w_]*)=([^\s,]+)')
 
# Patron para IPs
IP_PATTERN = re.compile(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b')
 
 
def parse_line(line: str, line_num: int, source_file: str) -> dict | None:
    """Parsea una linea de log ZPA y devuelve un dict o None si no coincide."""
 
    match = ZPA_LINE_PATTERN.match(line.strip())
    if not match:
        return None
 
    timestamp = f"{match.group('date')}T{match.group('time')}"
    source = match.group('source') or 'unknown'
    level = match.group('level') or 'unknown'
    event = match.group('event') or 'unknown'
    detail = match.group('detail') or ''
 
    # Extraer campos clave=valor
    fields = {}
    for kv in KEY_VALUE_PATTERN.finditer(detail):
        key, value = kv.group(1), kv.group(2)
        # Intentar convertir a numero
        try:
            value = int(value)
        except ValueError:
            try:
                value = float(value)
            except ValueError:
                pass
        fields[key] = value
 
    # Extraer IPs
    ips = IP_PATTERN.findall(line)
    if ips:
        fields['ips_found'] = list(set(ips))
 
    # Clasificar severidad numerica para facilitar ordenacion
    level_priority = {'ERROR': 4, 'WARN': 3, 'WARNING': 3, 'INFO': 2, 'DEBUG': 1}
    fields['_severity'] = level_priority.get(level.upper(), 0)
 
    return OrderedDict([
        ('timestamp', timestamp),
        ('source_file', source_file),
        ('source', source),
        ('level', level),
        ('event', event),
        ('fields', fields),
        ('line_number', line_num),
        ('raw_line', line.strip()),
    ])
 
 
def parse_file(filepath: str) -> list[dict]:
    """Parsea un archivo de log completo."""
    entries = []
    filename = os.path.basename(filepath)
 
    try:
        with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
            for line_num, line in enumerate(f, 1):
                entry = parse_line(line, line_num, filename)
                if entry:
                    entries.append(entry)
    except Exception as e:
        print(f"  [WARN] Error leyendo {filepath}: {e}", file=sys.stderr)
 
    return entries
 
 
def parse_bundle(bundle_path: str) -> list[dict]:
    """Parsea todos los .log de un directorio (o un archivo individual)."""
    all_entries = []
 
    path = Path(bundle_path)
 
    if path.is_file():
        print(f"  Parseando archivo: {path.name}")
        entries = parse_file(str(path))
        all_entries.extend(entries)
        print(f"    -> {len(entries)} eventos")
 
    elif path.is_dir():
        log_files = sorted(path.glob('*.log'))
        print(f"  Encontrados {len(log_files)} archivos .log")
 
        for logfile in log_files:
            print(f"  Parseando: {logfile.name}")
            entries = parse_file(str(logfile))
            all_entries.extend(entries)
            print(f"    -> {len(entries)} eventos")
 
    else:
        print(f"  [ERROR] Ruta no existe: {bundle_path}", file=sys.stderr)
        sys.exit(1)
 
    # Ordenar por timestamp
    all_entries.sort(key=lambda e: e.get('timestamp', ''))
 
    return all_entries
 
 
def apply_filters(entries: list[dict], args) -> list[dict]:
    """Aplica filtros de linea de comandos."""
    filtered = entries
 
    if args.filter_level:
        levels = [l.strip().upper() for l in args.filter_level.split(',')]
        filtered = [e for e in filtered if e['level'].upper() in levels]
 
    if args.filter_event:
        filtered = [e for e in filtered if args.filter_event.upper() in e['event'].upper()]
 
    if args.filter_tag is not None:
        filtered = [e for e in filtered if e['fields'].get('tag') == args.filter_tag]
 
    if args.filter_time:
        start, end = args.filter_time
        filtered = [e for e in filtered if start <= e['timestamp'].split('T')[1][:5] <= end]
 
    if args.filter_error:
        codes = [int(c.strip()) for c in args.filter_error.split(',')]
        filtered = [e for e in filtered if e['fields'].get('error_code') in codes]
 
    if args.filter_ip:
        filtered = [e for e in filtered if args.filter_ip in str(e['fields'].get('ips_found', []))]
 
    return filtered
 
 
def print_summary(entries: list[dict]):
    """Imprime un resumen estadistico."""
    print("\n" + "=" * 60)
    print("RESUMEN")
    print("=" * 60)
    print(f"Total eventos: {len(entries)}")
 
    # Por nivel
    levels = {}
    for e in entries:
        lvl = e['level']
        levels[lvl] = levels.get(lvl, 0) + 1
    print("\nPor nivel:")
    for lvl, count in sorted(levels.items(), key=lambda x: -x[1]):
        print(f"  {lvl}: {count}")
 
    # Por evento (top 20)
    events = {}
    for e in entries:
        evt = e['event']
        events[evt] = events.get(evt, 0) + 1
    print("\nPor evento (top 20):")
    for evt, count in sorted(events.items(), key=lambda x: -x[1])[:20]:
        print(f"  {evt}: {count}")
 
    # Por archivo fuente
    sources = {}
    for e in entries:
        src = e['source_file']
        sources[src] = sources.get(src, 0) + 1
    print("\nPor archivo:")
    for src, count in sorted(sources.items(), key=lambda x: -x[1]):
        print(f"  {src}: {count}")
 
    # Rango temporal
    if entries:
        first = entries[0]['timestamp']
        last = entries[-1]['timestamp']
        print(f"\nRango temporal: {first} -> {last}")
 
 
def main():
    parser = argparse.ArgumentParser(
        description='Convierte logs ZPA de texto plano a JSON',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Ejemplos:
  python zpa_log_to_json.py -i zsa-tunnel.log -o tunnel.json
  python zpa_log_to_json.py -i logs/ -o bundle.json
  python zpa_log_to_json.py -i logs/ -o bundle.json --filter-level ERROR
  python zpa_log_to_json.py -i logs/ -o bundle.json --filter-event BRK_MT
  python zpa_log_to_json.py -i logs/ -o bundle.json --filter-tag 65539
  python zpa_log_to_json.py -i logs/ -o bundle.json --filter-time 11:00 11:15
  python zpa_log_to_json.py -i logs/ -o bundle.json --filter-error 10060,5011
        """
    )
 
    parser.add_argument('-i', '--input', required=True, help='Archivo .log o directorio del bundle')
    parser.add_argument('-o', '--output', required=True, help='Archivo JSON de salida')
    parser.add_argument('--filter-level', help='Filtrar por nivel (ERROR,WARN,INFO,DEBUG)')
    parser.add_argument('--filter-event', help='Filtrar por tipo de evento (ej: BRK_MT)')
    parser.add_argument('--filter-tag', type=int, help='Filtrar por tag de micro-tunel')
    parser.add_argument('--filter-time', nargs=2, metavar=('START', 'END'), help='Filtrar por rango HH:MM HH:MM')
    parser.add_argument('--filter-error', help='Filtrar por codigo(s) de error (ej: 10060,5011)')
    parser.add_argument('--filter-ip', help='Filtrar por IP')
    parser.add_argument('--summary', action='store_true', help='Mostrar resumen estadistico')
    parser.add_argument('--pretty', action='store_true', help='JSON con indentacion (legible, mas grande)')
 
    args = parser.parse_args()
 
    print(f"\nParseando: {args.input}")
    entries = parse_bundle(args.input)
 
    if not entries:
        print("[WARN] No se encontraron eventos. Revisa el patron regex.", file=sys.stderr)
        sys.exit(1)
 
    # Aplicar filtros si se especificaron
    original_count = len(entries)
    if any([args.filter_level, args.filter_event, args.filter_tag,
            args.filter_time, args.filter_error, args.filter_ip]):
        entries = apply_filters(entries, args)
        print(f"\nFiltrado: {original_count} -> {len(entries)} eventos")
 
    # Resumen
    if args.summary or len(sys.argv) == 1:
        print_summary(entries)
 
    # Exportar JSON
    indent = 2 if args.pretty else None
    with open(args.output, 'w', encoding='utf-8') as f:
        json.dump(entries, f, indent=indent, ensure_ascii=False)
 
    size_mb = os.path.getsize(args.output) / (1024 * 1024)
    print(f"\nExportado: {args.output} ({len(entries)} eventos, {size_mb:.2f} MB)")
 
 
if __name__ == '__main__':
    main()

Uso:

# Convertir todo el bundle
python zpa_log_to_json.py -i logs/ -o bundle.json --summary
 
# Solo errores
python zpa_log_to_json.py -i logs/ -o errors.json --filter-level ERROR --pretty
 
# Solo eventos de micro-tuneles que fallaron
python zpa_log_to_json.py -i logs/ -o mt_fails.json --filter-event BRK_MT_FAIL
 
# Solo el tag 65539
python zpa_log_to_json.py -i logs/ -o tag_65539.json --filter-tag 65539
 
# Solo entre 11:00 y 11:15
python zpa_log_to_json.py -i logs/ -o window.json --filter-time 11:00 11:15
 
# Solo errores de codigo 10060 y 5011
python zpa_log_to_json.py -i logs/ -o specific_errors.json --filter-error 10060,5011

Script extra: Convertir JSON a CSV para Excel

#!/usr/bin/env python3
"""
zpa_json_to_csv.py
Convierte un JSON generado por zpa_log_to_json.py a CSV para abrir en Excel.
"""
 
import json
import csv
import sys
 
def json_to_csv(input_json, output_csv):
    with open(input_json, 'r', encoding='utf-8') as f:
        events = json.load(f)
 
    if not events:
        print("Sin eventos.")
        return
 
    # Recopilar todas las keys de fields
    all_field_keys = set()
    for e in events:
        all_field_keys.update(e.get('fields', {}).keys())
    # Quitar keys internas que empiezan por _
    all_field_keys = {k for k in all_field_keys if not k.startswith('_')}
    field_keys = sorted(all_field_keys)
 
    # Cabecera
    headers = ['timestamp', 'source_file', 'source', 'level', 'event', 'line_number'] + field_keys
 
    with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f:
        writer = csv.DictWriter(f, fieldnames=headers, extrasaction='ignore')
        writer.writeheader()
 
        for e in events:
            row = {
                'timestamp': e.get('timestamp', ''),
                'source_file': e.get('source_file', ''),
                'source': e.get('source', ''),
                'level': e.get('level', ''),
                'event': e.get('event', ''),
                'line_number': e.get('line_number', ''),
            }
            # Aplanar fields
            for key in field_keys:
                val = e.get('fields', {}).get(key, '')
                if isinstance(val, list):
                    val = '; '.join(str(v) for v in val)
                row[key] = val
 
            writer.writerow(row)
 
    print(f"Exportado: {output_csv} ({len(events)} filas)")
 
if __name__ == '__main__':
    if len(sys.argv) != 3:
        print("Uso: python zpa_json_to_csv.py input.json output.csv")
        sys.exit(1)
    json_to_csv(sys.argv[1], sys.argv[2])
python zpa_json_to_csv.py bundle.json bundle.csv
# -> Abrir bundle.csv en Excel con filtros y formato condicional

FASE 3: jq (Linux/Mac/Windows) - Consultar JSON sin instalar nada

Una vez tienes el JSON, jq es la herramienta estandar para consultarlo desde terminal. Es como un “grep para JSON”.

Instalacion

# Linux (Debian/Ubuntu)
sudo apt install jq
 
# Mac
brew install jq
 
# Windows (scoop)
scoop install jq
 
# Windows (chocolatey)
choco install jq

Consultas basicas sobre bundle.json

# Ver la estructura (primeras 3 entradas)
jq '.[0:3]' bundle.json
 
# Solo errores
jq '[.[] | select(.level == "ERROR")]' bundle.json
 
# Solo errores, campos clave (resumen limpio)
jq '[.[] | select(.level == "ERROR") | {
    time: .timestamp, 
    event: .event, 
    source: .source, 
    tag: .fields.tag, 
    error: .fields.error_code,
    dest: .fields.destination
}]' bundle.json
 
# Solo eventos BRK_MT que fallaron
jq '[.[] | select(.event | test("FAIL"))]' bundle.json
 
# Contar eventos por tipo
jq '[.[] | .event] | group_by(.) | map({event: .[0], count: length}) | sort_by(-.count)' bundle.json
 
# Eventos de un tag especifico
jq '[.[] | select(.fields.tag == 65539)]' bundle.json
 
# Eventos entre dos horas
jq '[.[] | select(.timestamp >= "2026-05-11T11:00:00" and .timestamp <= "2026-05-11T11:15:00")]' bundle.json
 
# Extraer todas las IPs unicas
jq '[.[] | .fields.ips_found // empty] | flatten | unique' bundle.json
 
# Errores con su linea original
jq '.[] | select(.level == "ERROR") | "\(.timestamp) \(.event) \(.raw_line)"' bundle.json
 
# Contar errores por codigo
jq '[.[] | .fields.error_code // empty] | group_by(.) | map({code: .[0], count: length})' bundle.json
 
# Timeline resumida de un micro-tunel
jq '[.[] | select(.fields.tag == 65539) | {t: .timestamp, e: .event, l: .level}]' bundle.json
 
# Exportar subconjunto a CSV con jq (usando @csv)
jq -r '(.[0] | keys_unsorted) as $keys | 
  $keys, 
  (.[] | [.[$keys[]]] ) | @csv' bundle.json > bundle.csv

Una liner para diagnostico rapido

# "Dame los 10 eventos mas frecuentes"
jq '[.[] | .event] | group_by(.) | map({count: length, event: .[0]}) | sort_by(-.count) | .[0:10]' bundle.json
 
# "Dame todos los errores con timestamp y detalle"
jq -r '.[] | select(.level=="ERROR") | "\(.timestamp) | \(.event) | \(.raw_line)"' bundle.json
 
# "Que archivos fuente generan mas eventos"
jq '[.[] | .source_file] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count)' bundle.json
 
# "Cuanto tiempo entre el primer y ultimo evento"
jq '[.[] | .timestamp] | [min, max]' bundle.json

FASE 4: Herramientas visuales para explorar el JSON

4.1 VS Code con extension JSON

VS Code tiene un visor JSON integrado que permite colapsar, expandir, y buscar dentro de archivos JSON grandes.

Extension recomendada: “JSON Flow” - Permite navegar archivos JSON de cientos de MB sin colgarse (VS Code nativo no maneja bien JSON de mas de 50MB).

Extensiones adicionales utiles:

ExtensionQue hace
JSON FlowNavega JSON grande sin congelar VS Code
Excel ViewerAbre CSV directamente en VS Code con filtros
Log File HighlighterColorea los logs originales por nivel de severidad
JSON to CSVConvierte JSON a CSV dentro de VS Code
Data PreviewVista de tabla interactiva para JSON y CSV

4.2 jsonhero.io (online, privado)

Sube tu JSON a https://jsonhero.io y obtienes:

  • Vista en arbol navegable
  • Estadisticas automaticas por campo
  • Graficos de distribucion
  • Busqueda full-text
  • Exportacion

Ventaja: No necesitas instalar nada. Puedes abrir el JSON directamente en el navegador.

Precaucion: Si el JSON contiene datos sensibles de tu empresa, no uses servicios online. Usa herramientas locales.

4.3 JSON Crack (online o self-hosted)

https://jsoncrack.com/ genera un grafo visual de tu JSON. Puedes ver la estructura y navegar por los nodos.

4.4 Tabulator / Datatables (HTML)

Si prefieres una vista de tabla en el navegador:

<!-- Guardar como viewer.html y abrir en el navegador junto al JSON -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>ZPA Log Viewer</title>
    <style>
        body { font-family: Consolas, monospace; margin: 20px; background: #1e1e1e; color: #d4d4d4; }
        h1 { color: #569cd6; }
        .controls { margin: 10px 0; }
        .controls input, .controls select { padding: 5px; margin-right: 10px; }
        table { border-collapse: collapse; width: 100%; font-size: 12px; }
        th { background: #264f78; color: white; padding: 8px; text-align: left; cursor: pointer; position: sticky; top: 0; }
        th:hover { background: #37699b; }
        td { padding: 6px 8px; border-bottom: 1px solid #333; }
        tr:hover { background: #2a2d2e; }
        .ERROR { color: #f44747; font-weight: bold; }
        .WARN, .WARNING { color: #cca700; }
        .INFO { color: #6a9955; }
        .DEBUG { color: #808080; }
        .summary { background: #252526; padding: 15px; border-radius: 5px; margin: 10px 0; }
    </style>
</head>
<body>
    <h1>ZPA Log Viewer</h1>
    <div class="controls">
        <input type="file" id="fileInput" accept=".json">
        <input type="text" id="searchBox" placeholder="Buscar en eventos...">
        <select id="levelFilter">
            <option value="">Todos los niveles</option>
            <option value="ERROR">ERROR</option>
            <option value="WARN">WARN</option>
            <option value="INFO">INFO</option>
            <option value="DEBUG">DEBUG</option>
        </select>
        <span id="countDisplay"></span>
    </div>
    <div class="summary" id="summaryBox"></div>
    <table>
        <thead>
            <tr>
                <th>Timestamp</th>
                <th>File</th>
                <th>Source</th>
                <th>Level</th>
                <th>Event</th>
                <th>Tag</th>
                <th>Fields</th>
            </tr>
        </thead>
        <tbody id="tableBody"></tbody>
    </table>
 
    <script>
        let allData = [];
 
        document.getElementById('fileInput').addEventListener('change', function(e) {
            const reader = new FileReader();
            reader.onload = function(event) {
                allData = JSON.parse(event.target.result);
                renderTable(allData);
                showSummary(allData);
            };
            reader.readAsText(e.target.files[0]);
        });
 
        document.getElementById('searchBox').addEventListener('input', applyFilters);
        document.getElementById('levelFilter').addEventListener('change', applyFilters);
 
        function applyFilters() {
            const search = document.getElementById('searchBox').value.toLowerCase();
            const level = document.getElementById('levelFilter').value;
            let filtered = allData;
            if (level) filtered = filtered.filter(e => e.level === level);
            if (search) filtered = filtered.filter(e => JSON.stringify(e).toLowerCase().includes(search));
            renderTable(filtered);
        }
 
        function renderTable(data) {
            const tbody = document.getElementById('tableBody');
            tbody.innerHTML = '';
            document.getElementById('countDisplay').textContent = data.length + ' eventos';
 
            data.forEach(e => {
                const tr = document.createElement('tr');
                const fieldsClean = {...e.fields};
                delete fieldsClean._severity;
                delete fieldsClean.ips_found;
 
                tr.innerHTML = `
                    <td>${e.timestamp}</td>
                    <td>${e.source_file}</td>
                    <td>${e.source}</td>
                    <td class="${e.level}">${e.level}</td>
                    <td>${e.event}</td>
                    <td>${e.fields.tag || ''}</td>
                    <td>${JSON.stringify(fieldsClean)}</td>
                `;
                tbody.appendChild(tr);
            });
        }
 
        function showSummary(data) {
            const levels = {};
            data.forEach(e => { levels[e.level] = (levels[e.level] || 0) + 1; });
            const summary = document.getElementById('summaryBox');
            summary.innerHTML = '<strong>Resumen:</strong> ' +
                Object.entries(levels).map(([k,v]) => `<span class="${k}">${k}: ${v}</span>`).join(' | ');
        }
    </script>
</body>
</html>

Uso: Guarda este HTML, abrelo en el navegador, y arrastra el JSON sobre el visor de archivos. Tendra filtros, busqueda, y colores por nivel de severidad.


FASE 5: Grafana + Loki (entorno profesional)

Si trabajas en un SOC o tienes un entorno de monitoreo, lo ideal es ingerir el JSON en un stack de observabilidad.

Arquitectura

Logs ZPA (texto plano)
       |
       v
zpa_log_to_json.py  (parser Python)
       |
       v
bundle.json  (JSON estructurado)
       |
       v
+----j-------+--------+
|             |        |
v             v        v
Grafana+Loki  ELK      Excel
(busqueda     (full    (manual
 y graficos)   text)    rapido)

Ingesta en Grafana + Loki

# promtail-config.yaml
# Promtail es el agente de Loki que lee archivos y los envia al servidor
 
scrape_configs:
  - job_name: zpa-logs
    static_configs:
      - targets:
          - localhost
        labels:
          job: zpa
          __path__: /ruta/a/bundle/parsed/*.json
 
    pipeline_stages:
      - json:
          expressions:
            timestamp: timestamp
            source: source
            level: level
            event: event
            tag: fields.tag
            destination: fields.destination
            error_code: fields.error_code
 
      - labels:
          level:
          event:
          source:
 
      - timestamp:
          source: timestamp
          format: "2006-01-02T15:04:05"

Consultas Grafana/Loki

# Todos los errores
{job="zpa"} | json | level="ERROR"
 
# Eventos de micro-tunel que fallaron
{job="zpa"} | json | event=~".*FAIL.*"
 
# Eventos de un tag especifico
{job="zpa"} | json | tag="65539"
 
# Contar errores por tipo por minuto
count_over_time({job="zpa"} | json | level="ERROR" [1m])
 
# Buscar texto libre
{job="zpa"} |= "DTLS" |= "timeout"

FASE 6: Herramientas de Chrome/Brave en contexto

Como se menciono antes, net-internals no sirve para leer logs ZPA. Pero hay herramientas del navegador utiles una vez tienes datos:

chrome://net-export/ (exportar trafico del navegador)

Cuando usarlo: Si quieres correlacionar el trafico del navegador con los logs de ZPA. Por ejemplo, si ZPA esta interceptando trafico via su proxy local (dgwip.exe en 127.0.0.1:3128), puedes:

  1. Abrir chrome://net-export/ (o brave://net-export/)
  2. Pulsar “Start logging to Disk”
  3. Reproducir el problema (navegar a una web que falla)
  4. Pulsar “Stop Logging”
  5. Subir el JSON resultante a https://netlog-viewer.appspot.com/

Esto te muestra:

  • Si la conexion HTTP/HTTPS pasa por el proxy de ZPA
  • Tiempos de resolucion DNS (puede incluir ZPA Domain Cache)
  • Errores de socket
  • Estado de las conexiones

Extensiones de Chrome utiles

ExtensionUso
Fiddler Everywhere (extension)Captura y analiza trafico HTTP/HTTPS en el navegador
WappalyzerDetecta que tecnologias usa un sitio (util para identificar el backend al que ZPA apunta)

JSON Viewer (extension de Chrome)

Si exportas un JSON con el parser y lo abres en Chrome/Brave, la extension JSON Viewer (de gildas-lormann) le da formato con colores, colapsado, y busqueda. Es util para una inspeccion rapida sin VS Code.


Resumen de herramientas por escenario

ESCUCHA RAPIDA (5 min)
  -> PowerShell o grep + keywords criticas

ANALISIS PROFUNDO (30 min)
  -> Python parser -> JSON -> jq o VS Code

EXPORTACION A EXCEL/INFORME
  -> Python parser -> JSON -> CSV con zpa_json_to_csv.py

VISUALIZACION INTERACTIVA (sin instalar nada)
  -> Python parser -> JSON -> viewer.html en navegador

VISUALIZACION ONLINE (datos no sensibles)
  -> Python parser -> JSON -> jsonhero.io

ENTORNO SOC / CONTINUO
  -> Python parser -> JSON -> Grafana+Loki o ELK

CORRELACION CON TRAFICO NAVEGADOR
  -> chrome://net-export/ -> netlog-viewer.appspot.com

Checklist rapido antes de empezar tu analisis

[ ] 1. Descomprimir bundle
[ ] 2. Inspeccionar formato real de los logs (head -30)
[ ] 3. Adaptar el regex del parser si es necesario
[ ] 4. Convertir a JSON con el parser
[ ] 5. Ejecutar consulta rapida: errores por tipo
[ ] 6. Filtrar por nivel ERROR y WARN
[ ] 7. Buscar codigos BRK_MT_
[ ] 8. Buscar DTLS/fallback
[ ] 9. Buscar FQDN
[ ] 10. Correlacionar timestamps entre archivos
[ ] 11. Documentar hallazgos

Guia complementaria a la hoja de ruta de analisis de logs ZPA. Scripts probados con Python 3.10+ y PowerShell 5.1+. El patron regex base debe adaptarse al formato exacto de la version de ZPA Client desplegada.