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 administración), obtienes un ZIP que contiene decenas de archivos de log. Estos logs cubren multiples componentes del cliente y pueden remontarse varios días atras. El volumen puede ser abrumador.

El objetivo no es leer todo. Es encontrar rápidamente donde está el problema.

FASE 1: Conocer la estructura del bundle

Estructura típica de un Support Bundle ZPA

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

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 versión 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 rápida (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 más grandes son los que más actividad tienen. Si zsa-tunnel.log tiene 50MB y zsa-tray.log tiene 200KB, ya sabes dónde está la acción.

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

En orden de prioridad:

1. zsa-tunnel.log - EL ARCHIVO MÁS IMPORTANTE

Por qué: Aquí se registran las conexiones de los micro-túneles: establecimiento, errores, fallos DTLS, timeouts, desconexiones. Si algo falla en ZPA, se refleja aquí.

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 qué: Gestiona el ciclo de vida del cliente, sesiones de usuario, y eventos del sistema. Si el servicio se cuelga, se reinicia, o pierde la sesión, aquí 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 DIAGNÓSTICO

Por qué: Este archivo contiene los resultados del diagnóstico interno de ZPA. Incluye tests de conectividad, estado de túneles, y problemas detectados automáticamente.

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)

Después 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.logTráfico 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.jsonConfiguración 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 actualización causo el problemaBaja
zsa-install.logSolo si el problema es la instalación 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 más accesible para análisis manual de logs.

Busqueda básica: Ctrl+F → Find in Files

Atajo: Ctrl + Shift + F

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

Esto te da todas las líneas 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 líneas largas).

Paso 2: Marcar todas las ocurrencias

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

Paso 3: Extraer solo las líneas que interesan

Search -> Find in Files → marca la opción “Bookmark Line”. Después, Search -> Bookmark -> Copy Bookmarked Lines. Esto copia solo las líneas relevantes a un nuevo documento para análisis 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 automáticamente

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 tamaño sin colgarse
  • Busqueda regex rápida en multiples archivos
  • Permite colapsar secciones por timestamps
  • Tiene extensiones especificas para logs

Extensión recomendada

Instala “Log File Highlighter” de emilast. Automáticamente colorea:

  • Líneas con ERROR/error en rojo
  • Líneas con WARN/warning en amarillo
  • Líneas 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 análisis rápido

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 más 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 diagnóstico del navegador. Muestra eventos de red del navegador en tiempo real: DNS, sockets, proxy, certificados, etc.
  • Es útil para depurar problemas de conectividad web desde el navegador.
  • NO lee archivos de log de ZPA ni de ninguna otra aplicación.

Cuando si sería útil en el contexto de ZPA:

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

1. Abre brave://net-internals/#events

  1. Haz clic en “Start logging to disk”
  2. Reproduce el problema (navega a la web que falla)
  3. Para de grabar
  4. Revisa los eventos en la pestaña Events

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

Herramientas del navegador más útiles para diagnóstico 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 más útil para esto:

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

Esto es mucho más útil que net-internals para análisis 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 rápido de códigos de evento ZPA

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

Códigos BRK_MT_ (Broker Micro-Tunnel)

CódigoSignificadoGravedadQue hacer
BRK_MT_SETUP_SUCCESSMicro-túnel 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 política para esa appWarnRevisar App Segments en ZPA Admin
BRK_MT_SETUP_FAIL_TIMEOUTTimeout al establecer micro-túnelErrorRevisar conectividad con Brokers
BRK_MT_SETUP_FAIL_AUTHFallo de autenticaciónErrorRevisar certificado de enrollamiento
BRK_MT_CLOSED_FROM_BROKEREl Broker cerro el micro-túnelInfo/WarnPuede ser normal o indicar política 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 tráfico

Códigos de protocolo

PatronSignificado
DTLS aparece como estado activoZPA opera con DTLS (óptimo)
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 tamaño de paquete para evitar fragmentación
error 10060Timeout de conexión (Winsock)
error 10054Conexión reiniciada por el par

Códigos de servicio

PatronSignificado
FQDN_NO_MATCHEl hostname del equipo no coincide con las políticas
DeviceTokenToken de dispositivo usado para autenticación
UPM Hard v28 / Soft v912Versión del protocolo de comunicación con el Broker
SplitVpnTnIndica si la coexistencia con VPN está habilitada

FASE 6: Ejemplo práctico resumido

Situación: “El usuario dice que no puede acceder a la aplicación interna app1.ad.corp.example.com desde ZPA.”

Flujo de diagnóstico:

PASO 1: Buscar el dominio en zsa-tunnel.log
 -> Busca: "app1.ad.corp.example.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

Conversión de Logs ZPA a JSON: Herramientas y Scripts

Por qué convertir a JSON

Los logs de ZPA son texto plano con formato semi-estructurado: tienen timestamps, niveles de severidad, códigos de evento, y campos clave-valor, pero cada línea 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 línea en un objeto consultable:

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

FASE 0: Entender el formato real de tus logs

Antes de escribir cualquier parser, necesitas saber exactamente como lucen tus líneas. Los logs ZPA no tienen un formato único documentado públicamente. 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 típicos 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.corp-example 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=srv-app01.ad.corp.example.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=198.51.100.10 app_connector=10.10.10.50 duration=342s bytes_in=1024 bytes_out=512

Acción 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) - Conversión directa

PowerShell es la herramienta más accesible en un entorno Windows con ZPA, porque ya está instalada.

Script básico: 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.corp-example" } |
 Select-Object timestamp, event, @{N='Tag';E={$_.fields.tag}}, level |
 Sort-Object timestamp | Format-Table -AutoSize

FASE 2: Python (multiplataforma) - El parser más flexible

Python es la mejor opción 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 estándar para consultarlo desde terminal. Es como un “grep para JSON”.

Instalación

# 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 diagnóstico rápido

# "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 extensión JSON

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

Extensión recomendada: “JSON Flow” - Permite navegar archivos JSON de cientos de MB sin colgarse (VS Code nativo no maneja bien JSON de más de 50MB).

Extensiones adicionales útiles:

ExtensiónQue 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 árbol navegable
  • Estadisticas automaticas por campo
  • Gráficos de distribución
  • Busqueda full-text
  • Exportación

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

Precaución: 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 útiles una vez tienes datos:

chrome://net-export/ (exportar tráfico del navegador)

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

1. Abrir chrome://net-export/ (o brave://net-export/)

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

Esto te muestra:

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

Extensiones de Chrome útiles

ExtensiónUso
Fiddler Everywhere (extensión)Captura y analiza tráfico HTTP/HTTPS en el navegador
WappalyzerDetecta que tecnologías usa un sitio (útil para identificar el backend al que ZPA apunta)

JSON Viewer (extensión de Chrome)

Si exportas un JSON con el parser y lo abres en Chrome/Brave, la extensión JSON Viewer (de gildas-lormann) le da formato con colores, colapsado, y busqueda. Es útil para una inspección rápida 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 rápido antes de empezar tu análisis

[ ] 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

Guía complementaria a la hoja de ruta de análisis de logs ZPA. Scripts probados con Python 3.10+ y PowerShell 5.1+. El patron regex base debe adaptarse al formato exacto de la versión de ZPA Client desplegada.