# ghchronicle Recoge todas las métricas que GitHub expone sobre una cuenta y las guarda con la fecha en que ocurrieron. Source: https://jmrplens.github.io/ghchronicle/es/ Cada una lleva la fecha en que ocurrió la cosa, que es lo que hace que una pregunta sobre julio pasado siga teniendo respuesta. ## Lo que recoge - [91 medidas](/ghchronicle/es/collectors/measurements/) - [34 colectores](/ghchronicle/es/collectors/) - [10 destinos](/ghchronicle/es/sinks/) - [152 paneles de dashboard](/ghchronicle/es/dashboards/) ## Qué es GitHub responde la mayoría de las preguntas sobre el presente y casi ninguna sobre el pasado. La API de tráfico sirve catorce días y olvida. El feed de actividad guarda los últimos trescientos eventos, sean de cuando sean. Las notificaciones leídas desaparecen. Los logs de los jobs se borran a los noventa días. **ghchronicle** recorre esas superficies con una cadencia y escribe cada observación como un punto fechado, en la base de datos que ya tengas. Un binario de Go, sin más dependencia que un analizador de YAML. ## Para quién es - Para quien ya tenga una base de datos de series temporales y un Grafana - Para quien mantenga proyectos y quiera conservar tráfico y estrellas más allá de la ventana de GitHub - Para equipos que quieran tiempos de fusión, carga de revisión y coste de CI como historia y no como un número que se reinicia - Para quien quiera sacar sus datos de GitHub antes de que GitHub los descarte ## Qué no es - No es un servicio alojado: corre en tu máquina, con tu token - No sustituye a GitHub Insights, que responde sobre el ahora - No recupera lo que GitHub ya ha descartado: empieza el día que lo ejecutas - No es un generador de insignias, aunque sepa dibujar una ## Cómo se usa Tres pasos, y la primera pasada aterriza en tu base de datos. ### Instalar Un binario, una release o la imagen de contenedor. ```sh go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest ``` ### Configurar La configuración mínima que hace algo. Cada ${VAR} se lee del entorno, así que este fichero no guarda secretos. ```yaml github: token: ${GITHUB_TOKEN} targets: user: your-login sinks: influxdb: url: http://localhost:8181 bucket: github ``` ### Recoger Lo que aterriza en la base de datos. Fíjate en las marcas de tiempo: la estrella está fechada en 2024, no hoy, porque es cuando se dio. ```text gh_star,repo=parser,user=someone starred=1i 1731590400000000000 gh_traffic,repo=parser,kind=views count=142i,uniques=61i 1757376000000000000 gh_pull_request,repo=parser,number=318,state=MERGED churn=214i,seconds_to_merge=5820i 1757462400000000000 ``` ## Cómo está hecho Cuatro ideas, y la segunda es de la que se derivan las demás. 1. **Recorrer**: Cada familia de métricas tiene su cadencia, porque se mueven a velocidades muy distintas: las ejecuciones de workflows cada cuarto de hora, el calendario de contribuciones cada doce. 2. **Fechar**: Un punto lleva el momento en que ocurrió la cosa, no el momento en que se recogió. Eso es lo que hace que volver a recoger converja en lugar de acumular copias. 3. **Enviar por push**: Aquí nada se recoge por scrape. Envía por push a InfluxDB, PostgreSQL, Graphite, Elasticsearch, Prometheus, OpenTelemetry, Loki, Telegraf, un fichero o cualquier cosa a la que llegue Telegraf, así que corre donde pueda alcanzarlos. 4. **Dibujar**: Una especificación de dashboard, renderizada una vez por almacén. Los mismos paneles con la base de datos que elijas, y donde un almacén no puede responder con honestidad, el panel lo dice. ## Por dónde empezar - [Inicio rápido](/ghchronicle/es/start/quickstart/): De cero a la primera pasada. - [El token](/ghchronicle/es/start/token/): Qué permisos, y por qué el automático no basta. - [La fecha del punto](/ghchronicle/es/how/dating/): La idea de la que se deriva el resto del diseño. - [Elegir almacén](/ghchronicle/es/sinks/): Qué puede y qué no puede responder cada uno de los 10. --- # Qué es Qué recoge ghchronicle y el problema que viene a resolver. Source: https://jmrplens.github.io/ghchronicle/es/start/ GitHub responde la mayoría de las preguntas sobre el presente y casi ninguna sobre el pasado. La API de tráfico sirve catorce días y olvida. El feed de actividad guarda los últimos trescientos eventos, sea cual sea su fecha. Las notificaciones leídas desaparecen en semanas. Los logs de los jobs se borran a los noventa días. La lista de estrellas dice cuándo se dio cada una, pero solo si se pregunta antes de que la lista sea lo bastante larga como para que recorrerla salga caro. Nada de eso queda archivado en ningún sitio salvo que uno lo archive. `ghchronicle` recorre esas superficies según un horario y escribe cada observación como un punto sellado con la fecha en que la cosa ocurrió de verdad, de modo que dentro de un año la pregunta "a qué velocidad fusionábamos en julio" siga teniendo respuesta. ```sh ghchronicle -config config.yaml ``` Es un único binario de Go sin más dependencias que un analizador de YAML, y envía por push a todos los almacenes que soporta, así que se ejecuta allí donde los alcance: un servidor, un contenedor, un workflow programado. > **La tarjeta no es el objetivo** > > También sabe dibujar una tarjeta SVG de resumen para un README de perfil. Eso > es una función secundaria. La razón por la que el proyecto existe es la > ingesta. ## Las seis palabras que usa todo lo demás | Palabra | Qué significa aquí | | --------------- | ---------------------------------------------------------------------------------------------------------- | | **familia** | Un colector, nombrado en la configuración: `actions`, `stars`, `issues`. Hay 34 | | **grupo** | Un conjunto de familias con nombre, para encender o apagar un área entera: `ci`, `security`, `audience`. Hay 8 | | **medida** | Un tipo de fila del almacén, nombrada `gh_*`: `gh_star`, `gh_workflow_run`. Hay 91 | | **punto** | Una fila: una medida, sus etiquetas, sus campos y la fecha en que ocurrió la cosa | | **pasada** | Un recorrido por las familias a las que les toca, que es lo que hace el proceso en bucle | | **relleno** | Una ejecución con `-backfill`, que recorre la historia en vez del incremento | `ghchronicle -groups` imprime los grupos con sus familias, y `ghchronicle -config config.yaml -list` imprime los repositorios que cubriría una pasada. ## Por dónde seguir - [Inicio rápido](/ghchronicle/es/start/quickstart/): de cero a una primera pasada. - [El token](/ghchronicle/es/start/token/): qué ámbito compra qué familia. - [La fecha del punto](/ghchronicle/es/how/dating/): la idea de diseño de la que se sigue todo lo demás. --- # Inicio rápido De cero a la primera pasada, y qué hace esa primera pasada que las siguientes no hacen. Source: https://jmrplens.github.io/ghchronicle/es/start/quickstart/ Seis pasos y un fichero de configuración. La única decisión que merece pensarse antes de empezar es qué almacén guarda la historia, y se puede aplazar imprimiendo primero los puntos por la terminal. ## De cero a la primera pasada 1. **Instala el binario.** - **Go** ```sh go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest ``` - **Release** Coge el archivo de tu plataforma en la [página de releases](https://github.com/jmrplens/ghchronicle/releases) y pon `ghchronicle` en el `PATH`. - **Contenedor** ```sh docker pull ghcr.io/jmrplens/ghchronicle ``` 2. **Crea un token** en `https://github.com/settings/tokens` y expórtalo. ```sh export GITHUB_TOKEN=github_pat_... ``` Un token clásico con `repo`, `read:packages`, `read:user`, `read:org`, `security_events`, `read:public_key` y `read:gpg_key` ve todo lo que esto recoge. La [página del token](/ghchronicle/es/start/token/) explica qué permiso compra qué familia, y por qué el `GITHUB_TOKEN` automático de una Action no basta. 3. **Escribe la configuración.** Dos decisiones, y este es el fichero entero: ```yaml # config.yaml github: token: ${GITHUB_TOKEN} targets: user: tu-login sinks: stdout: true # cámbialo por influxdb cuando tengas dónde guardarlo ``` Cada `${VAR}` se lee del entorno al arrancar, así que el fichero no contiene secretos y se puede versionar. Todo lo demás tiene un valor por omisión. La versión documentada, la que comenta todas las opciones que hay, vive en el repositorio y no en la instalación, así que cógela de ahí cuando quieras leer el resto: ```sh curl -O https://raw.githubusercontent.com/jmrplens/ghchronicle/main/config.example.yaml ``` 4. **Mira qué se recogería**, antes de gastar cuota en ello. ```sh ghchronicle -config config.yaml -list ``` Eso imprime los repositorios en alcance. Los forks y los archivados quedan fuera por omisión. Si falta algo que esperabas, esta es la orden que te lo dice. 5. **Ejecuta una pasada.** ```sh ghchronicle -config config.yaml -once ``` Con `sinks.stdout: true` los puntos salen por la terminal como line protocol en vez de ir a una base de datos, que es la forma más barata de ver la forma de lo que estás a punto de guardar. 6. **Déjalo corriendo.** ```sh ghchronicle -config config.yaml ``` Cada familia corre entonces con su propia cadencia: las ejecuciones de workflows cada quince minutos, el calendario de contribuciones cada doce horas. ## Qué hace la primera pasada que las siguientes no hacen Tres cosas ocurren una sola vez, y son la razón de que la primera pasada sea la cara. - Se recorre entera la lista de estrellas, página a página, para que cada estrella lleve la fecha en que se dio. Después, las cien más nuevas de cada repositorio viajan en una consulta GraphQL por cada diez. - Un mes de ejecuciones de workflows, para que una instalación nueva no dibuje una historia de CI que empieza hace quince minutos. Después, el doble de la cadencia, y nunca menos de dos horas. - El calendario de contribuciones de cada año, si `every.history` está puesto, hasta el día en que se creó la cuenta. Después, solo el año en curso, reescrito a su cadencia. Espera unos pocos miles de puntos de una primera pasada sobre veinte repositorios, y unos pocos cientos de cada una de las siguientes. Nada de eso es la historia. La primera pasada es un incremento más ancho, y un dashboard a noventa días o a dos años empieza entonces el día en que instalaste el colector: medido tras un día de pasadas, alrededor de una quinta parte de las pull requests que declaran los repositorios, commits solo de los últimos treinta días y jobs de una décima parte de las ejecuciones de workflows. Lanza `ghchronicle -config config.yaml -backfill` una vez, antes del servicio o justo después; [la página del relleno](/ghchronicle/es/how/backfill/) dice hasta dónde llega y lo que cuesta. > **Conserva el fichero de estado** > > `state_file` es lo que recuerda por dónde iba cada familia, y > [dentro viven seis cosas](/ghchronicle/es/configuration/#state_file). Bórralo y > la siguiente pasada lo recoge todo otra vez, lo que cuesta cuota y nada más en > cinco de las seis; la sexta es el commit desde el que arranca cada diff de > dependencias, y los cambios del hueco no se vuelven a recoger. ## Por dónde seguir - [Formas de instalar](/ghchronicle/es/install/) es lo que convierte la orden de arriba en algo que sigue corriendo: systemd, Docker o una Action programada. - [La fecha del punto](/ghchronicle/es/how/dating/) es la idea de diseño de la que se deriva todo lo demás. - [Elegir almacén](/ghchronicle/es/sinks/) decide qué preguntas podrás hacer después. - [Coste de una pasada](/ghchronicle/es/api/cost/) es el precio medido en llamadas a la API. --- # El token Qué familias compra cada permiso, y por qué el GITHUB_TOKEN automático no basta. Source: https://jmrplens.github.io/ghchronicle/es/start/token/ Todo aquí es acceso de lectura. El colector nunca escribe en GitHub. Lo que varía es cuánto de la cuenta se le permite ver a un token dado, y hay unos cuantos permisos que conviene entender en vez de limitarse a concederlos. ## Cómo crearlo 1. Ve a `https://github.com/settings/tokens`. 2. Elige el tipo de token y dale los permisos de abajo. - **Clásico** `repo`, `read:packages`, `read:user`, `read:org`, `security_events`, `read:public_key` y `read:gpg_key` cubre todo lo que esto recoge. - **Fine-grained** Acceso de lectura a los repositorios, más los permisos de cuenta para seguidores, gists, paquetes, plan, claves SSH de Git y claves GPG. 3. Ponlo donde el proceso pueda leerlo, y en ningún otro sitio. ```sh export GITHUB_TOKEN=github_pat_... ``` El fichero de configuración lo referencia como `${GITHUB_TOKEN}`, que se expande desde el entorno al arrancar. Eso es lo que permite versionar el fichero dejando el token fuera. ## Qué compra cada permiso | Permiso | Sin él | | ---------------------------------- | ------------------------------------------------------------------------------------------------------ | | Acceso de escritura al repositorio | El tráfico es un 403. GitHub solo muestra visitas y clones a quien podría hacer push | | `security_events` | Las alertas de Dependabot y de code scanning parecen exactamente un repositorio con la función apagada | | `read:packages` | El registro de contenedores es invisible. Las versiones de paquete cuestan una llamada por paquete | | `read:user` | Faltan seguidores, contribuciones, gists y cuentas sociales | | `read:org` | Los repositorios de una organización no se descubren | | `read:public_key`, `read:gpg_key` | La familia `keys` no escribe nada, y no lo dice. Ningún otro permiso implica ninguno de los dos | El del tráfico es el que sorprende. No es un permiso de lectura en absoluto: GitHub decide quién puede ver visitas y clones preguntando si quien llama podría hacer push, así que un token de solo lectura recibe un 403 en cada repositorio. El colector lo anota como "no disponible" y sigue, que es por lo que el síntoma es un panel de tráfico vacío y no una pasada fallida. > **No disponible no es un error** > > Todo lo que el token no puede ver se anota como no disponible y se salta. Un > repositorio con una función apagada no debe detener la pasada de los otros > cuarenta, así que el log dice `not available (403)` y la pasada continúa. ## Por qué el GITHUB_TOKEN automático no basta Un workflow recibe un `GITHUB_TOKEN` gratis. No basta para esto, y la razón merece decirse con precisión en vez de dejar que alguien la descubra como un dashboard vacío. - **Está limitado a un repositorio.** El tráfico necesita acceso de escritura a _cada_ repositorio que se recoja. El token automático lo tiene para el repositorio en el que corre el workflow, y para ninguno más. - **No tiene `security_events`.** Las alertas de Dependabot y de code scanning le son invisibles. - **No tiene `read:packages`.** El registro de contenedores le es invisible. - **No es un usuario.** Todo lo que es de cuenta (seguidores, el calendario de contribuciones, la facturación, las notificaciones, las estrellas que diste) va sobre la persona, no sobre un repositorio, y el token automático es una instalación, no una persona. Así que un workflow necesita un token personal guardado como secreto del repositorio y pasado por la entrada `token`. Ver [GitHub Actions](/ghchronicle/es/install/actions/). ## Funcionar sin token Es posible, y conviene ser honesto sobre lo que cuesta. Una tarjeta de números públicos se puede dibujar con llamadas sin autenticar, pero los paneles de tráfico y de alertas quedarán vacíos y el log dirá `not available (403)` en cada uno. Eso es el colector informando de un permiso, no de un fallo. --- # Formas de instalar Una página por sistema operativo, cuatro maneras de poner el binario en marcha, y para qué sirve cada una. Source: https://jmrplens.github.io/ghchronicle/es/install/ Un único binario estático sin dependencias en tiempo de ejecución. Cómo llega a la máquina es la única decisión, y se deriva de dónde quieras que corra. ## Tu sistema, de la descarga al servicio Las releases llevan binarios para Linux, macOS y Windows, en amd64 y arm64. Las tres páginas de abajo son el mismo recorrido para cada sistema, hasta el final: qué archivo, cómo comprobarlo, dónde va el binario, cómo ejecutarlo una vez y cómo dejarlo corriendo cuando termines de mirarlo. - [Linux](/ghchronicle/es/install/linux/): Un tar.gz, /usr/local/bin y una unidad de systemd blindada. - [macOS](/ghchronicle/es/install/macos/): El archivo darwin, el atributo de cuarentena y un agente o demonio de launchd. - [Windows](/ghchronicle/es/install/windows/): Un zip, PowerShell y cmd, una tarea programada y lo que de verdad cambia allí. ## Conseguir el binario - **Go** ```sh go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest ``` Necesita una cadena de herramientas de Go, y compila desde fuente la etiqueta más reciente. - **Release** Coge el archivo de tu plataforma en la [página de releases](https://github.com/jmrplens/ghchronicle/releases). Se compilan archivos para Linux, macOS y Windows, en amd64 y arm64, y se publican como `tar.gz` (`zip` en Windows). ```sh tar -xzf ghchronicle_1.0.0_linux_amd64.tar.gz sudo install -m 755 ghchronicle /usr/local/bin/ ``` - **Contenedor** ```sh docker run -v $PWD/config.yaml:/config.yaml:ro -e GITHUB_TOKEN \ ghcr.io/jmrplens/ghchronicle -config /config.yaml ``` Distroless, estático y corriendo como uid 65532. - **Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: once config: .github/ghchronicle.yaml ``` Una Action compuesta que descarga un binario de release, así que el runner no necesita cadena de herramientas de Go. ## Elegir dónde corre El colector envía por push a todos los almacenes que soporta, así que no hace falta que sea alcanzable desde ningún sitio. Necesita alcanzar GitHub y alcanzar sus bases de datos. Esa es la única restricción, y es lo que hace viables las cuatro opciones. Tres de las cuatro quieren una máquina propia: systemd, Docker y cron. GitHub Actions es la que no. Las páginas por sistema de arriba son donde vive el planificador, uno por sistema: systemd o cron en Linux, launchd en macOS y una tarea programada en Windows. - [systemd](/ghchronicle/es/install/systemd/): Un servicio permanente en una máquina propia. La opción por omisión: el fichero de estado persiste, el horario es el de la propia herramienta y la unidad se puede blindar a fondo. - [Docker](/ghchronicle/es/install/docker/): Lo mismo en un contenedor. Monta un volumen escribible para el fichero de estado y para cualquier destino de fichero. - [GitHub Actions](/ghchronicle/es/install/actions/): Sin máquina propia. Va bien para la tarjeta y para una pasada programada; el fichero de estado no sobrevive entre ejecuciones salvo que lo guardes en caché. - [cron](/ghchronicle/es/install/systemd/#cron-en-vez-de-un-servicio): `-once` ejecuta una pasada y termina, que es todo lo que necesita un planificador. Deja el fichero de estado en una ruta persistente. ## Los tres modos de ejecución | Orden | Hace | | ------------------------------------------- | ------------------------------------------------------------------------- | | `ghchronicle -config config.yaml` | Corre indefinidamente, cada familia con su cadencia | | `ghchronicle -config config.yaml -once` | Una pasada y termina | | `ghchronicle -config config.yaml -backfill` | Recorre cada superficie hasta el final, esperando al límite de peticiones | Más dos que no escriben nada: `-list` imprime los repositorios en alcance, y `-card ... -card-only` dibuja el SVG sin tocar una base de datos. > **El fichero de estado es lo único que debe persistir** > > Corra como corra, deja `state_file` en una ruta que sobreviva a un reinicio. > Es lo que evita que el recorrido completo de estrellas y el relleno año por > año del calendario de contribuciones vuelvan a ocurrir en cada ejecución. --- # Linux El camino completo en Linux: el archivo correcto, la firma, el PATH, un servicio y compilarlo tú mismo. Source: https://jmrplens.github.io/ghchronicle/es/install/linux/ Un único binario estático, `CGO_ENABLED=0`, así que no hay librería de C que instalar ni distribución con la que cuadrar. Un archivo de release y una entrada en el `PATH` es toda la instalación; lo que hay debajo va de hacerlo a conciencia. ## Elegir el archivo Los archivos de release se llaman `ghchronicle__linux_.tar.gz`, donde `` es `amd64` o `arm64`. `uname -m` dice cuál. | Lo que dice `uname -m` | El archivo que toca | | ---------------------- | ------------------- | | `x86_64` | `linux_amd64` | | `aarch64` | `linux_arm64` | ```sh VERSION=1.0.0 arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac base=https://github.com/jmrplens/ghchronicle/releases/download/v$VERSION curl -fsSLO "$base/ghchronicle_${VERSION}_linux_${arch}.tar.gz" ``` > **Nombra la versión en vez de pedir la más nueva** > > El repositorio publica una etiqueta móvil `v1` junto a las releases > numeradas, porque la Action está listada en el Marketplace y ese listado > necesita una. La release `v1` **no lleva ningún fichero**, así que nunca > construyas una URL de descarga a partir de la etiqueta mayor: detrás no hay > nada que descargar. Coge la versión de la > [página de releases](https://github.com/jmrplens/ghchronicle/releases) y > escríbela, como arriba. ## Comprobar lo que has descargado Junto a los archivos se publican dos ficheros: `checksums.txt`, que lleva el SHA-256 de cada archivo, y `checksums.txt.sigstore.json`, que es una firma sobre ese fichero. La cadena es entonces una firma y un resumen: verifica el fichero y luego verifica el archivo contra el fichero. 1. Coge el fichero de sumas y su firma. ```sh curl -fsSLO "$base/checksums.txt" curl -fsSLO "$base/checksums.txt.sigstore.json" ``` 2. Comprueba el archivo contra él. `--ignore-missing` es lo que permite comprobar una línea de un fichero de doce sin tener los otros once archivos. ```sh sha256sum --ignore-missing -c checksums.txt ``` ```text ghchronicle_1.0.0_linux_amd64.tar.gz: OK ``` 3. Comprueba el propio fichero de sumas, si tienes [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). ```sh cosign verify-blob \ --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --bundle checksums.txt.sigstore.json \ checksums.txt ``` ```text Verified OK ``` La firma es sin claves: no hay clave pública que buscar ni clave privada que nadie pueda perder, porque la identidad que se verifica es el workflow que se ejecutó, anotado en un registro público de transparencia. Eso es lo que dicen las dos opciones `--certificate`, y por eso no son opcionales: sin ellas cosign confirmaría que alguien firmó el fichero, que no es la pregunta. ## Ponerlo en el PATH El archivo lleva tres ficheros y ningún directorio, así que descomprímelo donde quieras de verdad. - ghchronicle_1.0.0_linux_amd64.tar.gz - ghchronicle el binario - LICENSE - README.md - **Para todos** ```sh tar -xzf ghchronicle_1.0.0_linux_amd64.tar.gz ghchronicle sudo install -m 755 ghchronicle /usr/local/bin/ ghchronicle -version ``` - **Para una cuenta** ```sh mkdir -p ~/.local/bin tar -xzf ghchronicle_1.0.0_linux_amd64.tar.gz -C ~/.local/bin ghchronicle chmod 755 ~/.local/bin/ghchronicle ghchronicle -version ``` `~/.local/bin` ya está en el `PATH` de la mayoría de distribuciones. Si `ghchronicle -version` responde «orden no encontrada», en la tuya no lo está. ```text ghchronicle 1.0.0 (commit 4e5dfc2, built 2026-09-14T23:04:02Z) ``` ## Ejecutarlo una vez El binario necesita un fichero de configuración y un token, y [el inicio rápido](/ghchronicle/es/start/quickstart/) escribe los dos en seis pasos. Con eso hecho: ```sh ghchronicle -config config.yaml -list # qué se recogería ghchronicle -config config.yaml -once # una pasada y termina ``` ## Dejarlo corriendo [systemd](/ghchronicle/es/install/systemd/) es el montaje que esta documentación toma por omisión en Linux, y la unidad de ahí está blindada en vez de ser mínima, porque este es con mucha probabilidad el único proceso de la máquina que guarda un token de GitHub con acceso de lectura a todos los repositorios de una cuenta. Un planificador también vale: [cron con `-once`](/ghchronicle/es/install/systemd/#cron-en-vez-de-un-servicio) es una sola línea, a cambio de dar a todas las familias la misma cadencia. ## Compilarlo desde fuente Go 1.27.1 o más nuevo es lo que declara el módulo. No hace falta nada más: la compilación fija `CGO_ENABLED=0`, así que no hay compilador ni paquete de cabeceras que encontrar. - **go install** ```sh go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest ``` Cae en `$(go env GOPATH)/bin`, que es `~/go/bin` salvo que lo hayas movido, y ese directorio tiene que estar en tu `PATH`. Un binario hecho así informa de su versión pero no de su commit ni de su fecha de compilación: ```text ghchronicle 1.0.0 (commit unknown, built unknown) ``` La versión sale del fichero `VERSION` que el módulo incrusta; las otras dos las sella la compilación de release y nada más, y un módulo descargado por el proxy no lleva copia de trabajo de donde leerlas. - **make** ```sh git clone https://github.com/jmrplens/ghchronicle cd ghchronicle make build # a bin/ghchronicle make install # a GOBIN, sellado como una compilación de release ``` `make build` sella la versión, el commit corto y la fecha del commit, así que `-version` dice exactamente de qué árbol viene. `make` sin objetivo lista todos los que hay. ## Dónde van los ficheros El colector no busca el fichero de configuración en ningún sitio concreto: `-config` vale por omisión `config.yaml` **relativo al directorio de trabajo**, y detrás no hay ninguna ruta de búsqueda. Así que la ruta es una decisión que tomas una vez y luego pasas en cada invocación. Lo que asume el resto de esta documentación: | Fichero | Ruta | Modo | | ------------------ | ---------------------------------- | --------------------------------- | | Configuración | `/etc/ghchronicle/config.yaml` | legible por todos, sin secretos | | Tokens | `/etc/ghchronicle/ghchronicle.env` | `600` | | Estado y registro | `/var/lib/ghchronicle/` | lo escribe el usuario del servicio | `state_file` tiene un valor por omisión propio, `ghchronicle-state.json` en el directorio de trabajo, con el registro de escrituras al lado como `ghchronicle-state-written.bin`. Ese valor está bien para una primera prueba en un directorio que hayas hecho tú, y mal para un servicio, cuyo directorio de trabajo no es algo en lo que apoyarse. Ponlo. --- # macOS El camino completo en macOS: el archivo darwin, la cuarentena, un agente o demonio de launchd y compilarlo tú mismo. Source: https://jmrplens.github.io/ghchronicle/es/install/macos/ El mismo binario estático que en todas partes, compilado para `darwin` tanto en Apple silicon como en Intel. macOS no es una plataforma que solo se compile y se dé por buena: cada cambio ejecuta la suite unitaria entera y la suite de extremo a extremo en un runner de macOS, junto a Linux y Windows. ## Elegir el archivo Los archivos de release dicen `darwin`, que es el nombre que la cadena de herramientas de Go da al sistema; macOS es el nombre que le da Apple. `uname -m` dice qué arquitectura es. | Lo que dice `uname -m` | La máquina | El archivo que toca | | ---------------------- | ------------- | ------------------- | | `arm64` | Apple silicon | `darwin_arm64` | | `x86_64` | Intel | `darwin_amd64` | ```sh VERSION=1.0.0 arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; esac base=https://github.com/jmrplens/ghchronicle/releases/download/v$VERSION curl -fsSLO "$base/ghchronicle_${VERSION}_darwin_${arch}.tar.gz" ``` > **Nombra la versión en vez de pedir la más nueva** > > El repositorio publica una etiqueta móvil `v1` junto a las releases > numeradas, porque la Action está listada en el Marketplace y ese listado > necesita una. La release `v1` **no lleva ningún fichero**, así que nunca > construyas una URL de descarga a partir de la etiqueta mayor: detrás no hay > nada que descargar. Coge la versión de la > [página de releases](https://github.com/jmrplens/ghchronicle/releases) y > escríbela, como arriba. ## Comprobar lo que has descargado Junto a los archivos se publican dos ficheros: `checksums.txt`, que lleva el SHA-256 de cada archivo, y `checksums.txt.sigstore.json`, que es una firma sobre ese fichero. 1. Coge el fichero de sumas y su firma. ```sh curl -fsSLO "$base/checksums.txt" curl -fsSLO "$base/checksums.txt.sigstore.json" ``` 2. Comprueba el archivo contra él. macOS trae `shasum` y no el `sha256sum` de una máquina Linux, así que se selecciona la línea de tu archivo y se le pasa por la tubería, que funciona con cualquier versión de `shasum`. ```sh grep "darwin_${arch}.tar.gz$" checksums.txt | shasum -a 256 -c - ``` ```text ghchronicle_1.0.0_darwin_arm64.tar.gz: OK ``` 3. Comprueba el propio fichero de sumas, si tienes [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). ```sh cosign verify-blob \ --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --bundle checksums.txt.sigstore.json \ checksums.txt ``` ```text Verified OK ``` La firma es sin claves: la identidad que se verifica es el workflow que se ejecutó, anotado en un registro público de transparencia, y por eso las dos opciones `--certificate` no son opcionales. Sin ellas cosign confirmaría que alguien firmó el fichero, que no es la pregunta. ## Ponerlo en el PATH El archivo lleva tres ficheros y ningún directorio. - ghchronicle_1.0.0_darwin_arm64.tar.gz - ghchronicle el binario - LICENSE - README.md - **Para todos** ```sh tar -xzf ghchronicle_1.0.0_darwin_arm64.tar.gz ghchronicle sudo install -m 755 ghchronicle /usr/local/bin/ ghchronicle -version ``` `/usr/local/bin` está en el `PATH` por omisión de cualquier instalación de macOS, en las dos arquitecturas, porque `/etc/paths` lo lista el primero. - **Para una cuenta** ```sh mkdir -p ~/bin tar -xzf ghchronicle_1.0.0_darwin_arm64.tar.gz -C ~/bin ghchronicle chmod 755 ~/bin/ghchronicle echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zprofile ``` `~/bin` no está en el `PATH` por omisión, de ahí la última línea. `zsh` es el intérprete de acceso en todo macOS soportado. > **Si macOS se niega a ejecutarlo** > > El binario no lleva firma de Developer ID ni está notarizado, así que una > copia que llegue con el atributo de cuarentena se rechaza con «no se puede > abrir porque no se puede verificar el desarrollador». El atributo no viaja > dentro del archivo: lo pone el proceso que escribe cada fichero, y lo heredan > los procesos que ese arranca. Un navegador escribe con la marca el `.tar.gz` > que descarga, `curl` no, y `tar -xzf` lanzado desde el Terminal escribe un > binario sin marca en cualquiera de los dos casos. El caso que muerde es abrir > el archivo en el Finder, porque Utilidad de Archivos pasa la marca a lo que > extrae. Así que mira antes de borrar nada: > > ```sh > xattr -l ghchronicle_1.0.0_darwin_arm64.tar.gz # lo que marcó el navegador > xattr -l ghchronicle # el binario extraído > xattr -c ghchronicle # quitarlo > ``` > > `xattr -c` quita todos los atributos extendidos y termina bien cuando no hay > ninguno. `xattr -d com.apple.quarantine` no: en un binario extraído desde el > Terminal se para con `No such xattr: com.apple.quarantine`, que parece una > instrucción rota y es solo que el atributo nunca estuvo ahí. ## Ejecutarlo una vez El binario necesita un fichero de configuración y un token, y [el inicio rápido](/ghchronicle/es/start/quickstart/) escribe los dos en seis pasos. Con eso hecho: ```sh ghchronicle -config config.yaml -list # qué se recogería ghchronicle -config config.yaml -once # una pasada y termina ``` ## Dejarlo corriendo con launchd launchd es lo que macOS tiene en vez de systemd, y la primera decisión que te pide es agente o demonio. | | Un LaunchAgent | Un LaunchDaemon | | -------------- | ------------------------- | -------------------------- | | Vive en | `~/Library/LaunchAgents/` | `/Library/LaunchDaemons/` | | Corre como | tú | `root`, o el `UserName` que le des | | Corre cuando | has iniciado sesión | la máquina está encendida, desde el arranque | | Bueno para | un portátil que usas | un Mac que se queda encendido | El agente es por donde empezar. No necesita `sudo`, y un colector que se para mientras el dueño del portátil está fuera de sesión no pierde nada que la siguiente pasada no recoja. ```xml title="~/Library/LaunchAgents/io.jmrp.ghchronicle.plist" Label io.jmrp.ghchronicle ProgramArguments /usr/local/bin/ghchronicle -config /Users/tu/Library/Application Support/ghchronicle/config.yaml EnvironmentVariables GITHUB_TOKEN github_pat_... RunAtLoad KeepAlive StandardOutPath /Users/tu/Library/Logs/ghchronicle.log StandardErrorPath /Users/tu/Library/Logs/ghchronicle.log ``` 1. Protege el fichero antes de que lleve un token, y luego cárgalo. ```sh chmod 600 ~/Library/LaunchAgents/io.jmrp.ghchronicle.plist launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.jmrp.ghchronicle.plist ``` 2. Mira que está arriba, y lee lo que dice. ```sh launchctl print gui/$(id -u)/io.jmrp.ghchronicle tail -f ~/Library/Logs/ghchronicle.log ``` 3. Después de editar el fichero, descárgalo y vuelve a cargarlo. launchd lee la lista de propiedades una sola vez, al arrancarla. ```sh launchctl bootout gui/$(id -u)/io.jmrp.ghchronicle launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.jmrp.ghchronicle.plist ``` > **El token queda en la lista de propiedades** > > Un trabajo de launchd no lee el perfil de tu intérprete, así que > `EnvironmentVariables` es donde tiene que ir el token. Ese fichero es > entonces el equivalente en macOS del fichero de entorno de systemd, y merece > el mismo trato: modo `600`, nunca en un repositorio, y acordarse de él cuando > se rote el token. `launchctl setenv` es la alternativa, y es peor: deja el > token en todos los procesos de la sesión. ### O una pasada con temporizador `StartInterval` es el cron de launchd, en segundos, y `-once` es el modo que le va. Sustituye `KeepAlive` por él y añade `-once` a `ProgramArguments`: ```xml StartInterval 3600 ``` Deja `state_file` en una ruta que sobreviva, en este modo sobre todo: es lo que evita que el recorrido completo de estrellas y el relleno año por año del calendario de contribuciones vuelvan a ocurrir en cada ejecución. ## Compilarlo desde fuente Go 1.27.1 o más nuevo es lo que declara el módulo. La compilación fija `CGO_ENABLED=0`, así que las herramientas de línea de órdenes de Xcode no hacen falta para ella. - **go install** ```sh go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest ``` Cae en `$(go env GOPATH)/bin`, que es `~/go/bin` salvo que lo hayas movido, y ese directorio tiene que estar en tu `PATH`. Un binario hecho así informa de su versión pero no de su commit ni de su fecha de compilación, porque esas dos las sella la compilación de release y un módulo descargado por el proxy no lleva copia de trabajo de donde leerlas. - **make** ```sh git clone https://github.com/jmrplens/ghchronicle cd ghchronicle make build # a bin/ghchronicle make install # a GOBIN, sellado como una compilación de release ``` El Makefile está escrito para GNU Make 3.81, que es la versión que trae macOS, así que el `make` de serie lo ejecuta. ## Dónde van los ficheros El colector no busca el fichero de configuración en ningún sitio concreto: `-config` vale por omisión `config.yaml` **relativo al directorio de trabajo**, y detrás no hay ninguna ruta de búsqueda. macOS no tiene la costumbre de `/etc/ghchronicle`, así que estos son los sitios convencionales, no sitios que la herramienta conozca: | Fichero | Para un agente | Para un demonio | | ----------------- | --------------------------------------------- | ------------------------------ | | Configuración | `~/Library/Application Support/ghchronicle/` | `/usr/local/etc/ghchronicle/` | | Estado y registro | `~/Library/Application Support/ghchronicle/` | `/usr/local/var/ghchronicle/` | | Log | `~/Library/Logs/ghchronicle.log` | `/usr/local/var/log/` | `state_file` tiene un valor por omisión propio, `ghchronicle-state.json` en el directorio de trabajo, con el registro de escrituras al lado como `ghchronicle-state-written.bin`. El directorio de trabajo de un trabajo de launchd no es algo en lo que apoyarse. Ponlo. --- # Windows El camino completo en Windows: el zip, PowerShell y cmd, una tarea programada y lo que de verdad cambia allí. Source: https://jmrplens.github.io/ghchronicle/es/install/windows/ Windows es una plataforma publicada, no algo añadido después: cada cambio ejecuta la suite unitaria entera y la de extremo a extremo en un runner de Windows, y el código fuente lleva partes solo de Windows allí donde el sistema se comporta distinto. Lo que sigue está escrito para PowerShell, con la forma de `cmd` al lado siempre que las dos difieran. ## Lo que cambia aquí Lee esto primero. Cuatro de las cinco líneas de abajo son la razón de que una orden copiada de una página de Linux no funcione. - **El programa** es `ghchronicle.exe`. Escribir `ghchronicle` lo encuentra, porque `PATHEXT` lista `.EXE`. - **Dependencias en ejecución**: ninguna. El binario se compila con `CGO_ENABLED=0`, así que no hay runtime de C que instalar. - **Pararlo** es Ctrl+C en su consola. No hay señal que enviarle, y `taskkill /F` lo termina sin cerrar sus destinos. - **Rutas en la configuración**: una barra invertida dentro de un escalar YAML entre comillas dobles es un escape. Usa barras normales. - **Ejecución desatendida**: una tarea programada. El binario no es un servicio del Administrador de control de servicios. ## Elegir el archivo Los archivos de Windows son `zip` y no `tar.gz`, y se llaman `ghchronicle__windows_.zip`, donde `` es `amd64` o `arm64`. | `$env:PROCESSOR_ARCHITECTURE` | El archivo que toca | | ----------------------------- | ------------------- | | `AMD64` | `windows_amd64` | | `ARM64` | `windows_arm64` | Esa variable describe el **proceso**, no la máquina. Un PowerShell de 32 bits en una máquina de 64 informa de `x86` y deja la arquitectura real de la máquina en `$env:PROCESSOR_ARCHITEW6432`; un PowerShell x64 emulado en una máquina ARM64 informa de `AMD64` y no pone nada más, que es el caso que te entrega sin ruido el archivo equivocado. Si alguno de los dos puedes ser tú, pregúntale a la máquina y no al intérprete: ```powershell (Get-CimInstance Win32_Processor).Architecture # 9 es x64, 12 es ARM64 ``` ```powershell $version = "1.0.0" $arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "amd64" } $base = "https://github.com/jmrplens/ghchronicle/releases/download/v$version" $zip = "ghchronicle_${version}_windows_${arch}.zip" Invoke-WebRequest -UseBasicParsing -Uri "$base/$zip" -OutFile $zip ``` `-UseBasicParsing` no es adorno. En Windows PowerShell 5.1 `Invoke-WebRequest` arma su resultado a través del motor de Internet Explorer salvo que se le diga que no, y falla del todo en una máquina donde Internet Explorer se haya quitado o nunca haya pasado su configuración de primer arranque, que es el caso de Server Core y de casi cualquier imagen endurecida. En PowerShell 7 el modificador se acepta y no hace nada. > **Nombra la versión en vez de pedir la más nueva** > > El repositorio publica una etiqueta móvil `v1` junto a las releases > numeradas, porque la Action está listada en el Marketplace y ese listado > necesita una. La release `v1` **no lleva ningún fichero**, así que nunca > construyas una URL de descarga a partir de la etiqueta mayor: detrás no hay > nada que descargar. Coge la versión de la > [página de releases](https://github.com/jmrplens/ghchronicle/releases) y > escríbela, como arriba. ## Comprobar lo que has descargado `checksums.txt` lleva el SHA-256 de cada archivo, y `checksums.txt.sigstore.json` es una firma sobre ese fichero. 1. Coge el fichero de sumas y compara la única línea que es tuya. ```powershell Invoke-WebRequest -UseBasicParsing -Uri "$base/checksums.txt" -OutFile checksums.txt $expected = (Select-String -Path checksums.txt -Pattern ([regex]::Escape($zip) + '$')).Line.Split(" ")[0] $actual = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash if ($actual -eq $expected) { "OK" } else { "MISMATCH" } ``` `Get-FileHash` devuelve el resumen en mayúsculas y `checksums.txt` lo guarda en minúsculas. Aun así comparan igual porque el `-eq` de PowerShell entre dos cadenas ignora las mayúsculas, que es el único sitio de esta página donde ese comportamiento por omisión resulta cómodo en vez de una trampa. 2. Comprueba el propio fichero de sumas, si tienes [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). La orden es la misma que imprimen las notas de la release, y la misma que ejecuta quien lea la página de Linux o la de macOS. ```powershell cosign verify-blob ` --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' ` --certificate-oidc-issuer https://token.actions.githubusercontent.com ` --bundle checksums.txt.sigstore.json ` checksums.txt ``` El acento grave es la continuación de línea de PowerShell, donde un guion de intérprete usa la barra invertida. ## Ponerlo en un sitio y en el PATH El archivo lleva tres ficheros y ningún directorio, así que descomprímelo en un directorio que hayas hecho. - ghchronicle_1.0.0_windows_amd64.zip - ghchronicle.exe el binario - LICENSE - README.md - **Para todos** Un PowerShell elevado, porque tanto el directorio como el `Path` de máquina piden permisos de administrador. ```powershell $dir = "C:\Program Files\ghchronicle" Expand-Archive -Path $zip -DestinationPath $dir -Force $machine = [Environment]::GetEnvironmentVariable("Path", "Machine") [Environment]::SetEnvironmentVariable("Path", "$machine;$dir", "Machine") ``` - **Para una cuenta** Sin elevación, y sin tocar nada fuera de tu perfil. ```powershell $dir = "$env:LOCALAPPDATA\Programs\ghchronicle" Expand-Archive -Path $zip -DestinationPath $dir -Force $user = [Environment]::GetEnvironmentVariable("Path", "User") [Environment]::SetEnvironmentVariable("Path", "$user;$dir", "User") ``` > **Dos trampas al escribir Path de vuelta** > > Los dos fragmentos leen `Path` del ámbito en el que escriben, nunca de > `$env:Path`. `$env:Path` es la copia propia del proceso, que Windows armó > juntando la lista de máquina y la de usuario: escribe eso de vuelta en > cualquiera de los dos ámbitos y habrás copiado el otro dentro, para siempre, > y vuelve a crecer cada vez que alguien repita la orden. > > La segunda trampa es solo del ámbito de máquina. > `[Environment]::GetEnvironmentVariable` expande `%SystemRoot%` y sus parientes > al leer, y `SetEnvironmentVariable` escribe el resultado de vuelta como una > cadena llana, así que un `Path` de máquina que llevara esas entradas vuelve > con ellas ya resueltas y su valor del registro cambia de tipo, de > `REG_EXPAND_SZ` a `REG_SZ`, para siempre. En `$machine` no se ve, porque la > expansión ya ocurrió antes. Abre Propiedades del sistema, Variables de > entorno, que muestra el valor sin expandir: si hay algún `%VAR%` en el `Path` > de máquina, añade el directorio desde ese cuadro de diálogo y no desde el > fragmento de arriba. Un `Path` nuevo solo llega a los procesos que arranquen después, así que abre una terminal nueva antes de la comprobación de abajo. La actual se queda con el entorno que le dieron. ```powershell ghchronicle -version ``` ```text ghchronicle 1.0.0 (commit 4e5dfc2, built 2026-09-14T23:04:02Z) ``` > **Si Windows avisa sobre el fichero** > > El binario no lleva firma Authenticode: la release firma el fichero de sumas > y los SBOM, y nada más. SmartScreen y Defender tratan un ejecutable sin > firmar descargado de internet con sus propios criterios, que varían por > versión y por directiva y que esta página no va a adivinar. Si Windows se > niega a arrancarlo, el atributo que hay que quitar es la marca de la web: > > ```powershell > Unblock-File -Path "$dir\ghchronicle.exe" > ``` ## El token, y el resto del entorno Cada `${VAR}` del fichero de configuración se lee del entorno cuando arranca el proceso, así que el token nunca tiene que estar en el fichero. - **PowerShell** ```powershell # Solo esta ventana $env:GITHUB_TOKEN = "github_pat_..." # Persistido para esta cuenta [Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "github_pat_...", "User") ``` - **cmd** ```bat rem Solo esta ventana set GITHUB_TOKEN=github_pat_... rem Persistido para esta cuenta, y NO visible en esta ventana setx GITHUB_TOKEN "github_pat_..." ``` > **Una variable persistida es un token en el registro** > > `SetEnvironmentVariable` con `User` o `Machine`, y `setx`, escriben en el > registro en claro, donde puede leerlas cualquier cosa que corra con esa > cuenta. Es el mismo trato que un fichero de entorno de systemd, sin el modo > del fichero en el que apoyarse. Una tarea programada que corra como `SYSTEM` > lee el ámbito de máquina, así que un token puesto ahí lo puede leer cualquier > servicio de la máquina: prefiere el ámbito de usuario y una tarea que corra > como ese usuario. ## Ejecutarlo una vez El binario necesita un fichero de configuración, y [el inicio rápido](/ghchronicle/es/start/quickstart/) escribe uno en seis pasos. Guárdalo en UTF-8, y atención a los dos detalles de Windows de debajo. ```powershell ghchronicle -config config.yaml -list # qué se recogería ghchronicle -config config.yaml -once # una pasada y termina ``` Si el binario está en el directorio actual y no en el `PATH`, PowerShell necesita que se le nombre como ruta: `.\ghchronicle.exe`. Un nombre a secas es una orden, y el directorio actual no se busca para órdenes. ### Rutas en el fichero de configuración YAML trata la barra invertida como un escape dentro de un escalar entre **comillas dobles** y como un carácter corriente en cualquier otro sitio. Así que una ruta de Windows entre comillas dobles no es la ruta que escribiste, y normalmente tampoco es YAML válido: ```yaml state_file: "C:\ghchronicle\state.json" # ghchronicle: config.yaml: yaml: line N: found unknown escape character state_file: C:\ghchronicle\state.json # correcto, escalar simple state_file: 'C:\ghchronicle\state.json' # correcto, comillas simples state_file: C:/ghchronicle/state.json # correcto, y el que conviene ``` Las barras normales son la respuesta más simple: Windows las acepta en una ruta, y sobreviven a entrecomillarlas de cualquiera de las maneras. ### El fichero tiene que ser UTF-8 Windows PowerShell 5.1, el que viene de serie, no escribe ninguna de las dos cosas que quiere un analizador de YAML, y escribe una cosa equivocada distinta según cómo se lo pidas. `>` y `Out-File` producen UTF-16LE, que el analizador lee como binario. `Set-Content` produce la página de códigos activa del sistema, normalmente ANSI, que se analiza bien mientras el fichero sea ASCII puro y destroza el primer carácter acentuado que aparezca. PowerShell 7 usa UTF-8 sin marca de orden de bytes por omisión y no tiene ninguno de los dos problemas; en 5.1, sé explícito: ```powershell Set-Content -Path config.yaml -Value $text -Encoding utf8 ``` `utf8` en 5.1 quiere decir UTF-8 **con** marca de orden de bytes, cosa que el valor no sabe expresar y de la que el cmdlet no avisa. El analizador del colector la salta, así que el fichero funciona; una herramienta que lea los primeros bytes por su cuenta puede que no. ## Dejarlo corriendo No hay modo servicio. El colector es un programa de consola: no habla con el Administrador de control de servicios, así que registrarlo con `sc.exe create` produce un servicio que Windows arranca y luego abandona, informando de que «no respondió a la petición de inicio o control de manera oportuna». Existen envoltorios de servicio de terceros y este proyecto ni distribuye ni prueba ninguno. La forma corriente de ejecutar algo desatendido en Windows es una tarea programada, y tiene dos formas. - **Una pasada con temporizador** El equivalente de cron en Windows, y el que conviene: no hay nada que parar, y una ejecución perdida cuesta una pasada. ```powershell $action = New-ScheduledTaskAction ` -Execute "C:\Program Files\ghchronicle\ghchronicle.exe" ` -Argument '-config "C:\ProgramData\ghchronicle\config.yaml" -once' $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) ` -RepetitionInterval (New-TimeSpan -Hours 1) ` -RepetitionDuration ([TimeSpan]::MaxValue) Register-ScheduledTask -TaskName ghchronicle -Action $action -Trigger $trigger ``` `-RepetitionDuration ([TimeSpan]::MaxValue)` es lo que deja escrito «para siempre». La regla del propio Programador de tareas es que una repetición sin duración se repite indefinidamente, así que omitirlo no es un error, pero el cmdlet no tiene valor propio por omisión y la tarea queda registrada sin ninguna duración. Esta forma trae dos condiciones, y ninguna es la de cron. Una tarea horaria da a todas las familias una cadencia horaria como mucho, así que el ritmo de quince minutos de `actions` se pierde, que es el mismo cambio que [hace cron en Linux](/ghchronicle/es/install/systemd/#cron-en-vez-de-un-servicio). Y aquí `Register-ScheduledTask` no nombra ni `-User` ni `-Principal`, así que la tarea queda registrada con la cuenta que la crea y con el tipo de inicio de sesión por omisión, y corre **solo mientras esa cuenta tenga la sesión iniciada**. Un trabajo de cron no se para al cerrar sesión. Para que esta se comporte igual, regístrala con una entidad de seguridad con «ejecutar tanto si el usuario inició sesión como si no» marcado, que es `New-ScheduledTaskPrincipal`. - **Corriendo todo el rato** Una tarea disparada al iniciar sesión o al arrancar, con el colector en su modo permanente para que cada familia conserve su cadencia. ```powershell $action = New-ScheduledTaskAction ` -Execute "C:\Program Files\ghchronicle\ghchronicle.exe" ` -Argument '-config "C:\ProgramData\ghchronicle\config.yaml"' $trigger = New-ScheduledTaskTrigger -AtLogOn $settings = New-ScheduledTaskSettingsSet ` -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) Register-ScheduledTask -TaskName ghchronicle -Action $action ` -Trigger $trigger -Settings $settings ``` `-ExecutionTimeLimit ([TimeSpan]::Zero)` es el que importa: el valor por omisión detiene la tarea a los tres días, que para un proceso pensado para no terminar es un reinicio que nadie pidió. ```powershell Start-ScheduledTask -TaskName ghchronicle Get-ScheduledTaskInfo -TaskName ghchronicle # última ejecución, último resultado ``` `Stop-ScheduledTask` termina el proceso en vez de pedirle que acabe: es `taskkill /F` con otro nombre, y no cierra ningún destino al salir. En la forma con temporizador no hay nada que parar, que es buena parte de por qué es la mejor opción por omisión. > **Dónde va el log** > > Una tarea no tiene consola, así que el log tiene que ser un fichero: pon > `log.file` en la configuración. La salida estándar de una tarea programada se > descarta, y `Get-ScheduledTaskInfo` informa solo del código de salida. ## Compilarlo desde fuente Go 1.27.1 o más nuevo, que es la versión que declara `go.mod`. `CGO_ENABLED=0` quiere decir sin MSVC, sin MinGW y sin SDK de Windows. - **go install** ```powershell go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest ``` Cae en `$(go env GOPATH)\bin`, que es `%USERPROFILE%\go\bin` salvo que lo hayas movido, y ese directorio tiene que estar en tu `Path`. Un binario hecho así informa de su versión pero no de su commit ni de su fecha de compilación, porque esas dos las sella la compilación de release y un módulo descargado por el proxy no lleva copia de trabajo de donde leerlas. - **Desde una copia de trabajo** ```powershell git clone https://github.com/jmrplens/ghchronicle cd ghchronicle go build -o ghchronicle.exe .\cmd\ghchronicle ``` `go build` y no `make`: el Makefile es un Makefile de GNU cuyas recetas son intérprete POSIX, así que quiere Git Bash, MSYS2 o WSL. Esta línea es lo que hace `make build`, menos el sellado de la versión. ## Dónde van los ficheros El colector no busca el fichero de configuración en ningún sitio concreto: `-config` vale por omisión `config.yaml` **relativo al directorio de trabajo**, y detrás no hay ninguna ruta de búsqueda. El directorio de trabajo de una tarea programada no es algo en lo que apoyarse, así que da todas las rutas del fichero en absoluto. | Fichero | Para una tarea de máquina | Para una cuenta | | ----------------- | ----------------------------- | ------------------------------ | | Configuración | `C:/ProgramData/ghchronicle/` | `${LOCALAPPDATA}/ghchronicle/` | | Estado y registro | `C:/ProgramData/ghchronicle/` | `${LOCALAPPDATA}/ghchronicle/` | | Log | `C:/ProgramData/ghchronicle/` | `${LOCALAPPDATA}/ghchronicle/` | `${LOCALAPPDATA}` se escribe así porque `${VAR}` es la única forma que el colector expande, desde el entorno, al arrancar el proceso. `%VAR%` es una notación del intérprete y para el fichero no significa nada: un `%LOCALAPPDATA%` copiado dentro del YAML te da un directorio llamado literalmente `%LOCALAPPDATA%`, al lado de donde la tarea estuviera trabajando. Son `%LOCALAPPDATA%` en `cmd` y `$env:LOCALAPPDATA` en PowerShell los que crean el directorio en primer lugar. Un directorio bajo `C:\ProgramData` lo puede escribir quien lo creó y lo puede leer todo el mundo, así que créalo elevado y luego concede escritura a la cuenta con la que corre la tarea. El fichero de estado y su registro de escrituras son los dos que el colector reescribe en cada pasada; si uno de ellos está marcado como de solo lectura el colector quita ese atributo él mismo, porque NTFS se niega a reemplazar un fichero de solo lectura incluso cuando se le pide reemplazarlo, y una pasada no debería fallar por una propiedad de fichero. --- # systemd Una unidad blindada para el único proceso de la máquina que guarda un token de GitHub, y para qué sirve cada restricción. Source: https://jmrplens.github.io/ghchronicle/es/install/systemd/ ## La unidad ```ini title="/etc/systemd/system/ghchronicle.service" [Unit] Description=ghchronicle, GitHub metrics collector After=network-online.target Wants=network-online.target [Service] Type=simple User=ghchronicle Group=ghchronicle EnvironmentFile=/etc/ghchronicle/ghchronicle.env ExecStart=/usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml Restart=always RestartSec=30s # This is the only process on the host holding a GitHub token, so it gets # nothing it does not need. NoNewPrivileges=true PrivateTmp=true PrivateDevices=true ProtectSystem=strict ProtectHome=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true ProtectClock=true ProtectHostname=true ProtectProc=invisible RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true LockPersonality=true MemoryDenyWriteExecute=true SystemCallArchitectures=native SystemCallFilter=@system-service CapabilityBoundingSet= AmbientCapabilities= RestrictAddressFamilies=AF_INET AF_INET6 StateDirectory=ghchronicle ReadWritePaths=/var/lib/ghchronicle [Install] WantedBy=multi-user.target ``` ## Por qué está blindada El modelo de amenaza es corto y es toda la justificación: **este es muy probablemente el único proceso de la máquina que guarda un token de GitHub con acceso de lectura a todos los repositorios de una cuenta.** Un token es una credencial al portador. Cualquier cosa que pueda leer la memoria de este proceso o su fichero de entorno tiene la cuenta. Así que la unidad le da al proceso exactamente lo que necesita, que resulta ser casi nada: un socket TCP saliente y un directorio escribible. | Directiva | Qué quita | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `CapabilityBoundingSet=`, `AmbientCapabilities=` | Todas las capacidades de Linux. No abre puertos privilegiados ni posee dispositivos | | `NoNewPrivileges=true` | Cualquier vía para ganar privilegios mediante exec, incluidos los binarios setuid | | `ProtectSystem=strict` | El acceso de escritura a todo el sistema de ficheros, salvo `ReadWritePaths` | | `ProtectHome=true` | Todos los directorios personales, que es donde suelen vivir las credenciales interesantes de una máquina | | `PrivateTmp=true`, `PrivateDevices=true` | Los ficheros temporales compartidos y los nodos de dispositivo físicos | | `ProtectProc=invisible` | La capacidad de ver los procesos de otros usuarios en `/proc`, así que no puede leer la línea de órdenes de otro proceso | | `RestrictAddressFamilies=AF_INET AF_INET6` | Los sockets Unix y netlink. Habla HTTPS y nada más | | `MemoryDenyWriteExecute=true`, `LockPersonality=true` | Las primitivas habituales de shellcode | | `SystemCallFilter=@system-service` | Toda llamada al sistema fuera del conjunto ordinario de servicio, incluidas las de módulos y ajuste del núcleo | | `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname`, `RestrictNamespaces`, `RestrictRealtime`, `RestrictSUIDSGID` | Todas las vías que quedan para cambiar la máquina desde dentro del servicio | `StateDirectory=ghchronicle` hace que systemd cree `/var/lib/ghchronicle` con el dueño correcto al arrancar, así que el fichero de estado tiene dónde vivir sin un `mkdir` manual y un `chown` que alguien olvidará tras una reinstalación. Ahí viven dos ficheros, no uno. Junto a `state.json` la pasada guarda su registro de escrituras, `state-written.bin` por omisión, que es lo que evita volver a escribir un punto que no ha cambiado; `ReadWritePaths` cubre el directorio, así que los dos están ya permitidos. Si pones alguno en otro sitio, esa ruta hay que añadirla aquí, y perder el registro cuesta una pasada de reescritura: [solo se escribe lo que ha cambiado](/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). ## Instalarla 1. Crea el usuario y los directorios. ```sh sudo useradd --system --no-create-home --shell /usr/sbin/nologin ghchronicle sudo mkdir -p /etc/ghchronicle ``` 2. Pon la configuración en su sitio. - /etc/ghchronicle/ - config.yaml legible por todos, sin secretos dentro - ghchronicle.env modo 600, los tokens - /var/lib/ghchronicle/ - state.json lo crea el servicio - state-written.bin el registro de escrituras, al lado 3. Escribe el fichero de entorno, y nada más en él. ```sh title="/etc/ghchronicle/ghchronicle.env" GITHUB_TOKEN=github_pat_... INFLUX_TOKEN=... ``` ```sh sudo chmod 600 /etc/ghchronicle/ghchronicle.env ``` Todo en `config.yaml` los lee mediante `${VAR}`, que es lo que permite que la configuración sea legible por todos y esté versionada mientras los secretos no lo están. 4. Arráncalo. ```sh sudo systemctl daemon-reload sudo systemctl enable --now ghchronicle sudo systemctl status ghchronicle ``` ## Qué vigilar El log dice qué se escribió y dónde. ```text level=INFO msg=written sink=influxdb family=traffic points=629 level=INFO msg="rate budget" bucket=core remaining=4354 limit=5000 ``` Dos avisos merecen una alerta: - **`rate limit reserve reached`** significa que se saltó una familia para proteger el presupuesto. Una vez está bien; en cada pasada significa que las cadencias son demasiado rápidas para el número de repositorios. - **`family failed everywhere, not marking it as run`** significa que todos los repositorios fallaron en una familia, así que se reintentará en vez de darla por hecha. ```sh journalctl -u ghchronicle -f journalctl -u ghchronicle -p warning --since today ``` > **Revisa el sandbox tras editar la unidad** > > `systemd-analyze security ghchronicle` puntúa la unidad y nombra todo lo que > el sandbox no está cubriendo. Es la forma más rápida de ver que una edición > quitó en silencio una restricción. ## cron en vez de un servicio `-once` ejecuta una sola pasada y termina, que es todo lo que necesita un planificador. ```text 0 * * * * /usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -once ``` Deja el fichero de estado en una ruta persistente también en este modo. Es lo que evita que el recorrido de estrellas y el relleno año por año del calendario de contribuciones vuelvan a ocurrir en cada ejecución. Ten en cuenta que un cron horario da a cada familia una cadencia horaria como mucho, así que el ritmo de quince minutos de `actions` se pierde. --- # Docker La imagen distroless, qué hay que montar con escritura y un compose junto a InfluxDB. Source: https://jmrplens.github.io/ghchronicle/es/install/docker/ ```sh docker run -d --name ghchronicle \ -v /etc/ghchronicle/config.yaml:/config.yaml:ro \ -e GITHUB_TOKEN -e INFLUX_TOKEN \ -p 9605:9605 \ ghcr.io/jmrplens/ghchronicle -config /config.yaml ``` ## La imagen Construida `FROM gcr.io/distroless/static-debian13:nonroot` sobre un binario estático con `CGO_ENABLED=0`. No lleva shell ni gestor de paquetes, así que la ejecución de código dentro del contenedor no tiene con qué pivotar, y la etiqueta `nonroot` fija el **uid 65532**, lo que lo mantiene fuera de root incluso cuando el orquestador no pone ningún `securityContext` propio. Dos consecuencias que conviene saber antes de depurarlo: - `docker exec ... sh` no funciona. No hay `sh`. Lee los logs en su lugar. - Todo lo que el contenedor escriba debe pertenecer al uid 65532 o ser escribible por él. ## Qué tiene que ser escribible El fichero de configuración se monta de solo lectura. Cuatro cosas no: | Ruta | Necesaria para | | ------------------- | --------------------------------------------------------------------------------------------- | | `state_file` | Siempre. Sin una ruta persistente el recorrido de estrellas se repite en cada reinicio | | `sinks.dedupe_file` | Siempre. El registro de escrituras, que por omisión vive junto al fichero de estado | | `sinks.file.path` | Solo con el destino de fichero | | `log.file` | Solo con un fichero de log configurado | Los dos primeros viven por omisión en el mismo directorio, así que un solo volumen montado cubre ambos. Montar solo el fichero de estado pierde el registro en cada reinicio, y entonces cada reinicio cuesta una pasada entera de reescritura, que es justo lo que el registro existe para evitar: [solo se escribe lo que ha cambiado](/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). ```sh docker volume create ghchronicle-state docker run -d --name ghchronicle \ -v /etc/ghchronicle/config.yaml:/config.yaml:ro \ -v ghchronicle-state:/var/lib/ghchronicle \ -e GITHUB_TOKEN \ ghcr.io/jmrplens/ghchronicle -config /config.yaml ``` ## El puerto `EXPOSE 9605` es el exportador de Prometheus, y es el único listener que el proceso abre. Publícalo solo si activaste el destino `prometheus`; todos los demás destinos son salientes. > **Escucha en 0.0.0.0 dentro de un contenedor** > > La configuración de ejemplo escucha en `127.0.0.1:9605`, que dentro de un > contenedor es el loopback del propio contenedor y es inalcanzable desde la > máquina anfitriona. Pon `sinks.prometheus.listen: 0.0.0.0:9605` y deja que > `-p` decida quién llega. ## Con compose - **Solo el colector** ```yaml title="compose.yaml" services: ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN} INFLUX_TOKEN: ${INFLUX_TOKEN} volumes: - ./config.yaml:/config.yaml:ro - state:/var/lib/ghchronicle volumes: state: ``` - **Con InfluxDB** ```yaml title="compose.yaml" services: influxdb: image: influxdb:3-core volumes: - influx:/var/lib/influxdb3 ports: - "8181:8181" ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: - influxdb environment: GITHUB_TOKEN: ${GITHUB_TOKEN} INFLUX_TOKEN: ${INFLUX_TOKEN} volumes: - ./config.yaml:/config.yaml:ro - state:/var/lib/ghchronicle volumes: influx: state: ``` El colector alcanza la base de datos por nombre de servicio, así que `sinks.influxdb.url` es `http://influxdb:8181`. ## Una pasada y salir El contenedor acepta las mismas opciones que el binario, así que un planificador puede ejecutarlo sin un servicio permanente. ```sh docker run --rm \ -v /etc/ghchronicle/config.yaml:/config.yaml:ro \ -v ghchronicle-state:/var/lib/ghchronicle \ -e GITHUB_TOKEN \ ghcr.io/jmrplens/ghchronicle -config /config.yaml -once ``` Monta el volumen de estado también en este modo. Es lo que hace barata la segunda ejecución. --- # GitHub Actions La Action compuesta, sus tres modos y las dos cosas que un runner alojado no conserva. Source: https://jmrplens.github.io/ghchronicle/es/install/actions/ El repositorio incluye una Action compuesta, así que un workflow no necesita cadena de herramientas de Go: descarga un binario de release y lo llama. ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: once config: .github/ghchronicle.yaml ``` ## Entradas | Entrada | Por omisión | Qué hace | | ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `token` | obligatoria | Un token personal. El `GITHUB_TOKEN` automático no basta | | `config` | `""` | Ruta a un fichero de configuración. Omítela para usar valores por defecto a partir de `user` | | `user` | el dueño del repositorio | La cuenta que recoger cuando no se da fichero de configuración | | `mode` | `once` | `once`, `backfill` o `card` | | `backfill-since` | `""` | Límite para `backfill`: una fecha, `90d`, `2y` o una duración de Go. Vacío es sin límite | | `card` | `""` | Ruta del SVG que escribir. Vacío significa sin tarjeta | | `card-layout` | `summary` | Uno de los trece diseños registrados | | `card-theme` | `auto` | `dark`, `light`, `auto`, o `both` para una tarjeta clara y su gemela `_dark` | | `card-fields` | `""` | Campos separados por comas. Vacío es el conjunto por defecto del diseño | | `card-motion` | `once` | `once`, `loop` u `off`; `loop` solo cambia `terminal` y `ticker` | | `card-width` | `""` | Ancho de la tarjeta en píxeles. Vacío dibuja el diseño con su propio ancho; cada uno dibuja entre dos extremos propios, que dice su [sección](/ghchronicle/es/card/layouts/). Solo `activity-heatmap` se gasta el sitio en datos, un año entero del calendario en su extremo lejano | | `card-speed` | `""` | A qué velocidad se reproduce un diseño animado, como decimal de 0 a 1. Vacío significa 0.5, el ritmo con el que siempre se han dibujado las tarjetas; por debajo la tarjeta va más lenta y por encima más rápida, y todos los diseños animados se escalan juntos. 0 es la animación más lenta y no una tarjeta quieta, eso es `card-motion: off` | | `include-private` | `false` | `true` cuenta los repositorios privados cuando no se da fichero de configuración (el comportamiento por defecto hasta v1.0.0). Ver el aviso más abajo | | `version` | `latest` | La release que instalar | ## Los tres modos - **once** Una pasada contra un fichero de configuración que tú das, que es como un workflow alimenta una base de datos. ```yaml name: Collect on: schedule: - cron: "*/30 * * * *" workflow_dispatch: jobs: collect: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: once config: .github/ghchronicle.yaml env: INFLUX_TOKEN: ${{ secrets.INFLUX_TOKEN }} ``` El fichero de configuración referencia las credenciales de la base de datos como `${VAR}`, así que entran como secretos y nunca al repositorio. - **card** Una pasada y un SVG, y nada escrito en ninguna base de datos. Este es el único modo que no necesita almacén alguno configurado. ```yaml name: Profile card on: schedule: - cron: "17 6 * * *" workflow_dispatch: permissions: contents: write jobs: card: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/github-stats.svg card-layout: github-stats card-theme: both - name: Commit if it changed run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add generated/ git diff --staged --quiet || git commit -m "Update the profile card" git push ``` El SVG es idéntico byte a byte para la misma entrada, así que un día sin cambios no produce ningún commit. - **backfill** Llega tan atrás como GitHub permita y espera al límite de peticiones en vez de detenerse. Ejecútalo una vez, a mano. ```yaml name: Backfill on: workflow_dispatch: inputs: since: description: "A date, 90d, 2y, or empty for no bound" default: "2y" jobs: backfill: runs-on: ubuntu-latest timeout-minutes: 360 steps: - uses: actions/checkout@v7 - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: backfill backfill-since: ${{ inputs.since }} config: .github/ghchronicle.yaml ``` Dale un `timeout-minutes` generoso: un relleno sin límite aparca en cada reinicio de la ventana, y un job que muere a medias ha gastado la cuota y se ha quedado con parte del beneficio. ## Una tarjeta en el README de tu perfil GitHub muestra en lo alto de tu perfil el README del repositorio que se llama como tu cuenta, `/`. La tarjeta vive en ese repositorio como un fichero, el README apunta a ella una vez y un workflow programado sustituye el fichero. El README no se reescribe nunca. 1. Crea un token personal (ver [el token](/ghchronicle/es/start/token/)) y guárdalo en `/` como el secreto `GHCHRONICLE_TOKEN`. El `GITHUB_TOKEN` automático no sirve, ni siquiera para números públicos: no es un usuario, así que la Action no puede listar tus repositorios con él y la tarjeta sale vacía. 2. Añade `.github/workflows/card.yml`: ```yaml name: Profile card on: schedule: - cron: "17 6 * * *" workflow_dispatch: permissions: contents: write # One run at a time: two that overlap would race each other to push. concurrency: group: profile-card cancel-in-progress: false jobs: card: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: animated-counters card-theme: both card-motion: once - name: Commit if it changed run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add generated/ git diff --staged --quiet || git commit -m "Update the profile card" git push ``` 3. Pega esta línea en `README.md` donde deba aparecer la tarjeta, una sola vez: ```html Mis estadísticas de GitHub ``` 4. Ejecuta el workflow a mano desde la pestaña Actions la primera vez, para que los ficheros existan antes de la primera ejecución programada. **Más de una tarjeta.** Repite el paso `uses: jmrplens/ghchronicle@v1` con otra ruta en `card` y otro diseño, y pega un `` por tarjeta. Cada paso es una pasada propia, y cada una vuelve a pedir todos los números: una pasada que escribe una tarjeta recoge todas las familias diga lo que diga su cadencia, y una en modo `card` no escribe nada en el fichero de estado. Así que varios pasos de tarjeta pueden compartir un `config`, y por tanto un `state_file`, y cada tarjeta es la cuenta entera. Eso es también lo que cuesta cada una: una tarjeta es una pasada en frío de todas las familias, así que N tarjetas son N pasadas [al precio por familia](/ghchronicle/es/api/cost/). **Un README que no está en la raíz.** Esto es para otros repositorios, porque GitHub muestra el README de un perfil solo desde la raíz de `/`. Las rutas del `` son relativas al README, así que un `docs/README.md` apunta a `../generated/card.svg`. > **Repositorios privados** > > Sin fichero de configuración, las estrellas, los forks, los lenguajes, el > tráfico y los nombres de repositorio salen solo de los repositorios públicos, > mientras que las cifras de contribuciones, commits y pull requests son totales > de la cuenta, tal como GitHub los muestra en tu perfil. Hasta v1.0.0, la > configuración por defecto de la Action contaba los repositorios privados. Con > `include-private: true` la tarjeta suma las estrellas, forks, lenguajes y > tráfico de tus repositorios privados, y los diseños que listan repositorios > **publican sus nombres**: `repo-list` y `summary` (el diseño por defecto de la > Action) los listan de entrada, y también cualquier diseño al que se le pida > `top_repos` en `card-fields`. Para contarlos sin nombrarlos, elige tú los campos y > deja fuera `top_repos`, por ejemplo > `card-fields: stars,forks,followers,contributions`. ## Dos cosas que un runner alojado no conserva > **El token tiene que ser un token personal** > > El tráfico necesita acceso de escritura a *cada* repositorio que se recoja, y > el `GITHUB_TOKEN` automático solo lo tiene para el repositorio en el que corre > el workflow. Tampoco tiene `security_events` ni `read:packages`, y no es un > usuario, así que nada de ámbito de cuenta funciona con él. Ver [el > token](/ghchronicle/es/start/token/). **El fichero de estado no sobrevive entre ejecuciones.** Cada ejecución vuelve por tanto a recorrer entera la lista de estrellas. En una cuenta pequeña son un puñado de llamadas; en una cuenta con muchas estrellas, guárdalo en caché: ```yaml - uses: actions/cache@v4 with: path: ~/.ghchronicle key: ghchronicle-state-${{ github.run_id }} restore-keys: ghchronicle-state- ``` y apunta `state_file` a `~/.ghchronicle/state.json` en la configuración. Una pasada en modo `card` lee el estado restaurado, que es lo que le permite saltarse el recorrido, y nunca lo vuelve a escribir: entrega sus puntos a la tarjeta y a ningún almacén, así que nada de lo que aprendió puede decirle a la siguiente recogida que una familia ya está hecha. Quien llena la caché es un paso `once` o `backfill`. ## Sin la Action Lo mismo a mano, si prefieres no depender de ella: ```yaml - uses: actions/setup-go@v7 with: go-version: stable - run: go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest - run: ghchronicle -config .github/ghchronicle.yaml -card profile.svg -card-only env: GITHUB_TOKEN: ${{ secrets.GHCHRONICLE_TOKEN }} ``` --- # El fichero Un fichero YAML, todos los valores expandibles desde el entorno, y para qué sirve cada bloque de primer nivel. Source: https://jmrplens.github.io/ghchronicle/es/configuration/ ```sh cp config.example.yaml config.yaml ``` `config.example.yaml` documenta cada opción en comentarios. Es el fichero que hay que copiar, y se mantiene al día con el código: añadir un ajuste implica añadirlo también allí. ## Los bloques ```yaml github: # el token, la reserva, el timeout targets: # qué repositorios groups: # qué categorías de métrica llegan a recogerse sinks: # a dónde van los puntos every: # cada cuánto corre todo: un valor por defecto, por grupo, por familia heartbeat: # cada cuánto despierta el bucle, para una prueba log: # nivel, formato y un fichero rotatorio opcional state_file: # lo que recuerda un reinicio backfill: # el límite, al ejecutar con -backfill ``` Solo `github.token` y uno de `targets.user`, `targets.orgs` o `targets.repos` son verdaderamente obligatorios, más al menos un destino. Todo lo demás tiene valor por defecto. ## Expansión de `${VAR}` Todo valor de tipo cadena se expande desde el entorno al arrancar. `${GITHUB_TOKEN}` se convierte en el valor de esa variable, o en una cadena vacía si no está definida. ```yaml github: token: ${GITHUB_TOKEN} sinks: influxdb: url: http://localhost:8181 token: ${INFLUX_TOKEN} ``` Esta es toda la razón de que el fichero se pueda versionar. La configuración es la forma del despliegue y le corresponde estar en control de versiones; los tokens son credenciales y les corresponde un fichero de entorno con modo 600, o un almacén de secretos. - /etc/ghchronicle/ - config.yaml legible por todos, versionado - ghchronicle.env modo 600, nunca versionado ## `github` ```yaml github: token: ${GITHUB_TOKEN} reserve_rate: 500 timeout: 30s # base_url: https://github.example.com/api/v3 # web_url: https://github.example.com ``` | Clave | Por omisión | Significado | | -------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `token` | obligatoria | Un token personal clásico o fine-grained | | `reserve_rate` | `500` | Llamadas que no se gastan nunca, para que lo demás que use el token siga funcionando. Un valor no positivo cae al valor por defecto | | `timeout` | `30s` | Por petición. GraphQL sobre una cuenta grande puede ser lento. Una duración de Go; lo que no se pueda interpretar o no sea positivo cae al valor por defecto | | `base_url` | api.github.com | Una instancia de GitHub Enterprise usa `https:///api/v3` | | `web_url` | derivada de la API | El sitio donde está la página de perfil, que `achievements` lee sin el token. Solo hace falta cuando `base_url` es un proxy delante de la API | La reserva se escala por cubo; ver [límites de la API](/ghchronicle/es/api/). ## `state_file` ```yaml state_file: /var/lib/ghchronicle/state.json ``` Seis cosas, y borrar el fichero cuesta una distinta por cada una: - `last_run`, cuándo corrió cada familia por última vez. Sin él todas las familias vencen a la vez, así que la pasada siguiente es completa. - `first_saw`, cuándo se vio cada repositorio por primera vez. Sin él el recorrido completo de la historia de estrellas se vuelve a hacer. - `last_head`, el commit en que estaba cada repositorio cuando corrió el diff de dependencias. Sin él los cambios de dependencias del hueco se pierden: la pasada siguiente tiene la fotografía y no el diff. - `last_full`, cuándo leyó una página entera cada familia que normalmente lee solo lo que ha cambiado. Sin él una familia se lee como vencida, así que la pasada siguiente las lee todas enteras. - `last_notified`, por dónde se cortó la ventana del buzón. Sin él a cero pide el buzón entero. - `last_event`, el evento más reciente que tenía el feed. Sin él vacío lee el feed entero. Cinco de las seis cuestan solo cuota, porque lo que se vuelve a recoger se indexa por medida, etiquetas y marca de tiempo y sobrescribe lo que ya está guardado. `last_head` es la que pierde algo: los cambios de dependencias entre la cabeza que tenía y la siguiente se leen de un rango que nadie puede nombrar una vez que la cabeza ha desaparecido. Una pasada con `-card-only` no escribe ninguna de las seis. Sus puntos llegan a [la tarjeta](/ghchronicle/es/card/) y a ningún almacén, así que una marca suya haría que la siguiente recogida se saltara una familia, o estrechara una lectura, cuyos datos fueron a parar a una imagen y a ningún otro sitio. El fichero lo lee como cualquier otra pasada. ### El otro fichero en el que una pasada se recuerda a sí misma ```yaml sinks: dedupe_file: /var/lib/ghchronicle/state-written.bin dedupe_horizon: 720h ``` | Clave | Por omisión | Significado | | ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- | | `sinks.dedupe_file` | junto a `state_file`, como `-written.bin` | El registro de lo que ya se ha escrito. `off` lo desactiva para todos los destinos | | `sinks.dedupe_horizon` | `720h` | Cuánto recuerda el registro un punto que ya nadie ofrece. Una duración de Go; lo que no se pueda interpretar o no sea positivo cae al valor por defecto | Perderlo cuesta una pasada de reescritura y nada más, que es justo lo que quiere un almacén que se ha vaciado y hay que volver a llenar. Los dos ficheros quieren una ruta persistente: [solo se escribe lo que ha cambiado](/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). ## `backfill` ```yaml backfill: since: 2y ``` Solo se aplica a una ejecución lanzada con `-backfill`, y `-backfill-since` lo sobrescribe. Ver [relleno histórico](/ghchronicle/es/how/backfill/). ## Cuando está mal, lo dice al arrancar La configuración se valida antes de hacer la primera llamada, y los mensajes nombran la clave y lo que necesita. | Mensaje | Significa | | --------------------------------------------------------------------- | ------------------------------------------------------------------ | | `github.token is empty and GITHUB_TOKEN is unset` | Exactamente lo que dice | | `targets: set at least one of user, orgs or repos` | No hay nada que recoger | | `sinks: enable at least one of ...` | Una ejecución que recoge y tira casi nunca es lo que alguien quiso | | `every.families.: unknown collector` | El nombre no es una familia. El mensaje lista las que existen | | `sinks.influxdb: url and bucket are required` | Cada destino valida sus propias claves obligatorias y dice cuáles | | `sinks.sql.dialect: "mysql" is not postgres, the only dialect so far` | El valor no es uno de los aceptados, y el mensaje los lista | > **Una cadencia para una familia inexistente es fatal** > > `every` se comprueba contra la lista de colectores conocidos al arrancar en > vez de ignorarse. De lo contrario, una errata ahí significaría una familia > corriendo en silencio con su valor por defecto para siempre, algo que ya ha > mordido dos veces. ## El resto - [Objetivos](/ghchronicle/es/configuration/targets/): qué repositorios, y los valores por defecto de forks y archivados. - [Cadencias](/ghchronicle/es/configuration/cadences/): el bloque `every`, y qué significa `0`. - [Registro](/ghchronicle/es/configuration/logging/): nivel, formato y el fichero rotatorio. - [Elegir almacén](/ghchronicle/es/sinks/): el bloque `sinks`, una página por almacén. --- # Generar la configuración Un formulario que escribe el config.yaml de una ejecución normal y el paso de workflow de la Action, a partir de la lista de ajustes del propio binario. Source: https://jmrplens.github.io/ghchronicle/es/configuration/builder/ Responde cómo es tu despliegue y esto escribe las tres cosas que lo ponen en marcha: el `config.yaml` que lee una ejecución normal, el comando que lo ejecuta y el paso que un workflow le da a la Action compuesta. Las tres salen de un mismo conjunto de respuestas, y ninguna pide una credencial. ## Por qué no puede desviarse Los controles no son una copia de los ajustes. `cmd/gen_config` exporta los propios tipos de `internal/config` a un fichero que la página lee, incluido el valor por omisión al que se resuelve cada clave, que mide validando una sonda en vez de repetir un número. `make check-config-options` falla cuando ese fichero deja de ser lo que produce el código, en la suite de análisis y en CI, así que el formulario no puede ofrecer un ajuste que el binario no tiene ni perderse uno que sí tiene. La dirección contraria también se comprueba, y su límite conviene decirlo en voz alta. Las configuraciones que este formulario escribe para los conjuntos de respuestas de `internal/config/testdata/config-cases.json` pasan por el parser real en las propias pruebas de `internal/config`, incluidas las formas que han hecho tropezar a alguien: una cadencia por familia bajo `every.families`, un `include_private` desactivado, un destino cuyas credenciales son referencias `${VAR}`, una ejecución de solo tarjeta sin ningún destino y respuestas que se espera que el parser rechace. Esos casos los genera el módulo que ejecuta esta página, así que no pueden ser una copia suya, y cada rama de ese módulo que puede cambiar lo que escribe tiene uno. Lo que el corpus no demuestra es el comportamiento que ningún conjunto de respuestas produce, y por eso el fichero que lo define enumera lo que no puede cubrir y por qué. ## Qué permite el formulario y rechaza el parser Un formulario es un formulario: un destino es una casilla y las claves sin las que no se resuelve no lo son, así que unos pocos clics bastan para escribir un fichero que muere al arrancar. La página avisa de aquellas que su propia lista generada de ajustes puede ver, encima de las salidas, y nombra el resto aquí. Rechazar el resto en el formulario obligaría a guardar en esta página una segunda copia de una regla del parser, que es justo la desviación que todo este montaje existe para evitar. - Un destino activado con una clave obligatoria vacía se rechaza con nombre y ejemplo: `sinks.loki: url is required, for example http://loki:3100`. De esta avisa el formulario. - Un campo de credencial respondido con la credencial en vez de con el nombre de una variable de entorno se queda fuera del fichero. De esta avisa el formulario. - Un fichero sin nada dentro se rechaza: `the file is empty`. - No indicar ninguna cuenta se rechaza: `targets: set at least one of user, orgs or repos`. - `heartbeat: 0` se rechaza: tiene que ser positivo, y dejar la clave fuera es la forma de que el tic del bucle se derive solo. - Una cadencia que no es una duración se rechaza donde se usa: `every.families.repo: time: invalid duration "soon"`. - `sinks.elasticsearch.api_key` rellenado a la vez que `sinks.elasticsearch.username` se rechaza: `sinks.elasticsearch: set either api_key or username and password, not both`. - Escribir `-` en `sinks.sql.path` con `sinks.stdout` activado se acepta y deja dos escritores sobre el mismo flujo; elige uno. > **Qué hace el formulario con una credencial** > > Donde un ajuste es una credencial, el formulario pide el NOMBRE de una > variable de entorno y escribe `${NOMBRE}` en el fichero. Esa es la expansión > que hace el binario al arrancar, y es lo que permite publicar el fichero. Una > respuesta que no es un nombre de variable no se escribe: el formulario lo dice > y deja la clave fuera, en vez de convertir un token pegado en una referencia a > una variable que no va a existir nunca. Hay un campo que eso no cubre, > `sinks.otlp.headers`, que es texto libre porque una cabecera no siempre es una > credencial: lo que escribas ahí se escribe tal cual, así que pon una > referencia `${VAR}`. El token de la Action es un secreto del repositorio, que > es lo que referencia el paso. ## El formulario Esta página es un formulario que escribe una configuración. Lo que sigue es el inventario que ofrece, generado a partir de los propios tipos del binario, y las tres salidas de las que parte. - **La cuenta y su API** - `github`: un bloque de ajustes - `github.token`: string, obligatorio, una credencial, como `${GITHUB_TOKEN}` - `github.base_url`: string, como `https://github.example.com/api/v3` - `github.web_url`: string, como `https://github.example.com` - `github.timeout`: string, por omisión `30s`, como `30s` - `github.reserve_rate`: int, por omisión `500`, como `500` - **Qué recoger** - `targets`: un bloque de ajustes - `targets.user`: string, como `your-github-login` - `targets.orgs`: list, como `some-org, another-org` - `targets.repos`: list, como `someone/one-repo` - `targets.exclude`: list, como `someone/experiment-*` - `targets.include_forks`: bool, por omisión `false`, como `false` - `targets.include_archived`: bool, por omisión `false`, como `false` - `targets.include_private`: bool, por omisión `true`, como `true` - **Dónde van los puntos** - `sinks`: un bloque de ajustes - `sinks.influxdb`: un bloque de ajustes - `sinks.influxdb.url`: string, obligatorio, como `http://localhost:8181` - `sinks.influxdb.token`: string, una credencial, como `${INFLUX_TOKEN}` - `sinks.influxdb.org`: string, por omisión `default`, como `default` - `sinks.influxdb.bucket`: string, obligatorio, como `github` - `sinks.influxdb.batch`: int, como `5000` - `sinks.influxdb.exclude`: list, por omisión `gh_job_log`, como `gh_job_log` - `sinks.influxdb.dedupe`: bool, por omisión `true`, como `true` - `sinks.prometheus`: un bloque de ajustes - `sinks.prometheus.listen`: string, por omisión `:9605`, como `127.0.0.1:9605` - `sinks.prometheus.path`: string, por omisión `/metrics`, como `/metrics` - `sinks.prometheus.no_prime`: bool, por omisión `false`, como `false` - `sinks.otlp`: un bloque de ajustes - `sinks.otlp.endpoint`: string, obligatorio, como `http://collector:4318/v1/metrics` - `sinks.otlp.headers`: map, una credencial, como `Authorization: Bearer ${OTLP_TOKEN}` - `sinks.otlp.service`: string, como `ghchronicle` - `sinks.otlp.raw`: bool, por omisión `false`, como `false` - `sinks.otlp.batch`: int, como `2000` - `sinks.otlp.repeat`: string, como `1m` - `sinks.loki`: un bloque de ajustes - `sinks.loki.url`: string, obligatorio, como `http://loki:3100/loki/api/v1/push` - `sinks.loki.tenant_id`: string, como `tenant-one` - `sinks.loki.labels`: map, como `job: ghchronicle` - `sinks.loki.batch`: int, como `1000` - `sinks.loki.max_age`: string, como `1h` - `sinks.file`: un bloque de ajustes - `sinks.file.path`: string, obligatorio, como `/var/log/ghchronicle/points.lp` - `sinks.file.format`: string, uno de `influx`, `json` - `sinks.file.max_bytes`: int, como `67108864` - `sinks.file.keep`: int, como `5` - `sinks.stdout`: bool, por omisión `false`, como `false` - `sinks.stdout_format`: string, uno de `influx`, `json` - `sinks.telegraf`: un bloque de ajustes - `sinks.telegraf.url`: string, obligatorio, como `http://telegraf:8186/telegraf` - `sinks.telegraf.username`: string, como `telegraf` - `sinks.telegraf.password`: string, una credencial, como `${TELEGRAF_PASSWORD}` - `sinks.telegraf.batch`: int, como `5000` - `sinks.telegraf.dedupe`: bool, por omisión `true`, como `true` - `sinks.graphite`: un bloque de ajustes - `sinks.graphite.addr`: string, obligatorio, como `graphite:2003` - `sinks.graphite.prefix`: string, por omisión `github`, como `github` - `sinks.graphite.batch`: int, como `1000` - `sinks.graphite.dedupe`: bool, por omisión `true`, como `true` - `sinks.sql`: un bloque de ajustes - `sinks.sql.dialect`: string, por omisión `postgres`, uno de `postgres` - `sinks.sql.path`: string, obligatorio, como `/var/lib/ghchronicle/points.sql` - `sinks.sql.max_bytes`: int, como `67108864` - `sinks.sql.keep`: int, como `5` - `sinks.sql.dedupe`: bool, por omisión `true`, como `true` - `sinks.elasticsearch`: un bloque de ajustes - `sinks.elasticsearch.url`: string, obligatorio, como `http://elasticsearch:9200` - `sinks.elasticsearch.prefix`: string, por omisión `ghchronicle`, como `ghchronicle` - `sinks.elasticsearch.username`: string, como `elastic` - `sinks.elasticsearch.password`: string, una credencial, como `${ES_PASSWORD}` - `sinks.elasticsearch.api_key`: string, una credencial, como `${ES_API_KEY}` - `sinks.elasticsearch.batch`: int, como `1000` - `sinks.elasticsearch.dedupe`: bool, por omisión `true`, como `true` - `sinks.dedupe_file`: string, por omisión `ghchronicle-state-written.bin`, como `/var/lib/ghchronicle/state-written.bin` - `sinks.dedupe_horizon`: string, por omisión `720h`, como `720h` - **Cada cuánto** - `every`: un bloque de ajustes - `every.default`: string, como `15m` - `every.groups`: map, como `ci: 1m` - `every.families`: map, como `deps: 24h` - **El registro de la ejecución** - `log`: un bloque de ajustes - `log.level`: string, por omisión `info`, uno de `debug`, `info`, `warn`, `error` - `log.format`: string, por omisión `text`, uno de `text`, `json` - `log.file`: string, como `/var/log/ghchronicle/ghchronicle.log` - `log.max_bytes`: int, como `67108864` - `log.keep`: int, como `5` - **La ejecución** - `heartbeat`: string, como `15s` - `groups`: list, como `audience, account, repos` - `state_file`: string, por omisión `ghchronicle-state.json`, como `/var/lib/ghchronicle/state.json` - `backfill`: un bloque de ajustes - `backfill.since`: string, como `2y` - **config.yaml** Guárdalo junto al binario, o en la ruta que pases a -config, y ejecútalo con el comando de abajo. ```yaml github: token: ${GITHUB_TOKEN} targets: user: octocat sinks: stdout: true ``` - **El comando que lo ejecuta** ```sh ghchronicle -config config.yaml -once ``` - **Paso del workflow** Estas respuestas necesitan ajustes que ningún input transporta, así que el paso lee el fichero de arriba. Publícalo en esa ruta. ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} config: .github/ghchronicle.yaml ``` ## Qué hacer con cada salida No hay un único comando, y por eso el formulario lo escribe. Una configuración que nombra un destino se ejecuta con `-once`. Una que no nombra ninguno la rechaza `-once`, con el parser pidiendo un destino que no querías, así que se ejecuta con `-card-only` y una ruta para la tarjeta. El comando de arriba cambia con las respuestas, y el paso también. El paso va en los `steps:` de un job de workflow. Las respuestas que ningún input transporta hacen que el paso lea el fichero, y el fichero hay que publicarlo en la ruta que nombra el paso; las respuestas que los cuatro inputs sí transportan se escriben en el propio paso, y entonces no hace falta fichero ninguno. Un paso sin fichero que no nombra destino es una ejecución de tarjeta, `mode: card` con una ruta en `card:`, porque eso es lo único que la Action convierte en `-card-only`. Las dos salidas tienen valores por omisión opuestos en un ajuste, y el paso se escribe de forma que diga cuál te toca. `include-private` está desactivado salvo que lo pidas, porque una tarjeta que cuenta repositorios privados publica sus nombres en un README público; `targets.include_private` está activado salvo que digas lo contrario, porque el token ya llega a ellos. Por eso un paso sin fichero siempre escribe el input en vez de dejarlo en un valor por omisión que significa lo contrario que el fichero de al lado. Mira [GitHub Actions](/ghchronicle/es/install/actions/) para el workflow que lo rodea, y [el token](/ghchronicle/es/start/token/) para lo que el secreto tiene que poder hacer. ## Lo demás - [El fichero](/ghchronicle/es/configuration/): para qué sirve cada bloque y qué pasa cuando un valor está mal. - [Objetivos](/ghchronicle/es/configuration/targets/): qué repositorios. - [Cadencias](/ghchronicle/es/configuration/cadences/): cada familia, su grupo y su intervalo de fábrica. - [Elegir almacén](/ghchronicle/es/sinks/): una página por destino. --- # Objetivos Qué repositorios toca una pasada, y por qué los forks y los archivados quedan fuera por omisión. Source: https://jmrplens.github.io/ghchronicle/es/configuration/targets/ ```yaml targets: user: your-github-login orgs: [] repos: [] exclude: [] include_forks: false include_archived: false include_private: true ``` ## Las claves | Clave | Por omisión | Qué hace | | ------------------ | ----------- | ---------------------------------------------------------- | | `user` | ninguno | La cuenta que recoger. Sus repositorios se descubren solos | | `orgs` | `[]` | Organizaciones que incluir además | | `repos` | `[]` | Repositorios que recoger digan lo que digan los filtros | | `exclude` | `[]` | Globs de shell, comparados contra `owner/name` | | `include_forks` | `false` | Si se recogen los forks | | `include_archived` | `false` | Si se recogen los repositorios archivados | | `include_private` | `true` | Si se recogen los repositorios privados | Hay que poner al menos uno de `user`, `orgs` o `repos`, o el arranque falla con `targets: set at least one of user, orgs or repos`. `user` es además lo que hace posibles las familias de cuenta. Una configuración que solo nombra repositorios no tiene login que darle a GraphQL, así que el calendario de contribuciones, el feed de eventos, las notificaciones, la facturación, los paquetes y las familias salientes se saltan todas. ## Nombrar un repositorio anula toda exclusión `repos` no es otro filtro. Es una declaración de intenciones, y gana: nombrar `someone/thing` lo recoge aunque sea un fork, aunque esté archivado y aunque un glob de `exclude` lo hubiera capturado. ```yaml targets: user: acme repos: - someone-else/a-fork-i-actually-maintain exclude: - "acme/experiment-*" ``` `exclude` acepta globs de shell, así que `someone/experiment-*` descarta un prefijo entero en una línea. ## Por qué forks y archivados están apagados por omisión Ambos valores por defecto existen por la misma razón, que es el límite de peticiones. - **El tráfico de un fork es casi siempre cero.** GitHub informa de visitas y clones por repositorio, y para un fork que nadie visita eso son catorce días de ceros por pasada. Cuesta cuatro llamadas por repositorio para no aprender nada. - **Un repositorio archivado no puede cambiar.** Sus estrellas todavía pueden moverse, pero nada más, y recogerlo gasta el presupuesto en filas que ya no se mueven. Actívalos cuando la suposición no valga en tu caso. Un fork en el que de verdad desarrollas es un repositorio real con tráfico real, y `repos` es la forma de nombrarlo sin recoger también los cuarenta marcadores. Apagado no es invisible, por dos vías. Un repositorio archivado sigue teniendo la única fila que le corresponde, `gh_repo_archived`, fechada en el instante en que se archivó: el listado que una pasada ya paga dice qué repositorios están archivados, y la familia `totals` pregunta la fecha de todos en una consulta a su propia cadencia, así que la tabla _Repositories archived_ existe desde la primera pasada y cuesta un punto por pasada de `totals` después. Y un [relleno histórico](/ghchronicle/es/how/backfill/) recoge los repositorios archivados enteros diga lo que diga esta clave, porque su historia es la historia de la cuenta y basta con recorrerla una vez. Los forks quedan como estén configurados en ambos casos: un fork archivado con `include_forks: false` no tiene fila. > **Comprueba el conjunto antes de gastar cuota en él** > > ```sh > ghchronicle -config config.yaml -list > ``` > > Eso imprime los repositorios en alcance y no escribe nada. Si falta algo que > esperabas, esta es la orden que te lo dice antes de que una pasada gaste cuota > en el conjunto equivocado. ## Repositorios privados `include_private` vale `true` por omisión, porque un token que puede verlos se concedió a propósito y el objetivo de la herramienta es guardar la historia de la cuenta, no de su mitad pública. Lo que se guarda son las mismas medidas que para un repositorio público: recuentos, duraciones y nombres. Conviene saber dos cosas sobre lo que eso implica: - Los nombres de repositorio y de rama aparecen como valores de etiqueta, así que serán visibles para cualquiera que pueda leer el dashboard. - Solo se guarda el _host_ de una URL de webhook, nunca la ruta, porque la ruta suele llevar un secreto. Ponlo a `false` para recoger solo repositorios públicos. ## La cadencia del descubrimiento La lista de repositorios se rehace como mucho una vez por hora. Los repositorios se crean pocas veces y listarlos cuesta una página por cada cien, así que un repositorio nuevo puede tardar hasta una hora en entrar en la pasada. Nombrarlo en `repos` no cambia eso; reiniciar el proceso sí. --- # Cadencias Las tres capas del bloque every, la cadencia interna de cada familia, el aviso cuando una configuración la acelera, y el heartbeat. Source: https://jmrplens.github.io/ghchronicle/es/configuration/cadences/ ```yaml every: default: 15m groups: ci: 1m feeds: 10m families: actions: 30s ``` ## Las tres capas Gana la más específica. La entrada de una familia gana a la de su grupo, la de un grupo gana a `default`, y `default` gana al valor interno. Lo que se omite cae a la capa de abajo, así que un fichero que solo tenga `families:` se comporta exactamente como siempre. El valor es una duración de Go: `30s`, `15m`, `2h`, `36h`. | Capa | Alcanza | Se escribe | | ------------------ | ----------------------------------------- | ----------------------- | | `every.families` | una familia | `families: {keys: 24h}` | | `every.groups` | todas las familias de un grupo | `groups: {ci: 1m}` | | `every.default` | todas las que no nombren las dos de arriba | `default: 15m` | | la tabla interna | todas las que no nombre ninguna capa | nada | Son tres claves de un bloque anidado y no tres claves planas porque un mapa plano no puede con este vocabulario. `security` y `account` son a la vez nombre de familia **y** nombre de grupo, así que `every: {security: 1m}` tiene dos lecturas y ninguna forma de elegir entre ellas. Bajo `families:` la palabra es la familia; bajo `groups:` es el grupo; ya no queda sitio donde plantear la duda. Ese mismo anidamiento es lo que hace seguras las palabras `default` y `groups`: son campos de una estructura fija, y una clave desconocida se rechaza al arrancar, así que ninguna familia puede tapar una capa ni ninguna capa una familia. > **Las dos capas amplias nunca encienden una familia** > > `default` y `groups` no alcanzan a una familia cuya cadencia interna es `0`. > `deps`, `history` y `joblogs` vienen apagadas y se activan nombrándolas bajo > `families:` y de ninguna otra forma. Solo `deps` son 1,8 MB de SBOM por > repositorio, y un `default` escrito para acelerar las familias rápidas no debe > encender además tres familias que nunca mencionaste. Es la misma regla que ya > sigue `groups`. Del revés sale la forma más corta de recoger poco: apagarlo todo con `default` y luego nombrar lo que quieres de vuelta. ```yaml every: default: 0 families: traffic: 6h actions: 15m ``` ## Cada familia, su grupo y su cadencia interna Los valores internos no son números redondos elegidos por prolijidad. Salieron de una auditoría con sus costes, y el motivo de cada uno vive junto al número en el código. Esa es la fuente, y esta tabla está anclada a ella: un test de `internal/config` compara cada grupo, cada duración y cada motivo de aquí abajo con el código en ambos sentidos, así que una familia que se añada, se mueva o cambie de coste sin que esta tabla la siga rompe la compilación. `ghchronicle -groups` imprime esa misma pertenencia. | Grupo | Familia | Por omisión | Motivo | | ----------- | ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | `account` | `12h` | el calendario de contribuciones cambia una vez al día, y la familia entera cuesta un punto de GraphQL | | `account` | `achievements` | `24h` | las insignias de la página pública del perfil, leídas de la propia página porque ninguna API las lista, y al lado la distancia de cada insignia a su siguiente nivel, sacada de la API; una insignia se gana en semanas y el día cuesta una página y unos treinta puntos de GraphQL | | `account` | `billing` | `6h` | GitHub actualiza el informe de uso unas pocas veces al día como mucho | | `account` | `history` | `0` | apagada hasta que se la nombre: recorre cada año pasado y lo que va de este, y las filas son idempotentes | | `account` | `keys` | `24h` | una clave SSH o GPG cambia cuando alguien la cambia, y lo que importa es su fecha de caducidad, no la hora en que se vio | | `account` | `outbound` | `12h` | las estrellas dadas y el trabajo en repositorios ajenos van a la velocidad de una persona | | `account` | `profile` | `12h` | paquetes, gists y cuentas sociales, todo ello editado a mano | | `account` | `totals` | `12h` | dos veces al día sobra para un número que solo crece | | `audience` | `forks` | `12h` | la lista entera cabe en una página, y un fork es un suceso raro | | `audience` | `stars` | `6h` | el recorrido completo ocurre una vez; después las cien estrellas más nuevas viajan en una consulta GraphQL por cada diez repositorios | | `audience` | `traffic` | `6h` | la ventana de catorce días se reescribe entera cada vez, así que una pasada perdida se repara en la siguiente | | `ci` | `actions` | `15m` | una ejecución de workflow termina en minutos, y su tiempo en cola solo merece verse mientras ocurre | | `ci` | `artifacts` | `1h` | los artefactos aparecen con la ejecución que los hizo y caducan en escala de días | | `ci` | `deployments` | `1h` | es la superficie que lee un dashboard de entrega, y la página más nueva es barata: un punto de GraphQL por cada cinco repositorios | | `ci` | `joblogs` | `0` | apagada hasta que se la nombre: es texto y no una medida, cuesta una petición por fallo, y solo tiene sentido con un almacén de logs conectado | | `collector` | `ratelimit` | `15m` | es gratis, y merece la pena tenerla a la resolución de la familia más rápida | | `feeds` | `activity` | `30m` | el log del repositorio guarda cien entradas, que cubrieron veintiséis horas en el repositorio más activo medido | | `feeds` | `events` | `30m` | el feed guarda los últimos trescientos eventos sean cuales sean sus fechas, así que esto es el tamaño de una ventana, no una velocidad | | `feeds` | `notifs` | `30m` | las notificaciones leídas desaparecen rápido, así que esto es el tamaño de una ventana, no una velocidad | | `repos` | `branches` | `24h` | las ramas se crean y se borran todo el día, pero lo que responde la fila es cuáles están rancias ahora mismo, que es una pregunta diaria | | `repos` | `deps` | `0` | apagada hasta que se la nombre: el SBOM son 1,8 MB por repositorio y tiene su propio presupuesto de cien por minuto | | `repos` | `inventory` | `24h` | cuatro peticiones core por repositorio, para ajustes que cambian solo cuando alguien los cambia | | `repos` | `policyfiles` | `24h` | SECURITY.md, CODEOWNERS, dependabot.yml y FUNDING.yml se mueven una vez por trimestre | | `repos` | `repo` | `1h` | estrellas, forks, lenguajes y temas se mueven despacio, y esto es una petición por repositorio | | `repos` | `rulesets` | `24h` | un ruleset se edita unas pocas veces al año, cada versión conserva su propia fecha, y las dos peticiones responden 304 hasta que alguien edita uno | | `repos` | `settings` | `6h` | webhooks, rulesets, entornos y claves de despliegue cambian solo cuando alguien los cambia | | `security` | `analyses` | `6h` | GitHub poda los análisis de code scanning, y un repositorio produce un puñado al día | | `security` | `security` | `1h` | una alerta es algo sobre lo que actuar hoy, y la lista de abiertas es corta | | `work` | `commits` | `1h` | una petición por commit, así que el coste sigue a cuánto se ha subido y no a cada cuánto se pregunta | | `work` | `discussions` | `2h` | una discusión se responde en horas o días, y pocos repositorios tienen alguna | | `work` | `issueevents` | `1h` | la cronología de lo que se movió en dos cadencias, un punto GraphQL por repositorio, así que la hora es lo pronto que merece verse una transición | | `work` | `issues` | `1h` | una petición por elemento, así que el coste sigue a cuánto hay abierto y no a cada cuánto se pregunta | | `work` | `planning` | `6h` | etiquetas e hitos se editan a mano, unas pocas veces por semana como mucho | | `work` | `stats` | `12h` | GitHub las recalcula despacio de todas formas, así que preguntar más a menudo devuelve los mismos números | ## El aviso cuando una cadencia va más rápido de lo que el valor merece Una capa amplia es una forma barata de ralentizarlo todo y una forma cara de acelerarlo todo. `default: 15m` le pide a GitHub las claves SSH de la cuenta noventa y seis veces al día para un valor que cambia dos veces al año, y una cadencia de grupo hace lo mismo un nivel más abajo: `work` contiene familias que la auditoría midió en `1h` y en `12h`, así que un número ahí es un número sobre seis respuestas distintas. Por eso cada familia que una configuración recoge **cuatro veces más a menudo o más** que su cadencia interna se nombra al arrancar, con la clave que la fijó, los dos números, y el motivo de que ese número sea el que es: ```text level=WARN msg="every.default sets keys to 15m against a built-in 24h, 96 times more often: an SSH or GPG key changes when somebody changes it, and what matters is its expiry date, not the hour it was noticed" level=WARN msg="every.groups.work sets stats to 1h against a built-in 12h, 12 times more often: GitHub recomputes these slowly anyway, so asking more often returns the same numbers" ``` Es un aviso y nunca un rechazo, y es siempre por familia, nunca por grupo. Una línea que dijese "el grupo `work` va demasiado rápido" no nombraría nada sobre lo que actuar: el motivo de que una cadencia sea la que es pertenece a la familia, así que el aviso también. Cuatro es el umbral porque los valores internos son una escalera, `15m` `30m` `1h` `2h` `6h` `12h` `24h`, y el mayor salto entre dos peldaños vecinos es de tres, de `2h` a `6h`. Cuatro es por tanto el menor factor que ningún peldaño suelto alcanza. Bajar un peldaño es un ajuste deliberado de quien está mirando esa familia y se queda callado; cuatro o más solo puede ser una capa amplia cayendo donde nunca se la eligió, o un número escrito sin leer esta tabla. Nada avisa por ir más despacio. Esto va de desperdicio, no de gusto. ## Grupos: recoger menos que todo `every` fija cada cuánto corre una familia. `groups` fija qué familias existen de verdad para esta instalación. Si se omite la clave, recoge para todos los grupos, que es el valor por defecto y lo que "cada métrica que GitHub expone" ha significado siempre. ```yaml groups: [audience, account, repos, security] ``` Nombrar cualquier grupo apaga los demás: sus familias no se piden nunca y sus medidas no se escriben nunca. `ghchronicle -groups` imprime la lista, que es: | Grupo | Familias | | ----------- | ------------------------------------------------------------------------ | | `audience` | `forks`, `stars`, `traffic` | | `account` | `account`, `achievements`, `billing`, `history`, `keys`, `outbound`, `profile`, `totals` | | `repos` | `branches`, `deps`, `inventory`, `policyfiles`, `repo`, `rulesets`, `settings` | | `work` | `commits`, `discussions`, `issueevents`, `issues`, `planning`, `stats` | | `ci` | `actions`, `artifacts`, `deployments`, `joblogs` | | `security` | `analyses`, `security` | | `feeds` | `activity`, `events`, `notifs` | | `collector` | `ratelimit` | El `groups` de primer nivel y `every.groups` son dos preguntas distintas sobre los mismos ocho nombres: la primera decide si un grupo llega a recogerse, la segunda cada cuánto. Una cadencia bajo `every.groups` para un grupo que el `groups` de primer nivel deja fuera es legal y avisa, en lugar de fallar: estrechar una instalación no debería obligar además a podar un bloque `every` que ajustaste el año pasado. Los dos ejes tienen que decir que sí, y solo uno de ellos puede decir que no. Un grupo que no se nombra apaga sus familias diga lo que diga `every`, y nombrar un grupo nunca resucita una familia cuya cadencia es cero. En eso consiste que una familia venga apagada mientras el valor por defecto es todo. Escribir `groups: []` se rechaza en lugar de leerse como "no recojas nada": omitir la clave es la forma de pedirlo todo, así que una lista vacía solo puede ser un error. Hay tres superficies que no se pueden recuperar después, se haga lo que se haga, y están en tres grupos distintos: el feed de eventos y las notificaciones leídas (`feeds`), la ventana de catorce días del tráfico (`audience`) y los logs de los jobs, que se borran a los noventa días (`ci`). Un día no recogido de esas es un día que no existe. Todo lo demás se puede rellenar luego con `-backfill`. > **Los paneles de un grupo apagado darán error** > > Una medida que no se escribe nunca no existe como tabla, e InfluxDB 3 responde > con un error, y no con cero filas, a una consulta que nombra una tabla que no > tiene. Los dashboards se generan una vez, para el conjunto completo de > métricas, así que cada panel alimentado por un grupo apagado muestra ese > error. Es lo esperado. Son una demostración de lo que el colector sabe > dibujar, no una vista que se reorganice según tu configuración. PostgreSQL y > Elasticsearch se comportan igual; Prometheus y Graphite no tienen esquema que > echar en falta, así que esos mismos paneles leen No data. Las secciones de los dashboards tampoco están alineadas con los grupos, así que un grupo apagado vacía algunos paneles de varias secciones en lugar de una sección entera. `Stars and forks` lee `gh_repo` de `repos` además de `gh_star` de `audience`; `Code` lee `gh_workflow_run` de `ci` y `gh_repo_activity` de `feeds` junto a sus propios commits; `Inventory` lee de `repos` y de `account`, y sus paneles de licencias leen `gh_dependency_license`, que es la familia `deps` y viene apagada se seleccione `repos` o no. ## Qué significa `0` `0` apaga todas las familias que alcance la capa en la que está escrito. No es "tan a menudo como se pueda" ni "usa el valor por defecto": esas familias no corren nunca, no escriben nada y no cuestan nada. ```yaml every: families: billing: 0 # nada de datos de facturación artifacts: 0 # nada de filas de artefactos ``` Tres familias vienen apagadas y se activan dándoles cualquier duración, bajo `families:` y en ningún otro sitio: - **`joblogs`** es la cola del log de cada job fallido. Es texto y no una medida, cuesta una petición por fallo, y solo tiene sentido con un almacén de logs conectado. El destino de InfluxDB lo excluye por omisión. - **`history`** recorre el calendario de contribuciones de cada año pasado, un punto de GraphQL por año, hasta el día en que se creó la cuenta, y el año en curso de enero a hoy. Los años pasados no cambian y las filas son idempotentes; la fila del año en curso es una instantánea marcada `partial`, así que una cadencia diaria la mantiene al día y una única pasada la deja congelada en el día en que corrió. - **`deps`** es el grafo de dependencias. El SBOM son 1,8 MB por repositorio y tiene su propio presupuesto de cien por minuto. Una configuración que no deja nada activado lo dice al arrancar, en vez de correr un bucle vacío en silencio. > **Un nombre desconocido es fatal al arrancar** > > ```text > every.families.trafic: unknown collector (known: account, achievements, actions, activity, ...) > every.families.feeds: "feeds" is a group, not a family; every.groups.feeds is where a whole group's cadence lives > every.groups.actions: "actions" is a family, not a group; it is in group "ci", and every.families.actions is where its cadence lives > ``` > > Cada nombre se comprueba contra los colectores conocidos y los grupos conocidos > antes de la primera llamada a la API. De lo contrario, una errata significaría > una familia corriendo en silencio con su valor por defecto para siempre, y a un > nombre escrito en la capa equivocada se le dice en qué capa iba. ## `heartbeat`: el tic del bucle, que no es una cadencia `heartbeat` fuerza cada cuánto despierta el bucle de pasadas a preguntar qué familias tocan. No le da intervalo a ninguna familia, no se compara con nada de la tabla de arriba, y no se gana ninguno de los avisos de esta página. ```yaml heartbeat: 15s ``` Si se omite, el bucle late a la cadencia más corta configurada, entre un minuto y una hora, que es lo que quiere una instalación real: una familia que corre cada cuarto de hora no se retrasa por otra que corre cada doce horas. El techo importa en una configuración cuyas cadencias son todas más lentas que una hora: la búsqueda de la más corta arranca en una hora, así que el bucle sigue despertando cada hora y no encuentra nada que hacer. Se pone cuando lo que quieres bajo control es el bucle en sí, que casi siempre es una pasada de prueba. Es la única forma de hacer girar el bucle más rápido que ese suelo de un minuto, porque el suelo existe para sobrevivir a una cadencia mal tecleada y un heartbeat explícito no lo es. Un heartbeat **más largo** que la cadencia más corta frena a esa familia, y el arranque lo dice: ```text level=WARN msg="heartbeat is 1h and the shortest cadence is 15m (actions), so no family can run more often than every 1h" ``` ## Hacerlas más lentas Si el presupuesto aprieta, alarga `artifacts` y después `actions`. Son las dos únicas cuyo coste crece con lo activos que estén los repositorios y no con cuántos haya. Ver [coste de una pasada](/ghchronicle/es/api/cost/). El síntoma de unas cadencias demasiado rápidas es un aviso en cada pasada: ```text level=WARN msg="rate limit reserve reached, family skipped" family=actions ``` ## Cadencia no es resolución Alargar una cadencia no hace más gruesa la historia, porque los puntos se fechan por la cosa que ocurrió y no por la pasada. Recoger las ejecuciones de workflows cada hora en vez de cada quince minutos sigue registrando cada ejecución en el segundo en que terminó. Lo que arriesga una cadencia larga es perder una ventana entera: `events`, `notifs` y `activity` son ventanas y no velocidades, como dice la tabla de arriba, y ninguna más lo es. --- # Registro Nivel, formato, el fichero rotatorio que nunca sustituye a la salida de error, y las dos líneas que merecen alerta. Source: https://jmrplens.github.io/ghchronicle/es/configuration/logging/ ```yaml log: level: info # debug, info, warn, error format: text # text o json file: /var/log/ghchronicle/ghchronicle.log max_bytes: 67108864 keep: 5 ``` ## Las claves | Clave | Por omisión | Significado | | ----------- | ----------- | ----------------------------------------------------- | | `level` | `info` | `debug`, `info`, `warn` o `error`; cualquier otro valor se comporta como `info` | | `format` | `text` | `text` para una persona, `json` para un agente de envío; cualquier otro valor es `text` | | `file` | ninguno | Un fichero rotatorio **además de** la salida de error | | `max_bytes` | `67108864` | Rota a los 64 MiB. Un valor no positivo cae al valor por defecto | | `keep` | `5` | Cuántos ficheros rotados conservar. Un valor no positivo cae al valor por defecto | ## Los dos, nunca en lugar de Un `file` configurado no sustituye a la salida de error, se escribe además de ella. Bajo systemd el journal es donde mira todo el mundo primero, y un fichero de log que ocupara en silencio el lugar del journal sería una trampa: `journalctl -u ghchronicle` se quedaría mudo y la conclusión obvia sería que el servicio se ha parado. La rotación es por tamaño con sufijos numerados, así que la retención es un recuento y no una fecha y dos rotaciones en el mismo segundo no pueden chocar. El contador de tamaño se lee del fichero al arrancar, así que un reinicio no lo pone a cero dejando que el fichero crezca sin límite. ## Qué dice el log un día bueno ```text level=INFO msg="repositories discovered" count=18 level=INFO msg=written sink=influxdb family=traffic points=629 level=INFO msg="rate budget" bucket=core remaining=4354 limit=5000 ``` Una familia que aún no toca sencillamente no aparece. Eso es normal, y es lo primero que hay que mirar cuando parece faltar una medida: con una cadencia de doce horas, medio día de logs puede legítimamente no mencionar nunca `account`. ## Las dos líneas que merecen alerta **`rate limit reserve reached`** significa que se saltó una familia para proteger el presupuesto. ```text level=WARN msg="rate limit reserve reached, family skipped" family=artifacts ``` Una vez está bien. En cada pasada significa que las cadencias son demasiado rápidas para el número de repositorios. **`family failed everywhere, not marking it as run`** significa que fallaron todos los repositorios en una familia. ```text level=WARN msg="family failed everywhere, not marking it as run" family=security ``` La familia no se marca como hecha a propósito, así que se reintenta en la siguiente cadencia en vez de darse por completa. Esa es la línea que separa "una función está apagada en un repositorio" de "el token ha perdido un permiso". ## Depuración ```yaml log: level: debug ``` Debug añade tres líneas, y ninguna más: con cuántos puntos volvió el libro de escrituras al arrancar, que las familias de cuenta se saltaron porque `targets.user` está vacío, y cuántas entradas dejó fuera un destino por ser más antiguas que su horizonte. Esa última es la única forma de ver que un envío se recortó en lugar de rechazarse. No hay registro por petición: el cliente de `internal/ghapi` no lleva registrador, así que qué endpoint se llamó y qué respuesta volvió 304 no se ven en ningún nivel. > **JSON para un agente de envío, texto para una persona** > > `format: json` emite un objeto por línea, que es lo que quieren Promtail, > Vector o Filebeat. Es la misma información; solo cambia la codificación. > Combinar `format: json` con un `file` es la disposición para una máquina que > ya envía logs a algún sitio. ## No confundir con el colector de logs de jobs `log` es el diario de la propia herramienta. `every.joblogs` es un _colector_: recoge las últimas cuarenta líneas de cada job fallido de GitHub Actions y las convierte en puntos. Son ajustes sin relación, y el segundo pertenece a un almacén de logs y no a una base de datos de métricas. Ver [Loki](/ghchronicle/es/sinks/loki/). --- # La pasada Qué hace un recorrido por GitHub, en qué orden, y por qué cada familia tiene su propia cadencia. Source: https://jmrplens.github.io/ghchronicle/es/how/ Una pasada es un recorrido por cada familia cuyo intervalo ya ha vencido. Es un incremento, no una reconstrucción: pide lo poco que puede haber cambiado desde la vez anterior, escribe lo obtenido en todos los almacenes configurados y anota cuándo corrió cada familia. ## La forma de una pasada ```mermaid %% Generated by site/scripts/gen-figures.mjs from internal/config/config.go and internal/run/runner.go flowchart TD T["Temporizador"] --> D["Descubrir repositorios
(se rehace cada hora)"] D --> A["Familias de cuenta
account, totals, ratelimit, events, notifs,
billing, profile, outbound, history,
achievements, keys"] D --> R["Familias por repositorio
traffic, repo, branches, stars, issues,
issueevents, actions, artifacts, security,
stats, discussions, commits, activity,
analyses, forks, planning, joblogs, settings,
rulesets, inventory, deployments, policyfiles,
deps"] A --> B{"¿Presupuesto por
encima de la reserva?"} R --> B B -- "no" --> S["Saltar la familia
y avisar"] B -- "sí" --> C["Recoger"] C --> P["Puntos fechados"] P --> H["Destinos que conservan la fecha
InfluxDB, fichero, stdout, Telegraf, Graphite,
PostgreSQL, Elasticsearch"] P --> L["Loki
las veintidós representaciones de evento"] P --> RD["Reductor
valores actuales"] RD --> G["Prometheus
OTLP, cuando raw: false"] C --> M["Marcar la familia como hecha
en el fichero de estado"] ``` Dos cosas de ese diagrama son todo el diseño. Cada colector produce puntos _fechados_, y los almacenes que no pueden sostener una fecha los reciben ya reducidos a valores actuales, por el mismo reductor, antes de ver los datos. Ese es el tema de [la fecha del punto](/ghchronicle/es/how/dating/). ## Por qué familias y no un intervalo único Las superficies se mueven a velocidades muy distintas. Las ejecuciones de workflows terminan cada pocos minutos en una cuenta activa. El calendario de contribuciones cambia una vez al día. La lista de forks cambia unas pocas veces al año. Un único intervalo para todas o malgastaría el límite de peticiones en las lentas o perdería las rápidas, así que cada familia lleva la suya. | Familia | Por omisión | Recoge | | --------------- | ----------- | ------------------------------------------------------------------ | | `actions` | 15m | Ejecuciones de workflows, jobs, pasos, la caché de Actions | | `ratelimit` | 15m | Lo que le queda por gastar al colector, en cada presupuesto | | `activity` | 30m | El log de actividad del repositorio, donde queda un force push | | `events` | 30m | El feed de eventos de la cuenta, que guarda solo los últimos 300 | | `notifs` | 30m | La bandeja de notificaciones | | `artifacts` | 1h | Artefactos y su caducidad | | `commits` | 1h | Líneas cambiadas y estado de la firma, por commit | | `deployments` | 1h | Despliegues y sus entornos, en lote sobre todos los repositorios | | `issueevents` | 1h | La línea de tiempo de lo que se movió: etiquetas, asignaciones, transiciones | | `issues` | 1h | Pull requests, issues y revisiones, uno a uno | | `repo` | 1h | Estrellas, forks, lenguajes, temas, releases, rulesets | | `security` | 1h | Alertas de Dependabot y de code scanning | | `discussions` | 2h | La mitad de foro de un repositorio | | `analyses` | 6h | Análisis de code scanning, que GitHub poda | | `billing` | 6h | Uso por día, producto, SKU y repositorio | | `planning` | 6h | Etiquetas e hitos | | `settings` | 6h | Webhooks y sus entregas, entornos, claves de despliegue | | `stars` | 6h | El recorrido de la lista de estrellas una vez, luego las cien más nuevas | | `traffic` | 6h | La ventana entera de 14 días, reescrita | | `account` | 12h | Perfil, calendario de contribuciones, totales | | `forks` | 12h | Quién hizo fork, y cuándo | | `outbound` | 12h | Estrellas dadas, y trabajo en repositorios ajenos | | `profile` | 12h | Paquetes, gists, cuentas sociales | | `stats` | 12h | Commits por semana, el punch card, las definiciones de workflow | | `totals` | 12h | Las cifras de siempre, preguntadas a GitHub en vez de sumadas aquí | | `achievements` | 24h | Los distintivos del perfil, y lo que falta para el siguiente nivel | | `branches` | 24h | Qué ramas siguen vivas y cuánto hace que no se toca cada punta | | `inventory` | 24h | Qué puede hacer el token del propio workflow, los dos almacenes de secretos, el code scanning por omisión | | `keys` | 24h | Las claves SSH y GPG de la cuenta, y cuándo caduca cada una | | `policyfiles` | 24h | SECURITY.md, CODEOWNERS, dependabot.yml y FUNDING.yml | | `rulesets` | 24h | Cada versión del historial de cada ruleset | | `deps` | apagada | El SBOM de dependencias de cada repositorio, y lo que cambió | | `history` | apagada | El calendario de contribuciones de cada año, el actual incluido | | `joblogs` | apagada | La cola del log de cada job fallido | Poner cualquiera a `0` la apaga por completo. Ver [cadencias](/ghchronicle/es/configuration/cadences/). ## El descubrimiento La lista de repositorios se rehace como mucho una vez por hora. Los repositorios se crean pocas veces y listarlos cuesta una página por cada cien, así que cualquier cosa más corta gasta cuota para no aprender nada. Los forks y los archivados quedan fuera por omisión, por la razón que se expone en [objetivos](/ghchronicle/es/configuration/targets/). ## El fallo es por repositorio, no por pasada Una familia que falla en un repositorio se registra y se salta; la pasada continúa. Esto importa más de lo que parece, porque aquí "fallo" suele ser una función apagada: de cincuenta repositorios, la mayoría tiene Dependabot desactivado, y cada uno responde 403. Tratarlo como error perdería los otros cuarenta y nueve. Hay una excepción, y es deliberada. Una familia en la que fallaron _todos_ los repositorios no se marca como hecha. Marcarla escondería la caída hasta la siguiente cadencia, que para las familias de doce horas es medio día. ```text level=WARN msg="family failed everywhere, not marking it as run" family=security ``` ## El fichero de estado `state_file` guarda [seis cosas](/ghchronicle/es/configuration/#state_file), y las dos por las que se juzga una pasada son cuándo corrió cada familia por última vez y cuándo se vio cada repositorio por primera vez. Lo segundo es lo que hace que el recorrido completo de la historia de estrellas ocurra una vez y no en cada pasada. Se escribe a través de un fichero temporal y se renombra, así que una caída a mitad de escritura no puede dejar un estado truncado que provocaría una recolección completa. > **Tres pasadas recogen todas las familias diga lo que diga el estado** > > Un exportador guarda sus muestras en memoria, así que un reinicio lo vacía y > sigue vacío hasta que llega la cadencia de cada familia, lo que para las de > doce horas es medio día de dashboard a cero. Pagar una pasada completa es el error > más barato, así que la primera pasada tras el arranque corre todas las > familias activas diga lo que diga el fichero de estado. Un > [relleno](/ghchronicle/es/how/backfill/) hace lo mismo, porque llegar tan > atrás como permita GitHub es justo lo que se le pide, y también una pasada que > dibuja [una tarjeta](/ghchronicle/es/card/), porque cada número de la tarjeta > sale de esa única pasada y una familia saltada por no tocarle sería un cero en > la imagen. ## El freno Antes de cada familia el colector mira los tres cubos de límite de los que realmente gasta. Si alguno está en su reserva o por debajo y la ventana aún no se ha reiniciado, la familia se salta y se registra un aviso en vez de gastar el presupuesto hasta la última llamada. Ver [límites de la API](/ghchronicle/es/api/). Un [relleno histórico](/ghchronicle/es/how/backfill/) es la intención contraria: espera a que la ventana se renueve en vez de saltarse la familia. --- # La fecha del punto Cada punto lleva el momento en que ocurrió la cosa, y esa única regla decide qué puede responder todo el proyecto. Source: https://jmrplens.github.io/ghchronicle/es/how/dating/ **Un punto lleva la fecha en que ocurrió la cosa, no la fecha en que se recogió.** Esa es la única regla de la que se deriva todo lo demás. Una ejecución de workflow se sella cuando terminó. Una estrella se sella cuando se dio. Un día de tráfico se sella en la fecha de ese mismo día. Una pull request se sella cuando se cerró. Ninguno se sella en el instante en que el colector se enteró. ## Los tres tipos de punto No todo lo que GitHub informa tiene fecha propia, así que hay tres tratamientos y cada medida declara cuál le corresponde. | Tipo | Sellado en | Ejemplo | | ----------- | --------------------------------- | --------------------------------------------------------------- | | **Fechado** | el momento en que ocurrió la cosa | `gh_star`, `gh_workflow_run`, `gh_traffic`, `gh_commit` | | **Diario** | el inicio del día UTC | `gh_traffic_referrer`, `gh_label`, `gh_milestone`, `gh_webhook` | | **Ahora** | el instante de la pasada | `gh_repo`, `gh_actions_cache`, `gh_dependabot_alert` | Diario es para una instantánea sin fecha propia. GitHub devuelve los diez primeros referrers de los catorce días anteriores como una lista sin día asociado, así que no es una serie. Sellarla en el instante de la pasada escribiría cuatro copias al día y cualquier consulta que las sumara informaría de cuatro veces el tráfico. Sellarla al inicio del día UTC hace que las pasadas de un día reescriban una sola fila. Ahora es para algo que de verdad es un estado actual. El tamaño de la caché de Actions, el número de alertas abiertas, el inventario de workflows: ninguno ocurrió en un momento, así que fingir lo contrario sería una mentira con marca de tiempo. ## Por qué existe la regla: volver a recoger tiene que converger InfluxDB indexa un punto por medida, conjunto de etiquetas y marca de tiempo. Tres campos, una fila. Escribe los mismos tres otra vez y la fila se reemplaza, no se suma. Escrito del todo, dos pasadas separadas por seis horas ofrecen el mismo día dos veces, y la segunda reemplaza a la primera porque las tres claves son idénticas: ```text gh_traffic,owner=acme,repo=telemetry,kind=views count=220i,uniques=131i 1757203200000000000 gh_traffic,owner=acme,repo=telemetry,kind=views count=238i,uniques=140i 1757203200000000000 ``` ```text gh_traffic,owner=acme,repo=telemetry,kind=views count=238i,uniques=140i 1757203200000000000 ``` Una fila, con el número más reciente que GitHub dio para ese día. Sella esas dos líneas en el momento de recogerlas y son dos filas, y toda suma sobre ellas está equivocada por tantas veces como pasadas hayan corrido. Eso es lo que hace funcionar todo el diseño. La ventana de tráfico de GitHub es de catorce días, y el colector reescribe _los catorce_ en cada pasada en vez de intentar averiguar qué día es nuevo: GitHub guarda 14 días de tráfico y el colector los relee todos 4 veces al día. Fechadas tal como está hecho, esas 4 pasadas caen sobre las mismas 14 filas y el recuento más reciente sustituye al anterior. Selladas en el momento de recogerlas, cada pasada añade lo que leyó a lo que ya había. | Tras una semana, por repositorio | Fechado, tal como está hecho | Sellado al recoger | | --- | --- | --- | | Filas escritas por pasada | 14 | 14 | | Filas en el almacén | 14 | 392 | | Copias de cada día | 1 | 28 | | Sumar las visitas del mes | el tráfico | 28 veces el tráfico | La misma propiedad es la que hace seguro ejecutar dos veces un relleno histórico, y la que permite borrar el fichero de estado sin corromper nada: volver a recoger reescribe filas que ya había escrito. > **Todo almacén de historia tiene esta propiedad** > > No es específico de InfluxDB. La clave primaria del destino SQL es `(time, > columnas de etiqueta)`, que es la misma clave de serie escrita como > restricción, y sus inserciones terminan en `ON CONFLICT ... DO UPDATE` y no en > `DO NOTHING`. Elasticsearch deriva el id del documento de la medida, las > etiquetas y la marca de tiempo, e indexa en vez de crear. Graphite escribe en > la ranura de whisper que nombra la marca de tiempo. Los cuatro convergen por > la misma razón. ## Lo que Prometheus no puede sostener por construcción Prometheus sella una muestra en el instante del scrape. No toma una marca de tiempo del productor, y rechaza cualquier cosa apreciablemente más vieja que ahora. Esto se midió, no se supuso. Contra **Prometheus 3.14**, con `--web.enable-otlp-receiver` y `out_of_order_time_window: 30m`, una muestra fechada dos días atrás vuelve como **HTTP 400**. La mitad de lo que esto recoge es más viejo que eso a propósito: una estrella de 2020, una pull request fusionada en julio, el tráfico de ayer. Así que no hay configuración de Prometheus en la que sobreviva la historia fechada. Ensanchar la ventana de desorden mueve la frontera; no la elimina. > **No le des marcas de tiempo al exportador** > > El "arreglo" obvio es que el exportador emita cada muestra con la marca de > tiempo del punto. El formato de exposición de Prometheus lo permite, y > Prometheus rechazará justo las que importan. El exportador descarta la marca > de tiempo a propósito, y la reducción de abajo es la razón de que eso no sea > una pérdida. ## El reductor, y qué hace con cada medida Como el almacén no puede sostener la historia, la reducción ocurre _antes_ de que Prometheus o un backend OTLP con `raw: false` vean los datos. `Summarize` da a cada medida una de cuatro reglas. | Regla | Qué hace | Se usa para | | ---------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `keepLast` | Gana el punto más reciente de cada conjunto de etiquetas | Instantáneas: `gh_repo`, `gh_account`, `gh_release` | | `sum` | Se suman todos los puntos del lote | Ventanas: las visitas de los catorce días | | `count` | Los puntos pasan a ser un recuento más la media de cada campo numérico | Elementos fechados: las pull requests pasan a ser "cuántas se fusionaron" y "cuánto tardaron" | | `skip` | No se sirve nada | Historia sin valor actual honesto | Una medida sin regla se salta en vez de adivinarse. Ese es el valor por defecto seguro, y es deliberado: sin él, un colector nuevo podría inundar en silencio un exportador con una serie por estrella. `count` promedia cada uno de sus números excepto los identificadores. Un campo que une una fila con otra, `run_id`, `workflow_id`, `pull_request`, `number`, `stack` y los demás, es un nombre y no una cantidad: promediado sobre una cuenta se convierte en un número con la forma exacta del identificador del que está hecho y que no pertenece a nada, y `gh_deployment` publicó `run_id_mean` así. Esos campos quedan fuera de la reducción, y también las marcas que un punto lleva solo para tener algún campo, cuya media es 1,0 para siempre. El reductor publica además `total`, un recuento acumulado de elementos distintos vistos por serie. Eso es lo que permite que un dashboard de Prometheus responda "por día", mediante `increase()` sobre un contador monótono, ya que no tiene filas que contar. ### Las diez que no se sirven nunca Diez de las medidas llevan `skip`, así que un exportador de Prometheus y un backend OTLP con `raw: false` no las ven nunca. Siete son historia, dos son tamaño y una es texto: | Medida | Por qué | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `gh_artifact` | Historia. Una fila por artefacto de siempre, y ninguna se vuelve a mover | | `gh_commit_punchcard` | Tamaño. Una serie por repositorio, día de la semana y hora | | `gh_commits_week` | Historia. La serie semanal de commits | | `gh_contribution_day` | Historia. El calendario verde, una fila por día | | `gh_contribution_day_repo` | Historia. El mismo calendario partido por repositorio, que acuñaría una serie por día | | `gh_job_log` | Texto, no un número. Su sitio es un almacén de logs | | `gh_package_version` | Historia. La fecha de publicación de cada tag; su recuento ya es un campo de `gh_package` | | `gh_release_asset` | Tamaño. Una serie por cada fichero publicado alguna vez | | `gh_traffic_path` | Historia. Las rutas por día | | `gh_workflow_step` | Historia. Los tiempos por paso | Las dos que se saltan por tamaño son las que conviene conocer, porque son números de verdad y no historia: medido, juntas eran cuatro quintas partes de toda la salida del exportador. Ambas se dibujan bien en el dashboard de InfluxDB, y `gh_release` guarda las descargas por release, que es para lo que se leían los ficheros. ## Qué guarda cada almacén | Almacén | La historia fechada | Por qué | | --------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | InfluxDB, PostgreSQL, Graphite, Elasticsearch | sí | La marca de tiempo es parte de la identidad de una fila | | Telegraf | hasta donde permitan sus salidas | Reenvía las marcas de tiempo sin tocarlas; una salida que sella al recibir las pierde | | Fichero y stdout | sí | La marca de tiempo va en la línea | | Loki | solo eventos recientes | Loki rechaza una entrada demasiado atrasada respecto a la más nueva de su stream. Ver [Loki](/ghchronicle/es/sinks/loki/) | | OpenTelemetry | lo decide el backend | Los puntos OTLP llevan una marca de tiempo explícita; que se respete no depende de esta herramienta | | Prometheus | no | Solo valores actuales, según las reglas de arriba | ## Tres consecuencias que conviene conocer **Una etiqueta es una serie, un campo es un valor.** Todo lo no acotado va en un campo. El nombre del runner de Actions parece una buena etiqueta hasta que uno se fija en que un runner alojado se nombra de forma única en cada ejecución (`GitHub Actions 1000163135`), lo que crearía una serie por cada job ejecutado alguna vez. Es un campo. **Las filas semanales se anclan a la semana, no a hoy.** `gh_commits_week` se sella en el domingo con el que empieza cada semana. Una pasada del martes y otra del viernes tienen que caer en la misma fila, o cada relectura escribe una segunda copia del año. **Prometheus reserva algunos nombres de etiqueta.** Una etiqueta llamada `job` o `instance` choca con las etiquetas del scrape, y el receptor OTLP la sobrescribe con el nombre del servicio. Por eso los jobs de workflow se etiquetan `job_name`. --- # Relleno histórico Un recorrido deliberado hasta el final de cada superficie, hasta dónde llega, y las tres cosas que ningún relleno alcanza. Source: https://jmrplens.github.io/ghchronicle/es/how/backfill/ ```sh ghchronicle -config config.yaml -backfill ``` Una pasada es un incremento. Un relleno histórico es un recorrido. Son intenciones opuestas y la herramienta las trata como tales. ## La diferencia en una tabla | | Pasada | Relleno | | ---------------------- | ----------------------------------------- | -------------------------------------------------------- | | Páginas por colector | el pequeño valor por defecto del colector | hasta que se acabe la API, o hasta el límite dado | | Al llegar a la reserva | saltar la familia y avisar | esperar a que la ventana se reinicie y seguir | | Qué familias corren | las que tienen el intervalo vencido | todas las activas, diga lo que diga el fichero de estado | | Con qué frecuencia | según horario, siempre | a propósito, normalmente una vez | Una pasada normal nunca debe bloquear. Protege la reserva para que lo demás que use el mismo token siga funcionando, y salta una familia antes que dormir. Un relleno se lanza a propósito y lo único que importa es que termine, así que aparca hasta que el presupuesto vuelva a estar entero. Un relleno que se rinde a medias ha gastado la parte cara del presupuesto y conserva solo las familias que llegó a terminar: cada una se escribe y se marca en cuanto acaba, así que lo que hay que volver a lanzar es el resto. ## La espera Cuando un cubo está en su reserva o por debajo, el relleno espera. La espera no se adivina: cada respuesta de GitHub dice exactamente cuándo se reinicia su ventana, así que el colector duerme hasta ese instante más un segundo de holgura y registra lo que está haciendo. ```text level=INFO msg="rate limit reserve reached, waiting for the window to reset" family=commits wait=23m11s ``` Dos guardas sobre esa espera. Está limitada a una hora, para que un desfase de reloj o una cabecera obsoleta no se conviertan en un sueño ilimitado, y tiene un suelo de un segundo, para que no se convierta en un bucle activo. Un contexto cancelado termina la espera de inmediato, que es lo que hace que `Ctrl+C` funcione durante un relleno nocturno. ## Hasta dónde `-backfill-since` en la línea de órdenes, o `backfill.since` en el fichero de configuración. Cuatro formas de escribirlo, porque cada cual tira de una: | Valor | Significa | | ------------------------- | --------------------------------- | | `2024-01-01` | esa fecha | | `90d` | hace noventa días | | `2y` | hace dos años | | `720h` | una duración de Go antes de ahora | | vacío, `all`, `unlimited` | sin límite alguno | Sin límite significa que el recorrido solo se detiene donde se detiene la API, por muchas horas o días que eso lleve, con una pausa en cada reinicio de límite por el camino. ```sh ghchronicle -config config.yaml -backfill -backfill-since 2y ``` ## Lánzalo una vez, primero Una instalación nueva debería lanzar un relleno antes de arrancar el servicio, o justo después. La primera pasada es un incremento más ancho, no una historia: un mes de ejecuciones de workflows, la historia de estrellas entera y la página más reciente de todo lo demás. Los puntos van fechados, así que a cualquier almacén le vale; a un dashboard a noventa días o a dos años no, porque la historia que dibuja empieza el día en que se instaló el colector. Medido tras un día de pasadas y sin relleno, sobre repositorios con cientos de pull requests cada uno: pull requests, alrededor de una quinta parte de las que declaran los repositorios, porque una pasada lee una sola página de cincuenta por repositorio por muchas que tenga; issues, alrededor de la mitad; commits, bastante menos de una décima parte y ninguno de más de treinta días; jobs y pasos de una décima parte de las ejecuciones de workflows, así que la espera en cola, los jobs más lentos y los pasos que fallan se calcularon sobre esa décima parte. A dos años, _Pull requests merged_ marcaba una quinta parte de lo que decía _Pull requests merged, ever_. Estrellas, forks, releases, despliegues y alertas estaban completos, porque la primera pasada los recorre hasta el final de todos modos. ## Qué alcanza que una pasada no - Toda la historia de commits, en lugar de la última página. Esta es la única familia en la que un relleno es cualitativamente distinto y no solo más ancho: sin él la serie de líneas cambiadas empieza el día en que instalaste el colector. - Dos años de ejecuciones de workflows, y cada ejecución expandida en sus jobs y sus pasos. - Los repositorios archivados, enteros, diga lo que diga `include_archived`. Su historia es la historia de la cuenta y ya no se mueve, que es justo por lo que una pasada los deja fuera y por lo que un solo recorrido basta. Los forks quedan como estén configurados. Una pasada sigue escribiendo la única fila que tiene cada repositorio archivado, la fecha en que se archivó: el listado que ya paga dice cuáles, y una consulta por pasada de `totals` dice cuándo. - Todas las páginas de artefactos, veinte páginas de actividad del repositorio y los análisis de code scanning. - Cien pull requests e issues por repositorio. - Las notificaciones leídas además de las no leídas. - Veinticuatro meses de facturación. - Cien entregas de webhook por hook. ## Qué no enciende Un relleno corre todas las familias activas diga lo que diga el fichero de estado. No enciende ninguna. Las tres familias que vienen con cadencia `0`, `deps`, `history` y `joblogs`, siguen apagadas si no se les ha dado una por nombre bajo `every.families`, y `-backfill` no cambia eso. `history` es la que sorprende, porque recorrer cada año pasado del calendario de contribuciones es justo lo que tiene en la cabeza quien pide "toda la historia". Dale antes una cadencia: ```yaml every: families: history: 24h ``` En [cadencias](/ghchronicle/es/configuration/cadences/) está por qué `default` y `groups` tampoco pueden encender estas tres. ## Dos endpoints que necesitaron trato aparte Ambos se descubrieron ejecutándolo, no leyendo documentación. **Dependabot rechaza los números de página.** Responde con un error a `page=` sin más y pagina por cursor, así que el recorrido de alertas está escrito contra cursores. **La pasarela de GraphQL se rinde con cien pull requests.** Pedir cien pull requests con sus revisiones en una consulta responde un **502 en HTML** al cabo de unos diez segundos. El recorrido de pull requests parte su página por la mitad y reintenta sobre el mismo cursor, en silencio: los colectores no llevan registrador, así que un relleno de un repositorio activo lo muestra solo como una familia más lenta, nunca como una línea. > **Tres cosas no se pueden rellenar a ningún precio** > > Ninguna espera cambia esto, y son la razón de que el proyecto exista. > > - **El feed de eventos guarda trescientos eventos**, sea cual sea su fecha. > Pasado ese techo GitHub responde 422 "pagination is limited for this > resource", que el colector lee como el final de los datos. > - **El tráfico son catorce días.** Nada más viejo llegó nunca a guardarse en > GitHub. > - **Los logs de los jobs se borran a los noventa días** y responden 410 después, > mientras que los metadatos de la ejecución a la que pertenecen sobreviven > años. ## Cómo lanzar uno con seguridad Es idempotente. Los puntos se indexan por medida, etiquetas y marca de tiempo, así que un relleno ejecutado dos veces reescribe las mismas filas en vez de duplicarlas, en todo almacén que guarde la historia. Lo que cuesta es cuota de API y tiempo. Dos cosas que conviene hacer antes: ejecutar `-list` para confirmar el conjunto de repositorios, y comprobar que el almacén al que escribes es de los que guardan fechas. Rellenar hacia Prometheus recoge muchísima historia y después la reduce entera a un único valor actual. --- # Qué se recoge Las treinta y cuatro familias, qué le pide cada una a GitHub y por qué existe cada una. Source: https://jmrplens.github.io/ghchronicle/es/collectors/ Treinta y cuatro familias, noventa y una medidas. Esta página es para qué sirve cada familia; la [referencia de medidas](/ghchronicle/es/collectors/measurements/) es cada etiqueta y cada campo. Las familias además están agrupadas, y `groups:` en la configuración enciende o apaga un área entera. El binario imprime la agrupación que usa de verdad, que es la que hay que creer: ```sh ghchronicle -groups ``` ```text account the account itself: its lifetime numbers, its profile, its keys, its spending and what it does in other people's repositories account, achievements, billing, history, keys, outbound, profile, totals audience who is looking at the projects, who starred them and who copied them forks, stars, traffic ci continuous integration and deployment: runs, jobs, steps, artifacts, caches and deployments actions, artifacts, deployments, joblogs ... ``` ## Audiencia **`traffic`** recoge los únicos datos que GitHub tira de verdad. Las visitas y los clones viven exactamente catorce días y luego dejan de existir en ningún sitio. La ventana entera se relee y se reescribe en cada pasada, con cada día sellado en su propia fecha, así que un colector apagado una semana no pierde nada mientras vuelva dentro de la ventana. Los referrers y las rutas populares son distintos: la API devuelve una instantánea de diez sin fecha alguna, así que se sellan al inicio del día UTC y se leen como "quién mandaba tráfico cuando preguntamos". **`stars`** reconstruye la curva de estrellas desde su principio. El endpoint de stargazers devuelve un `starred_at` por usuario si se pide con el media type de estrellas, así que toda la historia está disponible en la primera ejecución: una gráfica que se remonta años, no una que empieza el día en que se instaló el colector. Tras la primera pasada solo se leen las cien más nuevas, ya que las estrellas nuevas caen al final, y se leen para todos los repositorios a la vez en una consulta GraphQL por cada diez. Esa es la diferencia entre un punto y 280 llamadas para un repositorio con 28.000 estrellas. **`forks`** recoge quién hizo fork y cuándo. La instantánea del repositorio lleva un contador de forks, que dice cuántos pero nunca cuándo ni de quién. La lista se recorre por REST en la primera pasada de una instalación nueva y en un backfill; después los cien más nuevos de cada repositorio viajan en el mismo tipo de lote que las estrellas. Una fila de fork no es estática como una estrella: lleva las estrellas del propio fork y cuánto hace que recibió un push, así que un repositorio del que el lote informa que tiene más de cien forks se recorre también por REST, que refresca eso en hasta quinientos forks como siempre hizo. ## Repositorios **`repo`** recoge lo que un repositorio es ahora mismo, más las cosas que se acumulan del lado de GitHub: lenguajes por bytes, temas, salud de la comunidad y descargas de release **por fichero**, que es lo que distingue una compilación para Linux de una para macOS. Los bytes por lenguaje importan porque la etiqueta del lenguaje dominante no puede mostrar un repositorio pasando de un lenguaje a otro con el tiempo. **`settings`** recoge la configuración que cambia, y cómo de bien funcionan las partes que hablan con el exterior. Una cosa de aquí es una serie temporal de verdad y no una instantánea: las entregas de webhook llevan un código de estado y una latencia. **`rulesets`** recoge el historial de cada ruleset, una fila por versión guardada con actor y fecha, que es el único rastro que GitHub conserva del momento en que se desactivó una protección. El ruleset en sí, lo que impone y quién puede saltárselo, es una instantánea diaria en `repo`; esto es la historia que el `days_since_change` de esa instantánea solo resume. **`branches`** recoge la lista viva de ramas, una fila por rama con la edad de su punta. Nada más responde a "qué ramas se abandonaron": la propia lista de GitHub ordena por nombre y olvida, así que una rama cuyo último commit es de hace cuatro meses parece exactamente igual que una que recibió un push esta mañana. A propósito no pregunta qué pull requests apuntan a cada rama, que es lo que encarece esa consulta; ese cruce es cosa del panel. **`inventory`** recoge las tres superficies de política por repositorio que cambian en una escala de meses: qué puede hacer el `GITHUB_TOKEN` de un workflow, qué edad tiene cada secreto guardado, y si el code scanning lo enciende GitHub o un workflow propio. Cuatro peticiones core por repositorio al día. Las tres son ajustes y no eventos, así que se sellan al inicio del día UTC y un cambio se lee como el día en que el valor se movió. **`policyfiles`** anota qué ficheros de gobernanza lleva un repositorio, `SECURITY.md`, `CODEOWNERS`, `dependabot.yml` y `FUNDING.yml`, y cuándo cambió cada uno por última vez. Si la mayoría existen hoy ya está en `gh_repo_policy`; cuándo cambiaron no está en ningún otro sitio, y `.github/dependabot.yml` no está en ninguna otra medida, que es lo que convierte "¿recibe este repositorio actualizaciones de dependencias por alguna de las dos vías?" en una pregunta que los datos pueden responder. > **Los webhooks fallan en silencio** > > Medido, un hook llevaba respondiendo 403 en setenta y ocho de sus últimas > cien entregas y no había nada en ningún sitio que lo dijera. Solo se guarda > el host de la URL del webhook; la ruta suele llevar un secreto. ## Desarrollo **`issues`** recoge pull requests e issues uno a uno, no como recuentos. Un recuento de pull requests abiertas no dice nada de cómo va realmente el trabajo; los números interesantes son duraciones. Cuánto tardó alguien en revisarlo, cuánto tardó en fusionarse, cómo de grande era el diff, cuántas rondas de revisión hicieron falta. Una consulta de GraphQL por repositorio cubre ambos: una pasada recorre lo que cambió en las dos últimas cadencias, de diez en diez, y una vez al día lee una página completa dimensionada al repositorio, que es la única lectura que reescribe una pull request abierta que nadie ha tocado. **`commits`** recoge la historia de commits con su tamaño y su firma. Esto es lo que sustituye a `stats/code_frequency`, que responde 202 con cuerpo vacío para siempre en una cuenta personal. GraphQL da líneas añadidas y quitadas por commit, atribuidas a un autor y fechadas en el commit y no en una semana, y la firma viene con la misma consulta. **`issueevents`** recoge las transiciones en lugar del estado. `issues` dice en qué acabó una pull request; esto dice cuándo se etiquetó, se cerró, se reabrió, se renombró o se pidió revisión. Una reapertura no existe en ninguna otra medida. La lista a nivel de repositorio, `/issues/events`, es la referencia de qué es un evento y cómo se llama, e incrusta la issue entera en cada evento: alrededor de un megabyte por página de cien, del que el colector conserva el tres por ciento. Así que una pasada pide a GraphQL la cronología de las issues y pull requests actualizadas en su ventana, diez ítems por página porque se midió que la pasarela descarta cronologías en silencio a partir de veinte, y un backfill recorre el endpoint por issue, `/issues/{n}/events`, que son las mismas filas sin la issue. Medido durante una semana de dos repositorios, la cronología coincide con la lista en todos los eventos de todos los tipos que sabe nombrar, campo a campo; una pull request en una pila se lee por su propia lista porque `added_to_stack` no tiene tipo en la cronología, y lo único que la lista ve y esto no es un commit que referencia una issue que nadie ha tocado, tres eventos de 2.217. **`deps`** recoge el grafo de dependencias, apagado por defecto. Dos formas del mismo asunto: el SBOM como fotografía, que es un histograma de licencias, y el diferencial entre dos commits, que dice qué entró y qué salió y con qué aviso de seguridad. Solo se guardan agregados, porque un solo bump de dependencias son trescientos setenta cambios y seis filas dicen lo mismo. **`discussions`** recoge la mitad de foro de un repositorio. Las discusiones son invisibles para todos los endpoints de issues y pull requests, y una pregunta respondida es un coste de soporte que no aparece nunca en los números de issues. Un repositorio con el foro apagado no se consulta: el listado que lo descubrió ya lo dice, y la consulta cuesta lo mismo haya o no algo que paginar. **`planning`** recoge etiquetas e hitos. Un hito es el único sitio donde GitHub registra una intención con fecha de vencimiento, y su porcentaje de completitud se calcula en el servidor. **`activity`** recoge el log de actividad propio de un repositorio. Es el único sitio donde queda registrado un force push: el feed público de eventos no lo distingue, y nada más dice que se creó o se borró una rama ni que una fusión fue un squash y no un rebase. Es tan perecedero como el tráfico: cien entradas cubrieron veintiséis horas en el repositorio más activo medido. ## Integración continua **`actions`** recoge las ejecuciones de workflows como hechos fechados. Una ejecución pertenece al instante en que terminó, no al instante en que nos enteramos, que es lo que hace que se pueda responder "cuánto tardó la CI el martes pasado". El tiempo a nivel de ejecución esconde dónde se fue el tiempo: una ejecución que tarda veinte minutos porque un job esperó dieciocho por un runner es idéntica a una que pasó dieciocho ejecutando. Solo el nivel de job los separa, y solo el nivel de job nombra el runner y los pasos, así que los jobs de cada ejecución se expanden a una petición extra por ejecución. Una vez: los jobs de un intento terminado no cambian nunca, así que una ejecución cuyos jobs este proceso ya escribió no se vuelve a listar, y una reejecución es un intento nuevo que sí. Una pasada ordinaria lee la lista de ejecuciones en páginas de treinta y pasa de página mientras vengan llenas de ejecuciones más nuevas que su ventana; la primera pasada tras el arranque y un backfill leen páginas de cien. **`artifacts`** recoge lo que dejaron atrás los workflows, con tamaños y caducidad. **`joblogs`** recoge el texto que imprimió un job fallido. Es lo único aquí que es un log y no una medida, y responde la pregunta que una gráfica nunca puede: no "la compilación falló" sino por qué. Solo los fallos, y solo sus últimas cuarenta líneas. Apagado por omisión. **`deployments`** recoge los despliegues más recientes de cada repositorio, que es la superficie que lee un dashboard de entregas. Un punto de GraphQL por cada cinco repositorios: cinco y no diez porque la pasarela abandona una consulta que no puede terminar en unos diez segundos, y esta pide conexiones y no números sueltos. ## Seguridad **`security`** cuenta las alertas abiertas por severidad y estado, y registra **explícitamente qué funciones están activadas**. Esa última parte es la razón de que "sin datos" y "sin alertas" sean distinguibles: sin ella, un repositorio con Dependabot apagado es idéntico a uno sin nada que arreglar. **`analyses`** recoge los análisis de code scanning en sí, no solo las alertas. Una alerta dice qué está mal ahora. Un análisis dice que el escaneo corrió, cuándo, sobre qué commit, con qué versión de la herramienta y cuántos resultados encontró, que es lo que responde "¿corrió de verdad el escaneo en esa release?". GitHub los poda, así que hay que capturarlos mientras están. ## Cuenta **`account`** es una consulta de GraphQL y lo más barato del proyecto. Devuelve el calendario de contribuciones completo de 366 días, todos los totales de contribución, el desglose de commits por repositorio y el bloque de patrocinios por **un punto de un presupuesto de cinco mil**. Lo mismo por REST serían docenas de llamadas y no incluiría el calendario en absoluto. **`profile`** recoge paquetes, gists, cuentas sociales y el grafo de seguidores. Los paquetes vienen de REST a propósito: GraphQL informa de cero paquetes para una cuenta mientras REST los lista. **`outbound`** es la otra mitad de todo lo demás. Todas las otras familias miden lo que la cuenta posee; esta mide lo que lee y a lo que contribuye: las estrellas que dio, y las pull requests que abrió en repositorios ajenos. Todo es GraphQL, un punto por consulta: la lista de estrellas dadas, cinco búsquedas de issues y los dos recorridos de comentarios. **`totals`** le pide a GitHub los números que son ciertos desde el principio: pull requests fusionadas alguna vez, commits totales, issues abiertas alguna vez, y la vida entera de cada repositorio. Es la única familia que existe por cómo se lee un almacén y no por lo que ofrece GitHub. Todo lo demás aquí es una fila por hecho, que es la forma correcta para "cuántas en julio" y la equivocada para "cuántas en total": responder eso desde las filas obliga a recorrer la tabla entera, y InfluxDB 3 Core rechaza una consulta que abra más ficheros que su tope, cuarenta mil donde esto se midió. La búsqueda devuelve un total para cualquier consulta y GraphQL uno para cualquier conexión, así que una consulta de diez búsquedas con alias, una búsqueda REST para el contador de commits y una consulta en lote dan un número que es una sola fila y que ya es correcto en la primera pasada de una instalación nueva. **`ratelimit`** es la única medida que el colector toma de sí mismo: lo que queda en cada uno de los quince presupuestos independientes de GitHub y cuándo se reinicia cada uno. `GET /rate_limit` no cuesta nada, y sin ella una familia saltada por falta de presupuesto es indistinguible de una que no tenía nada que contar. **`keys`** recoge las claves SSH y GPG de la propia cuenta: cuáles no se han usado nunca, y cuándo caduca la que firma todos los commits. **`stats`** recoge los commits por semana y el punch card de hora de la semana, los dos endpoints de `stats` que sí responden en una cuenta personal. **`history`** recorre el calendario de contribuciones de cada año pasado, un punto de GraphQL por año, hasta el día en que se creó la cuenta. Apagada por omisión porque solo hace falta una vez. **`achievements`** recoge las insignias de la página pública del perfil, y es la única familia que no sale de la API: GitHub no lista los logros ni en REST ni en GraphQL, así que la página se lee una vez al día como visitante anónimo, sin el token y sin cargarse a ningún presupuesto. Junto a cada insignia escribe cuánto le falta a la cuenta para el siguiente nivel de esa insignia, que eso sí sale de la API. El analizador es estricto a propósito: cuando GitHub rediseñe la página la familia no escribe nada y lo dice una vez, en vez de escribir números equivocados que parecerían exactamente igual de buenos. ## Actividad y coste **`events`** recoge el feed de actividad de la cuenta, la superficie más perecedera que tiene GitHub. Guarda aproximadamente los últimos trescientos eventos y descarta lo más viejo, sea cual sea su fecha, y nada más registra que un repositorio recibió una estrella, un fork, un seguimiento o un push en un minuto concreto. **`notifs`** recoge la bandeja de entrada. Como el feed de eventos es una ventana, no una historia: GitHub guarda las notificaciones no leídas alrededor de un año y las leídas mucho menos, y `per_page` se limita en silencio a 50 se pida lo que se pida. **`billing`** recoge lo que la cuenta gastó realmente, día a día, por producto, SKU y repositorio, que es el único sitio que dice qué repositorio quemó los minutos. Se guardan el bruto, el descuento y el neto en vez de derivar uno de los otros, porque el neto no siempre es cero: en la cuenta con la que se desarrolló esto lleva el crédito mensual. > **Una familia que falla en un repositorio no tumba la pasada** > > De cincuenta repositorios, la mayoría tiene Dependabot apagado, y cada uno > responde 403. Eso se anota como "no activado" y la pasada sigue. Solo una > familia en la que fallaron *todos* los repositorios se deja sin marcar, para > que se reintente en vez de darse por hecha. --- # Medidas Cada medida, sus etiquetas, sus campos y cómo se fecha cada una. Source: https://jmrplens.github.io/ghchronicle/es/collectors/measurements/ Noventa y una medidas. Cada fila dice cómo se fecha un punto, porque eso es lo que decide qué preguntas puede responder. ## Cómo leer las tablas | Fechado | Significa | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | | **fechado** | El punto lleva el momento en que ocurrió la cosa, así que la historia es real y volver a recoger reescribe las mismas filas | | **diario** | Una instantánea sin fecha propia, sellada al inicio del día UTC para que las pasadas de un día converjan en una fila en vez de apilarse | | **ahora** | Un estado actual, que solo tiene sentido como "qué es cierto en este momento" | Toda medida lleva `owner`, `repo` y `full_name` como etiquetas salvo que sea de ámbito de cuenta, en cuyo caso lleva `user`. Casi todas llevan además un campo `url`: la página de GitHub de aquello de lo que habla la fila, para que una fila de un dashboard que nombra algo también pueda abrirlo. Una `url` es absoluta o está ausente, porque los dashboards enlazan al propio valor, y toda medida que la lleva se enlaza por unidad desde al menos una tabla, salvo las ocho que solo se dibujan como curva o barra (`gh_pull_request_review`, `gh_workflow_job`, `gh_event`, `gh_issue_event`, `gh_artifact`, `gh_contribution_day`, `gh_contribution_day_repo` y `gh_commit_check`), donde una url por ítem no tiene fila en la que sentarse. Una etiqueta que GitHub deja vacía se escribe como `(none)`, una sola grafía en todas las medidas, y ese mismo `(none)` va en unos pocos campos de texto que no dicen nada en la mayoría de las filas, el `decision` de una pull request o el `assigned_to` de una issue: InfluxDB 3 crea la columna la primera vez que una fila la trae, y una consulta que nombra una columna que ninguna fila ha escrito falla entera, así que esos se escriben en todas las filas. La misma convención escribe `(ghost)` donde un login pertenecía a una cuenta que se ha borrado desde entonces. Los paréntesis son lo que importa de la grafía. GitHub contesta `unknown` él mismo en el `relationship` de una alerta de Dependabot, y `none` es un valor de más de uno de sus enums, así que un relleno escrito de cualquiera de las dos maneras no se distinguiría de una respuesta; `(none)` no es nunca un valor que GitHub devuelva. Un valor sí lee `none` sin ellos y lo dice en serio: `gate` en `gh_commit` es el estado de la puerta del commit, y `none` es el estado de un commit sobre el que no corrió ninguna, junto a `SUCCESS` y `FAILURE`. **Un valor que cambia después de la fecha de la fila es un campo, nunca una etiqueta.** Una etiqueta forma parte de la identidad de la fila, así que una etiqueta que cambia a posteriori abre una segunda serie en el mismo instante y la fila vieja se queda junto a la nueva para siempre: medido tras once horas de pasadas, uno de cada cincuenta artefactos tenía una fila con `expired=false` y otra con `expired=true` en el mismo sello, y un commit visto `PENDING` por una pasada y `FAILURE` por la siguiente contaba dos veces. Todas esas etiquetas son campos ahora, con nombre nuevo porque InfluxDB 3 fija una columna como etiqueta o campo en la primera escritura: `gh_artifact.expired` es `live`, `gh_commit.checks` es `gate`, `state` en los dos ítems de alerta es `alert_state` y el `reason` de code scanning es `resolution`, `state_reason`, `assignee`, `milestone` y `parent` de `gh_issue` son `resolution`, `assigned_to`, `milestone_title` y `parent_issue`, `draft` y `review_decision` de `gh_pull_request` son `is_draft` y `decision`, `gh_discussion.answered` es `has_answer`, `gh_pull_request_review.state` es `review_state`, `gh_deployment.state` es `outcome` y `gh_notification.unread` es `is_unread`. `state` en una pull request, una issue y una contribución externa sigue siendo etiqueta porque su fecha se mueve con él: la fila abierta se sella al inicio del día y la cerrada cuando se cerró. El exportador de Prometheus vuelve a leer los valores degradados como `labels` de Prometheus; Graphite, que no guarda texto, no puede agrupar por ellos y sus paneles lo dicen. ### De una fila a una consulta Cada fila de aquí es una tabla en el almacén, sus etiquetas son columnas por las que filtrar y agrupar, y sus campos son los números. Leídos contra InfluxDB 3 en modo SQL, los tres fechados se convierten en tres formas de consulta. Una medida **fechada** es historia, así que se lee sobre un rango: ```sql SELECT time, "count" FROM gh_traffic WHERE kind = 'views' AND repo = 'telemetry' AND time > now() - INTERVAL '90 days' ``` Una instantánea **diaria** es una fila por día, así que la fila más reciente es la respuesta y la diferencia entre dos días es el movimiento: ```sql SELECT time, downloads FROM gh_release_asset WHERE asset = 'ghchronicle_linux_amd64.tar.gz' ORDER BY time DESC LIMIT 30 ``` Un **elemento fechado** lleva una fila por cada cosa que ocurrió, que es lo que permite preguntar por los elementos y no por una cuenta: ```sql SELECT date_trunc('week', time) AS week, count(*) AS merged, avg(seconds_to_merge) / 3600 AS hours FROM gh_pull_request WHERE state = 'MERGED' GROUP BY week ORDER BY week ``` Las tres formas valen en los demás almacenes de historia; los [dashboards](/ghchronicle/es/dashboards/) llevan un juego de consultas por almacén para cada panel, que es el sitio del que copiar. ### Todas las medidas, por orden alfabético Noventa y una, y cada enlace cae en la tabla en la que está. [`gh_account`](#cuenta) · [`gh_account_total`](#cuenta) · [`gh_achievement`](#cuenta) · [`gh_achievement_progress`](#cuenta) · [`gh_actions_cache`](#integración-continua) · [`gh_actions_cache_entry`](#integración-continua) · [`gh_actions_policy`](#seguridad) · [`gh_artifact`](#integración-continua) · [`gh_artifact_total`](#integración-continua) · [`gh_billing_usage`](#coste) · [`gh_branch`](#configuración-y-entrega) · [`gh_branch_protection`](#configuración-y-entrega) · [`gh_code_scanning_alert`](#seguridad) · [`gh_code_scanning_alert_item`](#seguridad) · [`gh_code_scanning_analysis`](#seguridad) · [`gh_code_scanning_setup`](#seguridad) · [`gh_commit`](#desarrollo) · [`gh_commit_check`](#desarrollo) · [`gh_commit_punchcard`](#cuenta) · [`gh_commits_week`](#cuenta) · [`gh_contribution_day`](#cuenta) · [`gh_contribution_day_repo`](#cuenta) · [`gh_contribution_repo`](#cuenta) · [`gh_contribution_year`](#cuenta) · [`gh_contributions_total`](#cuenta) · [`gh_dependabot_alert`](#seguridad) · [`gh_dependabot_alert_item`](#seguridad) · [`gh_dependabot_ecosystem`](#configuración-y-entrega) · [`gh_dependency`](#configuración-y-entrega) · [`gh_dependency_change`](#configuración-y-entrega) · [`gh_dependency_license`](#configuración-y-entrega) · [`gh_deploy_key`](#configuración-y-entrega) · [`gh_deployment`](#configuración-y-entrega) · [`gh_discussion`](#desarrollo) · [`gh_discussion_comment`](#cuenta) · [`gh_environment`](#configuración-y-entrega) · [`gh_event`](#actividad) · [`gh_external_contribution`](#desarrollo) · [`gh_fork`](#estrellas-y-forks) · [`gh_gist`](#cuenta) · [`gh_issue`](#desarrollo) · [`gh_issue_comment`](#cuenta) · [`gh_issue_event`](#desarrollo) · [`gh_job_log`](#logs-de-jobs) · [`gh_key`](#cuenta) · [`gh_label`](#desarrollo) · [`gh_milestone`](#desarrollo) · [`gh_notification`](#actividad) · [`gh_package`](#cuenta) · [`gh_package_version`](#cuenta) · [`gh_pinned_item`](#cuenta) · [`gh_policy_file`](#configuración-y-entrega) · [`gh_profile_flag`](#cuenta) · [`gh_pull_request`](#desarrollo) · [`gh_pull_request_review`](#desarrollo) · [`gh_rate_limit`](#configuración-y-entrega) · [`gh_release`](#repositorios) · [`gh_release_asset`](#repositorios) · [`gh_repo`](#repositorios) · [`gh_repo_activity`](#integración-continua) · [`gh_repo_archived`](#repositorios) · [`gh_repo_community`](#repositorios) · [`gh_repo_created`](#cuenta) · [`gh_repo_language`](#repositorios) · [`gh_repo_policy`](#configuración-y-entrega) · [`gh_repo_topic`](#repositorios) · [`gh_repo_total`](#configuración-y-entrega) · [`gh_review_thread`](#desarrollo) · [`gh_ruleset`](#configuración-y-entrega) · [`gh_ruleset_rule`](#configuración-y-entrega) · [`gh_ruleset_version`](#configuración-y-entrega) · [`gh_secret`](#seguridad) · [`gh_security_feature`](#seguridad) · [`gh_security_setting`](#seguridad) · [`gh_social_account`](#cuenta) · [`gh_sponsors_listing`](#cuenta) · [`gh_sponsors_tier`](#cuenta) · [`gh_sponsorship`](#cuenta) · [`gh_star`](#estrellas-y-forks) · [`gh_star_given`](#estrellas-y-forks) · [`gh_star_list`](#cuenta) · [`gh_traffic`](#audiencia) · [`gh_traffic_path`](#audiencia) · [`gh_traffic_referrer`](#audiencia) · [`gh_webhook`](#configuración-y-entrega) · [`gh_webhook_delivery`](#configuración-y-entrega) · [`gh_workflow`](#integración-continua) · [`gh_workflow_job`](#integración-continua) · [`gh_workflow_run`](#integración-continua) · [`gh_workflow_run_total`](#integración-continua) · [`gh_workflow_step`](#integración-continua) ## Audiencia | Medida | Fechado | Etiquetas | Campos | | --------------------- | ------------------------- | ---------------------- | ---------------------------------- | | `gh_traffic` | fechado, un punto por día | `kind` (views, clones) | `count`, `uniques`, `url` | | `gh_traffic_referrer` | diario | `referrer` | `count`, `uniques`, `url`, `referrer_url` | | `gh_traffic_path` | diario | `path` | `count`, `uniques`, `title`, `url` | GitHub sirve catorce días y la ventana entera se reescribe en cada pasada, así que un colector caído un día se repara solo en la siguiente ejecución. Los referrers y las rutas son los diez primeros de esa misma ventana sin fechas, y por eso son una instantánea y no una serie. ## Estrellas y forks | Medida | Fechado | Etiquetas | Campos | | --------------- | ---------------------------------- | -------------------------- | ------------------------------------------------------ | | `gh_star` | fechado, cuando se dio la estrella | `user` | `starred`, `url`, `user_url` | | `gh_star_given` | fechado | `user`, `repo`, `language` | `stars`, `repo_stars`, `url` | | `gh_fork` | fechado, cuando se creó el fork | `by` | `forks`, `stars`, `days_since_push`, `advanced`, `url` | `gh_star_given` es la dirección saliente: lo que esta cuenta marcó en repositorios ajenos. `advanced` en un fork separa un derivado real de un marcador, que es lo que son la mayoría de los forks. ## Repositorios | Medida | Fechado | Etiquetas | Campos | | ------------------- | ------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `gh_repo` | ahora | `language`, `visibility`, `license`, `archived`, `fork`, `default_branch` | `stars`, `forks`, `watchers`, `open_issues`, `size_kb`, `age_days`, `days_since_push`, `days_since_config_change`, `network`, `repo_id`, `is_template`, `has_pages`, `web_commit_signoff_required`, `allow_update_branch`, `pull_request_creation_policy`, `url` | | `gh_repo_language` | ahora | `language` | `bytes` | | `gh_repo_topic` | ahora | `topic` | `present`, `url` | | `gh_repo_community` | ahora | | `health_percentage`, `url`, `has_readme`, `has_license`, `has_contributing`, `has_code_of_conduct`, `has_issue_template`, `has_pull_request_template` | | `gh_repo_archived` | fechado, al archivarse el repositorio | | `archived`, `age_days_at_archive`, `url` | | `gh_release` | ahora | `tag`, `draft`, `prerelease` | `downloads`, `assets`, `age_days`, `url` | | `gh_release_asset` | diario | `tag`, `asset` | `downloads`, `size_bytes`, `digest`, `content_type`, `uploader`, `age_days`, `url` | `open_issues` es el campo de GitHub y GitHub cuenta las pull requests dentro. Usa `gh_issue` para contar issues. La `url` de `gh_release_asset` es la dirección de descarga del binario, no una página: seguirla baja el fichero. Los assets son inventario, anclados al inicio del día UTC como las entradas de la caché: sellados en la pasada, cada asset era una fila nueva cada hora, que era el 15 por ciento de toda la base tras once horas. Una fila por asset y día sigue respondiendo a "descargas por día", y la fila más nueva sigue siendo el valor. `gh_repo_archived` es la única fila sobre un repositorio que lleva una fecha en vez de un estado: fechada en `archivedAt`, una limpieza se ve como la tanda que fue, y un repositorio vivo no produce fila alguna, así que contar las filas es contar el archivo. No necesita `include_archived`. El listado que una pasada ya paga dice qué repositorios están archivados, y la familia `totals` pregunta la fecha de todos ellos en una consulta GraphQL de cuatro escalares por repositorio, en cada pasada de `totals`: un punto a esa cadencia, y las filas que reescribe son las mismas filas, que es lo que necesita un exportador que solo guarda lo que se reescribe; el listado no puede dar la fecha por sí mismo, porque REST no lleva `archived_at` y su `updated_at` se midió entre dos segundos y ocho minutos después del archivado. Un fork archivado bajo la regla de forks por omisión es el único tipo sin fila. ## Desarrollo | Medida | Fechado | Etiquetas | Campos | | -------------------------- | ------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gh_pull_request` | fechado al cerrarse, diario mientras está abierto | `number`, `state`, `author` | `is_draft`, `decision`, `title`, `labels`, `label_names`, `author_association`, `additions`, `deletions`, `churn`, `changed_files`, `commits`, `comments`, `total_comments`, `reviews`, `review_requests`, `review_threads`, `base_ref`, `head_ref`, `merged_by`, `merge_commit`, `mergeable`, `merge_state`, `stack`, `stack_size`, `stack_position`, `seconds_to_first_review`, `seconds_to_first_human_review`, `seconds_to_merge`, `seconds_open`, `url` | | `gh_pull_request_review` | fechado, al enviarse | `number`, `author`, `reviewer`, `bot`, `self` | `review_state`, `reviews`, `seconds_to_review`, `url` | | `gh_issue` | fechado al cerrarse, diario mientras está abierto | `number`, `state`, `author` | `resolution`, `assigned_to`, `milestone_title`, `parent_issue`, `comments`, `reactions`, `labels`, `label_names`, `sub_issues_total`, `sub_issues_completed`, `pull_request`, `seconds_to_close`, `seconds_open`, `url` | | `gh_commit` | fechado, cuando se hizo el commit | `sha`, `author`, `branch`, `signature` | `gate`, `additions`, `deletions`, `churn`, `changed_files`, `commits`, `signed`, `oid`, `headline`, `url`, `pull_request`, `checks_total`, `checks_failed` | | `gh_commit_check` | fechado, al terminar la comprobación | `sha`, `app`, `check`, `conclusion` | `checks`, `failed`, `url` | | `gh_issue_event` | fechado, cuando ocurrió | `event`, `actor`, `kind`, `bot`, `label`, `milestone`, `requested_reviewer`, `review_requester`, `mentioned` | `events`, `number`, `title`, `url`, `commit_id`, `rename_from`, `rename_to` | | `gh_review_thread` | fechado, al escribirse el primer comentario del hilo | `thread`, `number`, `author`, `bot` | `path`, `comments`, `resolved`, `outdated`, `subject_type`, `resolved_by` | | `gh_discussion` | fechado, al crearse | `category`, `answerable`, `author`, `number` | `has_answer`, `comments`, `replies`, `reactions`, `upvotes`, `closed`, `state_reason`, `seconds_to_answer`, `seconds_to_close`, `title`, `url` | | `gh_label` | diario | `label` | `issues`, `pull_requests`, `used`, `url` | | `gh_milestone` | diario | `milestone`, `state` | `progress`, `issues`, `pull_requests`, `days_to_due`, `seconds_to_close`, `url` | | `gh_external_contribution` | fechado | `user`, `repo`, `number`, `kind`, `state` | `contributions`, `merged`, `title`, `comments`, `seconds_to_merge`, `seconds_open`, `url` | `gh_commit` es lo que sustituye a `stats/code_frequency`, que devuelve 202 con cuerpo vacío para siempre en una cuenta personal. `signature` vale `unsigned` cuando no hay firma alguna, que es un hecho distinto de una que no se pudo verificar. `gate` es el estado de toda la puerta sobre ese commit, que no es lo mismo que decir que falló una ejecución de workflow: una ejecución dice que falló un job, el resumen dice que el commit salió en rojo. Es un campo porque el veredicto llega después de la fecha del propio commit. `gh_commit_check` guarda solo las comprobaciones que no son de GitHub Actions, porque todo lo que corre Actions ya se recoge con mucho más detalle. `seconds_to_first_review` cuenta cualquier revisión, y en una cuenta con bots de revisión esa es la del bot: medido sobre 140 pull requests, su mediana era de cinco segundos, porque 132 recibieron la primera revisión de sourcery-ai o coderabbitai en el minuto siguiente a abrirse. `seconds_to_first_human_review` es la espera hasta otra persona, sobre las veinte primeras revisiones que trae la consulta: ni un bot, ni el autor. La respuesta del autor en un hilo de revisión llega como una revisión en estado `COMMENTED` con su propio nombre, y en esta cuenta era la primera revisión no automática en cada una de las 91 pull requests que tenían alguna, así que una espera que la contara medía lo rápido que el propietario contesta a sourcery-ai. Una pull request cuyas revisiones traídas son todas de bots y respuestas propias no lleva el campo, en vez de llevar uno falso. Un bot es una GitHub App (`__typename` Bot) o un login que termina en `[bot]`; una cuenta borrada no lo es. `gh_pull_request_review.bot` traza la misma línea revisión a revisión y `self` marca las del propio autor, para que una tabla de revisores pueda dejar fuera a ambos o mostrarlos aparte; `bot` es lo que ya hace `gh_review_thread.bot` con los hilos. `title`, `label_names` y `author_association` son campos porque un título no está acotado y nueve etiquetas en una pull request son una fila, no nueve series. `labels` es el recuento y `label_names` los nombres unidos por comas, ausente cuando no hay ninguna, tanto en pull requests como en issues. `author_association` es `OWNER`, `MEMBER`, `COLLABORATOR`, `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR` o `NONE`: lo que separa una contribución externa del trabajo del propio dueño. `mergeable` y `merge_state` solo se escriben mientras una pull request está abierta. Una ya fusionada sigue respondiendo `CONFLICTING` mucho después de fusionarse, que es rancio y no falso, pero se lee como un repositorio lleno de conflictos. Una pasada solo lee lo que se actualizó en las dos últimas cadencias, así que una pull request abierta que nadie toca ve reescritos sus `seconds_open`, `mergeable` y `merge_state` una vez al día, en la lectura de página entera, en vez de cada hora; todo lo que mueve `updatedAt`, una revisión, un comentario, un push, un cierre, lo reescribe la pasada siguiente. `stack`, `stack_size` y `stack_position` describen una pila de pull requests dependientes y no están en una pull request que no está en ninguna. `stack` es el número de la pila, no el de un miembro, así que la forma honesta de contar entregas es contar los valores distintos de `stack` más las filas que no llevan ningún campo de pila. `review_requests` y `review_threads` son las dos cuentas con las que se mide un flujo de revisión, y `total_comments` cuenta todos los comentarios de la pull request, no los de `comments`, que son los de la conversación. `sub_issues_total` y `sub_issues_completed` son hasta dónde ha llegado un épico, según la lista que GitHub mantiene en el padre. `parent_issue` es el otro extremo de esa misma relación, en el hijo, y vale `0` en una issue sin padre. `pull_request` es la pull request que cerró la issue, `0` cuando no la cerró ninguna. `gh_issue_event` es la transición, no el estado. `gh_issue` y `gh_pull_request` dicen en qué acabó algo; esto dice cuándo se etiquetó, se cerró, se reabrió, se renombró o se pidió revisión. Una reapertura no existe en ningún otro sitio. `mentioned` es la persona a la que le ocurrió un evento `mentioned` o `subscribed`, que GitHub registra como actor sin decir quién escribió el comentario; aquí esa persona tiene su propia etiqueta y `actor` vale `(none)` en esos dos tipos, así que la cuenta nombrada en "@coderabbitai" ya no comparte columna con la app que revisa. Una app se escribe como la escribe REST, con el sufijo `[bot]`, en todas las medidas. `gh_discussion` cuenta aparte comentarios y respuestas: los comentarios responden a la discusión, las respuestas responden a esos, y el número que GitHub enseña en la página es la suma de los dos. ## Integración continua | Medida | Fechado | Etiquetas | Campos | | ------------------------ | -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gh_workflow_run` | fechado, al terminar | `workflow` (la ruta del fichero), `event`, `conclusion`, `actor` | `duration_seconds`, `queued_seconds`, `attempt`, `success`, `run_id`, `run_number`, `pull_request`, `pull_requests`, `headline`, `head_repo`, `head_sha`, `head_branch`, `name`, `title`, `workflow_id`, `initial_actor`, `url` | | `gh_workflow_run_total` | ahora | | `runs` | | `gh_workflow_job` | fechado, al terminar | `workflow`, `job_name`, `attempt`, `conclusion`, `runner_group`, `labels` | `duration_seconds`, `queued_seconds`, `steps`, `success`, `runner`, `run_id`, `head_sha`, `head_branch`, `url` | | `gh_workflow_step` | fechado, al terminar | `workflow`, `job_name`, `attempt`, `step`, `conclusion` | `duration_seconds`, `step_number` | | `gh_workflow` | ahora | `workflow`, `path`, `state` | `active`, `age_days`, `days_since_change`, `url` | | `gh_artifact` | fechado, al crearse | `artifact` | `live`, `size_bytes`, `retention_days`, `digest`, `run_id`, `head_sha`, `head_branch`, `url` | | `gh_artifact_total` | ahora | | `live_bytes`, `count`, `walked` | | `gh_actions_cache` | ahora | | `size_bytes`, `count` | | `gh_actions_cache_entry` | diario | `cache`, `ref` | `size_bytes`, `caches`, `key`, `days_since_use`, `age_days` | | `gh_repo_activity` | fechado | `activity`, `actor` | `events`, `id`, `ref_name` | > **La etiqueta workflow cambió de significado** > > `workflow` llevaba el nombre de la ejecución y ahora lleva la ruta del fichero > del workflow. GitHub reescribe el nombre de todo lo que es dinámico, así que > una ejecución de Dependabot se llama como la subida de versión que hizo y una > de code scanning como la pull request que la disparó: medido sobre trescientas > ejecuciones de un repositorio, el nombre tomó treinta y cinco valores frente a > siete rutas, es decir treinta y cinco series para siete workflows. El nombre > legible no se pierde, es el campo `name`, y `gh_workflow` asocia esa misma > ruta a ese mismo nombre, así que un panel une `gh_workflow.path` con > `gh_workflow_run.workflow` para volver a escribir "CodeQL". Nada se fusiona a > través del cambio: cada serie tiene una identidad nueva, así que las filas > escritas antes quedan al lado de las nuevas en vez de continuarlas. `branch` era una etiqueta en `gh_workflow_run` y en `gh_artifact`, y ahora es el campo `head_branch` en ambas, y también en `gh_workflow_job`. Una rama es una identidad, pero no reutilizable: cada pull request y cada subida de Dependabot acuña un nombre que no vuelve, así que la etiqueta crece sin límite, y la pregunta acotada que de verdad hace un lector, si esto fue un push o un pull request, ya es la etiqueta `event`. Además InfluxDB 3 fija una columna como etiqueta o como campo la primera vez que la ve y rechaza toda escritura posterior que la contradiga, así que conservar el nombre habría obligado a tirar las dos tablas para publicar un valor que la propia API llama `head_branch`. `attempt` es etiqueta en el job y en el step porque el listado de jobs se pide ahora para todos los intentos y no solo para el último. Sin ella los dos intentos de una reejecución son una sola serie, distinguibles solo por el segundo en que terminaron, y un test inestable no se puede separar de uno roto. `queued_seconds` en una ejecución se escribe solo en los primeros intentos. GitHub conserva el `created_at` de la ejecución entre reintentos, así que en un segundo intento la distancia hasta `run_started_at` es el tiempo que tardó una persona en pulsar el botón, no una cola de runners: 0,4 s de media en primeros intentos frente a 1.747 s en segundos, medido. La cola de un reintento existe solo por job. `run_number` es el "#1483" que GitHub muestra y la gente cita; `run_id` es la clave de la API. `pull_request` es el número de la primera pull request que GitHub enlazó a la ejecución y `pull_requests` cuántas enlazó, ambos ausentes cuando no enlazó ninguna. `headline` es la primera línea del commit que corrió, y `head_repo` se escribe solo cuando la ejecución vino de otro repositorio, que es el aspecto que tiene la pull request de un fork. `gh_workflow_run_total` es el propio `total_count` del listado de ejecuciones, que es la historia entera y no los pocos cientos de ejecuciones que ve el recorrido. Es estado actual, así que se sella ahora, y es el único sitio donde se puede responder "cuántas ejecuciones ha habido en total" sin recorrer la tabla. Una pasada ordinaria pide la lista de ejecuciones en páginas de treinta y no de cien. La página son trece kilobytes por ejecución, de los que el colector conserva seiscientos bytes, y a cien ejecuciones era un mega y medio por repositorio activo cada cuarto de hora, el cuarenta y seis por ciento de todo lo que se descarga en un día. Los almacenes no pierden nada: el recorrido sigue pasando de página mientras una página venga llena de ejecuciones más nuevas que la ventana, hasta siete páginas, que son las doscientas diez ejecuciones a las que llegaban dos páginas de cien, y la primera pasada tras el arranque y un backfill siguen pidiendo cien. Lo que cambia es el exportador de Prometheus, que solo tiene lo que recogió la última pasada y muestra las treinta ejecuciones más nuevas entre builds en vez de las cien. Los jobs de una ejecución se listan una vez. Los jobs de un intento terminado no cambian nunca, y volver a listarlos en cada pasada era una petición por ejecución en la ventana, casi todas 304 que no cuestan cuota pero sí un tercio de segundo de espera cada una, noventa y seis veces al día. El colector recuerda cada intento cuyos jobs escribió, en memoria como la caché de ETag, así que tras un reinicio la primera pasada lista las veinte más nuevas de cada repositorio una vez y después solo pregunta por ejecuciones e intentos nuevos; un backfill las lista todas igualmente. Una reejecución conserva el id de la ejecución y es un intento nuevo, así que se lista otra vez. El tope de veinte acota lo que paga una pasada, no qué ejecuciones reciben jobs: una ventana con más ejecuciones que eso se completa a veinte por pasada. Una ejecución se recuerda solo cuando la pasada que la listó ha terminado bien, porque el runner no conserva nada de un colector que falló a medias. `retention_days` es la retención que un artefacto tuvo de verdad, que casi nunca es la configurada por omisión: ochenta y ocho de cada cien artefactos medidos vivían un día frente a un ajuste de noventa. `gh_repo_activity` es una fila por tipo de actividad, actor y segundo. La rama es el campo `ref_name`, un nombre por pull request y por bump de Dependabot, y sin ella en la clave las ramas que un push movió en el mismo segundo serían una sola fila en todos los almacenes, con la última escrita valiendo por todas: ocho force pushes en un segundo, medidos. Las entradas que comparten clave se pliegan en un punto: `events` las cuenta, `ref_name` nombra todas las ramas separadas por comas, `id` es el de la entrada más reciente, y así una suma de `events` es el número de actividades en cualquier almacén. El tiempo en cola solo existe a nivel de job. La cifra a nivel de ejecución mete la espera dentro de la duración, y la de nivel de job incluye la espera por una dependencia, así que un job que espera diecinueve minutos a que termine otro no es prueba de falta de runners. `runner` es un campo, no una etiqueta: un runner alojado se nombra de forma única en cada ejecución, así que como etiqueta crearía una serie por cada job ejecutado alguna vez. La etiqueta del job de workflow es `job_name` y no `job`, porque `job` choca con las etiquetas que Prometheus añade en el scrape. Cuando `walked` es menor que `count`, el tamaño vivo es un suelo y el repositorio tiene más artefactos de los que alcanzó el tope de páginas. `gh_actions_cache` dice que un repositorio ocupa doce gigabytes; `gh_actions_cache_entry` dice qué clave los ocupa y cuál lleva una semana sin tocarse, que es lo que decide qué desaloja GitHub al llegar al techo de diez gigabytes. La etiqueta es la clave sin su hash de contenido, porque la clave entera es una serie por build. ## Seguridad | Medida | Fechado | Etiquetas | Campos | | ----------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `gh_dependabot_alert` | ahora | `severity`, `ecosystem` | `open`, `url` | | `gh_dependabot_alert_item` | fechado, al abrirse | `number`, `severity`, `ecosystem`, `package`, `ghsa`, `scope`, `relationship`, `manifest` | `alert_state`, `alerts`, `cvss`, `cvss_v4`, `epss`, `epss_percentile`, `cve`, `cwe`, `summary`, `vulnerable_range`, `first_patched`, `dismissed_reason`, `dismissed_by`, `dismissed_comment`, `seconds_to_detect`, `seconds_to_resolve`, `seconds_open`, `url` | | `gh_code_scanning_alert` | ahora | `severity`, `tool` | `open`, `url` | | `gh_code_scanning_alert_item` | fechado, al abrirse | `number`, `severity`, `tool`, `rule`, `path`, `category`, `ref` | `alert_state`, `resolution`, `alerts`, `commit`, `line`, `cwe`, `seconds_to_resolve`, `seconds_open`, `url` | | `gh_code_scanning_analysis` | fechado, cuando corrió el escaneo | `tool`, `version`, `ref`, `category` | `analyses`, `results`, `rules`, `commit` | | `gh_security_feature` | ahora | `feature` | `enabled`, `open_alerts`, `alerts`, `url` | | `gh_security_setting` | ahora | `setting`, `status` | `enabled` | | `gh_code_scanning_setup` | diario | `state`, `query_suite`, `schedule` | `setups`, `languages`, `days_since_change` | | `gh_secret` | diario | `kind` (actions, dependabot), `secret` | `secrets`, `age_days`, `days_since_rotation` | | `gh_actions_policy` | diario | `permissions` | `policies`, `can_approve_pr` | Las etiquetas de las dos medidas de ítem no cuestan nada: los dos listados ya las traían, y la serie siempre estuvo indexada por `number`, así que agrupan filas que ya existen una por alerta en vez de multiplicarlas. Todas se escriben siempre, cayendo a `(none)` cuando GitHub omite alguna. Una etiqueta que solo se escribe a veces le da a la medida dos profundidades de ruta en Graphite, y los paneles indexan sus nodos desde una única tabla fija. `alert_state` en los dos ítems y `resolution` en el de code scanning son campos, porque una alerta se fecha al abrirse y los dos cambian al cerrarse; `resolution` vale `open` hasta entonces, para que la columna exista antes de que se cierre ninguna alerta. Los campos son lo contrario: `cve`, `cwe`, `first_patched`, `epss`, `epss_percentile` y `seconds_to_detect` se escriben solo cuando el aviso los trae, porque una puntuación EPSS que falta no es una puntuación de cero. `cvss` y `cvss_v4` necesitan la misma guarda por otro motivo: GitHub manda siempre las dos claves y rellena con `0.0` la que no tiene, y un aviso publicado solo con vector v4 eran 78 de las 225 alertas de un repositorio, suficiente para que el "peor CVSS" de un grupo de severidad formado por ellas leyera cero. Ninguna de las dos puntuaciones se escribe si no es mayor que cero; un panel que quiera un número por alerta lee `COALESCE(cvss_v4, cvss)`. `summary` es el título del aviso, `vulnerable_range` el rango que cubre, que junto a `first_patched` es la acción a tomar, y `dismissed_reason`, `dismissed_by` y `dismissed_comment` dicen por qué una persona cerró una alerta sin arreglarla; existen solo en una alerta en estado `dismissed`. `seconds_to_detect` es la distancia entre la publicación del aviso y el momento en que la alerta se abrió aquí. Es negativo cuando la alerta llegó primero, que es lo que pasa cuando un aviso se redacta a posteriori. Los dos campos `cwe` no se pueden unir. Dependabot escribe `CWE-400` y code scanning escribe `cwe-079`, ambas grafías del propio GitHub, y aquí no se normaliza ninguna. `line` es la línea de inicio de la instancia más reciente de la alerta, y su cero es el valor que da GitHub para una alerta sobre un fichero entero, no una lectura que falte. Una alerta de Dependabot se cierra de tres maneras, no de dos: `auto_dismissed_at` es como GitHub cierra por su cuenta la alerta de una dependencia de desarrollo, dejando las otras dos a nulo. Una alerta cerrada así hacía crecer `seconds_open` para siempre. `gh_security_feature` existe para que "sin datos" y "sin alertas" sean distinguibles. Sin ella, un repositorio con Dependabot apagado es idéntico a uno sin nada que arreglar. `enabled` se lee de la primera página completa del listado, que responde 403 cuando la función está apagada y, en code scanning, 404 cuando todavía no se ha analizado nada. `alerts` cuenta lo que leyó la pasada, que es una página, o sea cien como mucho, y no el total del repositorio. ## Cuenta | Medida | Fechado | Etiquetas | Campos | | ------------------------ | ---------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gh_account` | ahora | | `followers`, `following`, `following_users`, `public_repos`, `gists`, `packages`, `projects`, `starred`, `watching`, `sponsors`, `sponsoring`, `account_age_days`, `pronouns`, `url` | | `gh_contributions_total` | ahora | | `calendar_total`, `commits`, `pull_requests`, `reviews`, `issues`, `repositories`, `restricted`, `repos_with_commits`, `repos_with_issues`, `repos_with_pulls`, `repos_with_reviews`, `url` | | `gh_contribution_day` | fechado, un punto por día del calendario | | `contributions`, `level`, `url` | | `gh_contribution_year` | fechado, fin del año; el año en curso a diario | `year` | `contributions`, `commits`, `issues`, `pull_requests`, `reviews`, `repositories`, `restricted`, `repos_with_commits`, `repos_with_issues`, `repos_with_pulls`, `repos_with_reviews`, `partial` | | `gh_contribution_repo` | ahora | `repo`, `kind` (commits, issues, pulls, reviews) | `contributions`, `commits`, `days`, `commits_dated`, `url` | | `gh_contribution_day_repo` | fechado, el día al que pertenecen los commits | `repo`, `private`, `own` | `commits`, `url` | | `gh_commits_week` | fechado, el domingo de su semana | | `commits`, `owner_commits` | | `gh_commit_punchcard` | ahora | `weekday`, `hour` | `commits` | | `gh_package` | ahora | `package`, `type`, `visibility`, `repo` | `versions`, `tagged_versions`, `age_days`, `days_since_update`, `url` | | `gh_package_version` | fechado, al publicarse | `package`, `type`, `visibility`, `repo`, `tag` | `digest`, `published`, `url` | | `gh_gist` | ahora | `gist`, `public` | `files`, `comments`, `size_bytes`, `description`, `url`, `age_days`, `days_since_update` | | `gh_achievement` | diario | `achievement` | `name`, `tier_number`, `tier_name`, `present`, `image`, `url` | | `gh_achievement_progress` | diario | `achievement` | `name`, `count`, `tier_number`, `next_threshold`, `percent`, `page_tier`, `agrees`, `image`, `url` | | `gh_social_account` | ahora; la fila `orcid` a diario | `provider` | `url`, `present` | | `gh_pinned_item` | ahora | `repo` | `pinned`, `position`, `kind`, `stars`, `days_since_push`, `url` | | `gh_profile_flag` | ahora | `flag` | `enabled`, `message`, `age_days`, `url` | | `gh_sponsorship` | fechado, cuando se hizo el patrocinio | `direction` (sponsor, maintainer), `sponsorable` | `sponsorship`, `active`, `one_time`, `privacy`, `tier`, `amount_cents`, `url` | | `gh_sponsors_listing` | ahora | | `has_listing`, `listing_name`, `listing_public`, `listing_age_days`, `tiers`, `monthly_income_cents`, `next_payout_cents`, `next_payout_date`, `sponsor_spend_cents`, `lifetime_received_cents`, `sponsorships_received`, `goal_kind`, `goal_title`, `goal_target`, `goal_percent`, `url` | | `gh_sponsors_tier` | diario | `tier` | `tiers`, `price_cents`, `one_time`, `retired`, `age_days`, `url` | | `gh_star_list` | diario | `list` | `lists`, `items`, `private`, `name`, `age_days`, `days_since_add`, `url` | | `gh_account_total` | ahora | | `pulls_opened`, `pulls_merged`, `pulls_open_now`, `pulls_merged_elsewhere`, `pulls_reviewed`, `issues_opened`, `issues_closed`, `issues_elsewhere`, `commented_elsewhere`, `commits`, `repositories`, `url` | | `gh_repo_created` | fechado, al crearse | `repo`, `fork` | `created`, `private`, `url` | | `gh_key` | diario | `kind` (ssh, gpg), `key` | `keys`, `age_days`, `days_since_use`, `never_used`, `days_to_expiry`, `verified`, `revoked`, `can_sign`, `emails`, `url` | | `gh_discussion_comment` | fechado | `repo`, `own`, `is_answer`, `is_reply`, `author`, `comment`, `number` | `comments`, `answers`, `upvotes`, `title`, `reply_to`, `discussion_answered`, `discussion_answerable`, `discussion_closed`, `answered_by`, `answer_chosen_by`, `state_reason`, `category`, `seconds_to_answer`, `seconds_to_close`, `url` | | `gh_issue_comment` | fechado | `repo`, `own`, `number` | `comments`, `url` | `following` es el número del propio perfil, y cuenta organizaciones además de personas. La conexión `following` de GraphQL cuenta solo usuarios, que en esta cuenta daba cuatro donde el perfil daba nueve, así que más de la mitad era invisible. Se guardan las dos: `following` es lo que enseña la página del perfil, `following_users` es el recuento de personas de la conexión. Cuando la petición del perfil falla, el recuento de la conexión rellena las dos, que es la señal de que no se llegó al perfil. `versions` en un paquete es el recuento que declara el propio elemento del paquete, con el recorrido como respaldo. `tagged_versions` cuenta solo publicaciones con nombre. Antes contaba cada tag de contenedor, y cerca de la mitad de esos son el tag de respaldo de referrers OCI que GitHub publica por cada manifiesto de atestación y de firma: `sha256-` seguido del digest que la fila ya lleva. Nadie se descarga uno, hay uno nuevo en cada build, y excluirlos redujo a la mitad el recuento en dos paquetes, de ciento veintiséis a cincuenta y siete en uno. `gh_package_version` tampoco escribe ya una fila para ellos. `gh_pinned_item` y `gh_profile_flag` son la página del perfil convertida en datos. Un elemento fijado no tiene fecha propia, así que ambas se sellan ahora. `position` es un campo y no una etiqueta: un repositorio que pasa del hueco dos al tres es el mismo elemento fijado, y como etiqueta cada reordenación bifurcaría la serie. `flag` es una lista cerrada de ocho: `hireable`, `developer_program`, `campus_expert`, `github_star`, `bounty_hunter`, `employee`, `sponsors_listing`, que es si la cuenta tiene perfil de Sponsors, y `limited_availability`, que es el estado de disponibilidad llevado como octava marca en vez de como medida propia, con el `message` que muestra y los `age_days` desde que se puso ese estado. `age_days` se escribe solo en esa fila y es la edad del mensaje de estado, no de la marca; GitHub no dice cuándo se concedió ninguna de las otras, así que ninguna fila lleva fecha para ellas. `gh_sponsorship` es el único registro fechado del dinero. `gh_account.sponsors` y `gh_account.sponsoring` son recuentos de ahora que no dicen ni cuándo ni a quién, y `gh_sponsors_listing.lifetime_received_cents` es un total sin fechas dentro. Las dos conexiones se leen con `activeOnly` apagado, que es lo que recupera un patrocinio caducado. `sponsorable` es la otra parte, y es literalmente la palabra `private` cuando el patrocinio la oculta, en cuyo caso no se escribe URL en vez de inventarse una. `gh_sponsors_tier` es inventario estable, igual que una clave SSH. Fechar un nivel en su creación pondría los ocho en 2021, fuera de todo rango de dashboard, donde se leerían como "sin niveles"; anclados al inicio del día UTC convergen en una fila por nivel y día, y `age_days` mantiene recuperable la fecha de creación. `gh_star_list` tiene la misma forma por la misma razón: las listas en las que la cuenta archiva las estrellas que da, una fila por lista con cuántas guarda, anclada al inicio del día UTC. Una lista lleva dos fechas, cuándo se creó y cuándo entró la última estrella, y las dos sobreviven como `age_days` y `days_since_add` en vez de fechar la fila, que pondría una lista creada en 2024 fuera de todo rango de dashboard. La etiqueta es el slug, que es lo que direcciona la página de la lista; el nombre visible es un campo. Si el slug sobrevive a un cambio de nombre no está verificado, porque comprobarlo exige renombrar una lista. Viaja en la consulta de cuenta que ya se pagaba: medido el 2026-09-11, once listas con sus recuentos no añadieron nada a un coste de uno. `gh_contribution_day` es el único sitio donde los cuadrados verdes existen como datos. Con `every.history` puesto, llega hasta el año en que se creó la cuenta, a un punto de GraphQL por año. Su `level` es el tono del cuadrado, el cuartil del año que calcula el propio GitHub como el 0 a 4 que dibuja el perfil, y no es función del recuento: en una cuenta, 83 contribuciones un día y 52 otro fueron ambas el segundo cuartil. El cuartil es el de la ventana que se pidió, los últimos doce meses en la pasada y el año natural en `history`, así que un día que escriben ambas puede cambiar de tono entre una y otra, como en el perfil al elegir un año. Por eso la rejilla del dashboard tiñe cada día por su propio recuento: medido el 2026-09-14, la página del perfil usa los quintos del día más activo de la ventana, una regla que reprodujo los 366 cuadrados a partir de los recuentos del propio GitHub, mientras que `level` discrepaba de la página en 33 de esos días. `gh_achievement` es la única medida que no sale de la API. GitHub no lista los logros en REST ni en GraphQL, así que la familia lee la página pública del perfil, `https://github.com/?tab=achievements`, una vez al día como visitante anónimo: ningún token viaja a ella y no se carga a ningún presupuesto. Una fila por insignia, fechada al inicio del día UTC: `name` es la insignia, `tier_number` el número de su etiqueta (1 sin etiqueta, 2 a 4 para x2 a x4) y `tier_name` el color que le corresponde (default, bronze, silver, gold); el número no se llama `tier` porque Elasticsearch asigna un tipo a cada nombre de campo una sola vez para los índices de todas las mediciones y `tier` ya es una cadena en los patrocinios. El analizador es estricto con el marcado que acepta y contrasta cada parte de una tarjeta con las demás, así que cuando GitHub cambie la página la familia avisa una vez y no escribe nada hasta que se actualice el analizador; las filas anteriores se conservan, y un panel que lea la fila más reciente por insignia se queda obsoleto en vez de equivocado. `image` es la imagen de la insignia que muestra la página en ese nivel, para que un panel la dibuje. El sitio del que se lee la página se deriva de `github.base_url`. Un `base_url` que sea un proxy delante de la API tiene que nombrar el sitio con [`github.web_url`](/ghchronicle/es/configuration/#github), porque el host de la API contesta la url de la página con un 404 en JSON, y la familia lo rechaza en vez de leerlo como que no hay insignias. Una cuenta sin ninguna insignia no tiene pestaña de logros: su url contesta 404 mientras el perfil contesta 200, lo que son cero filas y ningún aviso, la misma lectura que hace cualquier familia de un 404. Un 200 sin ninguna tarjeta se rechaza como página cambiada en vez de leerse como que no hay ninguna. `gh_achievement_progress` la escribe la misma familia junto a las insignias: una fila por insignia con niveles (Pull Shark, Galaxy Brain, Starstruck, Pair Extraordinaire), la muestre la página o todavía no, que dice cuánto le falta a la cuenta para el siguiente nivel. GitHub no publica ni la regla con la que se gana una insignia ni el recuento alcanzado, así que el recuento se recalcula desde la API y los umbrales son los de la comunidad, la tabla Tiers de [Schweinepriester/github-profile-achievements](https://github.com/Schweinepriester/github-profile-achievements) tal y como se leyó el 2026-09-12: Pull Shark cuenta pull requests fusionadas en cualquier sitio y sus niveles empiezan en 2, 16, 128 y 1024; Galaxy Brain cuenta las discusiones cuya respuesta aceptada escribió la cuenta, en 2, 8, 16 y 32; Starstruck toma las estrellas del repositorio propio con más estrellas, sin los forks, en 16, 128, 512 y 4096; Pair Extraordinaire cuenta pull requests fusionadas en repositorios públicos con un commit coautorizado, una por pull request, en 1, 10, 24 y 48, contrastado ese mismo día con un recuento a mano (los dos recuentos diferían en dos, y la diferencia caía en un rango donde el recuento a mano pasaba de los mil resultados que una búsqueda pagina, y la página mostraba el mismo nivel en ambos casos; las pull requests coautorizadas de un repositorio privado no movieron nada). Las insignias de un solo nivel y las dos que GitHub aún prueba no tienen fila: no hay siguiente nivel contra el que medir. `tier_number` es el nivel que implica el recuento (0 por debajo del primer umbral), `page_tier` el nivel que muestra la página del perfil (0 cuando la insignia no está en ella) y `agrees` si los dos coinciden; cuando coinciden, `next_threshold` es dónde empieza el siguiente nivel (0 en el máximo) y `percent` el recuento frente a él (100 en el máximo). Una fila que no coincide es una regla que la página contradice, o una página que GitHub aún no ha recalculado, avisada una vez por proceso en el registro, y no lleva ninguno de los dos campos, así que no se dibuja ninguna barra a partir de una regla que la página contradice. Tres de los recuentos son una consulta GraphQL; el cuarto es un recorrido por las pull requests fusionadas de la cuenta en repositorios públicos con los mensajes de sus commits, partido por fecha de fusión donde un rango supera los mil resultados que una búsqueda pagina, un punto por página: unas pocas docenas de puntos y alrededor de un minuto al día sobre la vida entera de una cuenta. Un recuento que la API no dé es un día sin filas de progreso, nunca un día sin insignias. `gh_social_account` lleva una fila más que el listado de cuentas sociales: la página personal, bajo el proveedor `website`, del `blog` del perfil. El ORCID iD que muestra la página del perfil no está en ningún endpoint, así que la familia `achievements`, que ya lee esa página una vez al día, lo escribe desde la vcard de la página bajo el proveedor `orcid`, sellado al comienzo del día UTC como las insignias. Los enlaces que la API sí lista (Mastodon, LinkedIn, Bluesky) los escribe la familia `profile` desde la API y en la página se saltan, así que ninguna cuenta se escribe dos veces. La lectura es tan estricta como la de las insignias: una página sin la vcard es una página cambiada y un aviso, nunca "sin cuentas". `pronouns` es la línea de pronombres del perfil, `he/him`, como campo en la fila de cabecera, ausente cuando el perfil no muestra ninguna. Campo y no etiqueta: es texto libre que el propietario puede editar, y como etiqueta cada edición bifurcaría la única serie de la cuenta. `gh_issue_comment` y `gh_discussion_comment` se leen desde el extremo más reciente de sus conexiones, que listan de más antiguo a más nuevo: la única página de una pasada son los cien comentarios más recientes, y un comentario que pasó a ser la respuesta aceptada después de escribirse se vuelve a ver en la siguiente pasada. `gh_contribution_year` tiene una fila por año pasado, fechada el treinta y uno de diciembre, y una por el año en curso, que se pide en cada ejecución desde el uno de enero hasta ahora. Esa fila es una instantánea, sellada al inicio del día UTC y marcada `partial`, para que un panel que compare años distinga una barra que aún crece de una terminada; se lee como la fila más reciente por `year`. La primera ejecución del año siguiente la sustituye por la fila final fechada en diciembre. `gh_account.packages` se cuenta con los listados REST que recorre la familia profile, no con la conexión `packages` de GraphQL, que no ve el registro de contenedores y respondía 0 para una cuenta cuyos cuatro paquetes son todos contenedores. Si un listado falla, se mantiene el recuento de GraphQL. `gh_account_total` es la respuesta a "cuántos ha habido en total". Todas las demás medidas de aquí son una fila por hecho, que es la forma correcta para "cuántos en julio" y la equivocada para un total de por vida. GitHub los cuenta él mismo, con una petición de búsqueda cada uno, así que el número es una fila y es correcto en la primera pasada de una instalación recién hecha. `gh_dependency_change` escribe una fila en cada pasada de la familia `deps`, no solo cuando una dependencia se movió: un rango sin cambios, una cabeza que no se movió y la primera pasada, que aún no tiene base, escriben cada uno una fila con `change` y `ecosystem` a `(none)` y los dos contadores a cero. InfluxDB 3 crea la tabla con su primer punto y responde a una consulta que nombra una tabla que no ha visto con un error, así que una medida escrita solo cuando hay cambio no existía hasta el primer bump, y el panel que la lee era un error hasta entonces. La fila a cero no cuesta ninguna petición y los paneles dejan fuera la serie `(none)`. Las tres medidas sobre repositorios ajenos, `gh_discussion_comment`, `gh_issue_comment` y `gh_repo_created`, existen porque una pasada sobre los repositorios propios no ve nada de eso. Cada una lleva `own` para poder separarlos. > **Por qué la fila semanal se ancla al domingo** > > `gh_commits_week` se sella en el domingo con el que empieza cada semana, no en > la pasada. Una pasada del martes y otra del viernes tienen que caer en la > misma fila, o cada relectura escribe una segunda copia del año. ## Actividad | Medida | Fechado | Etiquetas | Campos | | ----------------- | ----------------------------- | ------------------------------------------------------- | ------------------------------- | | `gh_event` | fechado | `type`, `repo`, `action`, `ref_type` | `events`, `public`, `commits`, `url` | | `gh_notification` | fechado, última actualización | `reason`, `repo`, `private`, `subject_type` | `is_unread`, `notifications`, `title`, `url` | Ambas son ventanas, no historias. GitHub guarda los últimos trescientos eventos sea cual sea su fecha y descarta rápido las notificaciones leídas. Lo que se captura es lo que había cuando corrió la pasada. `gh_event` no lleva actor, porque el feed es el de la propia cuenta y el actor era el login en todas las filas; `action` y `ref_type` se escriben en todas las filas, `(none)` en un push. `is_unread` es un campo: leer un hilo no mueve su `updated_at`, así que la lectura diaria con `all=true`, la que lista un hilo leído sin respuesta, reescribe la misma fila en vez de abrir una segunda al lado. La `url` de una notificación se deriva de la dirección de API de su asunto, y un asunto con una forma que el mapeo no reconoce se queda sin ella en vez de adivinarla, así que buena parte de las filas no llevan enlace. ## Configuración y entrega | Medida | Fechado | Etiquetas | Campos | | ----------------------- | ---------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gh_webhook` | diario | `hook` (el id), `host`, `active` | `events`, `hooks` | | `gh_webhook_delivery` | fechado, al entregarse | `hook`, `host`, `event`, `status`, `code`, `ok` | `deliveries`, `duration_seconds`, `redelivery` | | `gh_ruleset` | diario | `ruleset`, `target`, `enforcement` | `rulesets`, `active`, `days_since_change`, `url` | | `gh_ruleset_rule` | diario | `ruleset`, `rule` | `rules`, `bypass_actors`, `bypass_always`, `bypass_sampled`, `ref_include`, `ref_exclude` | | `gh_ruleset_version` | fechado, al guardarse | `ruleset`, `target`, `actor_type` | `versions`, `version_id`, `ruleset_id`, `actor_id`, `url` | | `gh_branch_protection` | diario | `pattern` | `rules`, `admin_enforced`, `allows_deletions`, `allows_force_pushes`, `blocks_creations`, `dismisses_stale_reviews`, `requires_approving_reviews`, `required_reviews`, `requires_code_owner_reviews`, `requires_commit_signatures`, `requires_conversation_resolution`, `requires_linear_history`, `requires_status_checks`, `requires_strict_status_checks`, `required_checks`, `requires_deployments`, `restricts_pushes`, `restricts_review_dismissals`, `url` | | `gh_branch` | diario | `branch`, `is_default` | `branches`, `oid`, `days_since_commit` | | `gh_deployment` | fechado, al crearse el despliegue | `deployment`, `environment`, `task` | `outcome`, `deployments`, `deployment_state`, `success`, `superseded`, `creator`, `commit`, `ref`, `log_url`, `environment_url`, `run_id`, `seconds_to_status`, `seconds_live`, `url` | | `gh_policy_file` | fechado, cuando la ruta cambió por última vez | `file` (dependabot, codeowners, security, funding) | `present`, `bytes`, `changes`, `path`, `blocks`, `ecosystems`, `url` | | `gh_dependabot_ecosystem` | fechado, cuando dependabot.yml cambió por última vez | `ecosystem`, `interval` | `blocks` | | `gh_environment` | diario | `environment` | `environments`, `days_since_change`, `age_days`, `protection_rules`, `has_branch_policy`, `protected_branches`, `custom_branch_policies`, `can_admins_bypass`, `url` | | `gh_deploy_key` | diario | `key`, `read_only` | `keys`, `days_since_use` | | `gh_repo_policy` | ahora | | `security_policy`, `forking_allowed`, `discussions`, `issues`, `wiki`, `sponsorships`, `blank_issues`, `auto_merge`, `delete_branch_on_merge`, `merge_commit`, `rebase_merge`, `squash_merge`, `funding_links`, `issue_templates`, `pull_request_templates`, `branch_protection_rules`, `codeowners`, `codeowners_errors`, `vulnerability_alerts`, `url` | | `gh_repo_total` | ahora | `visibility`, `archived`, `fork` | `commits`, `stars`, `forks`, `watchers`, `issues`, `issues_open`, `issues_closed`, `pulls`, `pulls_open`, `pulls_merged`, `pulls_closed`, `releases`, `discussions`, `labels`, `milestones`, `branches`, `tags`, `size_kb`, `repo_id`, `age_days`, `days_since_push`, `url` | | `gh_dependency` | diario | `ecosystem` | `packages` | | `gh_dependency_license` | diario | `license` | `packages` | | `gh_dependency_change` | ahora | `change`, `ecosystem` | `packages`, `vulnerable`, `base`, `head` | | `gh_rate_limit` | ahora | `resource` | `limit`, `used`, `remaining`, `used_ratio`, `seconds_to_reset`, `own_cost`, `own_queries` | Los webhooks fallan en silencio. Medido, un hook llevaba respondiendo 403 en setenta y ocho de sus últimas cien entregas y no había nada en ningún sitio que lo dijera. Un 404 de la protección de rama no significa desprotegido: un repositorio puede estar gobernado enteramente por rulesets, de los que ese endpoint no sabe nada. `gh_ruleset_version` es el historial detrás de `gh_ruleset`: una fila por versión guardada de un ruleset, fechada en el momento en que GitHub la guardó, con el actor que la guardó. `days_since_change` solo resume esa historia: un ruleset apagado un martes y encendido el viernes siguiente se lee como "cambiado hace tres días", y nada más de lo recogido dice que una protección faltó alguna vez. GitHub nombra al actor por id y tipo y no por login, así que la fila lleva `actor_type` como etiqueta y `actor_id` como campo. La familia es `rulesets`, diaria: una petición de listado por repositorio y una de historial por ruleset, las dos con ETag, así que un día en que nadie editó una protección no cuesta nada del presupuesto. Medido el 2026-09-11 contra el ruleset que protege el repositorio más activo medido: veinte versiones a lo largo de cinco meses, 3 KB, una petición core. Solo se guarda el host de la URL de un webhook. La ruta suele llevar un secreto. `gh_repo_policy` y `gh_repo_total` llegan en una sola consulta de GraphQL por lotes que cuesta un punto por cada diez repositorios, que es por lo que unos ajustes que una pasada REST cobraría a ciento noventa y ocho llamadas llegan a recogerse. `codeowners_errors` es el que falla en silencio: un CODEOWNERS roto deja de pedir revisiones y no dice nada. `vulnerability_alerts` viaja en esa misma consulta sin coste añadido y es una segunda lectura, independiente, del mismo interruptor que informa `gh_security_feature{feature="dependabot"}.enabled`: uno es el ajuste del propio repositorio, el otro es si el listado respondió de verdad. Que las dos fuentes discrepen es el caso que merece verse. Las tres medidas de dependencias están apagadas por omisión. El SBOM es una llamada y un mega o dos por repositorio, y solo se guarda el agregado: una sola subida de dependencia son trescientos setenta cambios, y lo que se almacena son seis filas. El commit en el que termina un diff, y del que parte el siguiente, se lee como el SHA desnudo de `HEAD` bajo el media type `application/vnd.github.sha`: cuarenta bytes, donde el listado de un commit que leía antes eran cinco kilobytes y medio. La respuesta lleva ETag y se pide de forma condicional, así que en un repositorio al que nadie ha hecho push la lectura del día es un 304 gratuito, como lo era la del listado. El SBOM solo se lee cuando esa cabeza se movió: GitHub lo regenera en cada petición, así que su ETag nunca acierta y cada lectura se cobra de su propio cubo, y un repositorio sin commit tiene los paquetes que tenía. `gh_rate_limit` es la única medida que el colector se toma de sí mismo. GitHub lleva quince presupuestos independientes, y sin esto una familia saltada por falta de presupuesto es idéntica a una familia sin nada que contar. `GET /rate_limit` informa de los presupuestos y no cobra por ninguno, que es lo que hace que casi todo esto sea gratis. No todos los que informa son ciertos: medido con el token bajo el que corre esto, el endpoint respondía `graphql` como `used=0, remaining=5000` en el mismo minuto en que GraphQL respondía `used=162` y se movía de uno en uno con cada consulta, y los dos ni siquiera comparten reloj. Así que la fila `graphql` se construye con el bloque `rateLimit` con el que responde GraphQL, y la versión del endpoint se descarta en vez de publicarse al lado. Esa lectura es una petición cada quince minutos y, medido, no cuesta puntos. `graphql` no es el único cubo que el endpoint se inventa. Medido el 2026-09-12, las cabeceras de una petición de SBOM decían `dependency_sbom` used 1, remaining 99, reset en 59 s, y `GET /rate_limit` dos segundos después decía used 0, remaining 100, con el reset deslizándose un segundo por llamada. Cada respuesta REST nombra en sus cabeceras el cubo al que cobró, así que una fila se construye con las cabeceras más recientes que vio el cliente siempre que sigan dentro de su propia ventana y digan que se gastó más de lo que el endpoint admite. Las ventanas de `dependency_sbom` y `search` son de un minuto, así que una fila `dependency_sbom` que lea cero entre ejecuciones de la familia `deps` es un cubo rellenado, no el defecto. La fila `graphql` puede por tanto faltar, donde las otras se escriben siempre que el endpoint responda. No se escribe cuando todavía no se ha leído nada de GraphQL y ninguna lectura anterior sigue dentro de su ventana: una fila que falta dice "no medido" donde un cero dice "nada gastado", y el cero era el defecto. `own_cost` y `own_queries` están solo en esa fila. `limit`, `used` y `remaining` describen la ventana del token entero, compartida con quien sea que lo tenga; estos dos son la parte de la que responde este proceso. Cuentan desde el arranque del proceso, así que un reinicio los devuelve a cero y un panel tiene que leerlos como contador y no como valor. ## Logs de jobs | Medida | Fechado | Etiquetas | Campos | | ------------ | ------------------------------------ | ----------------------------- | --------------------- | | `gh_job_log` | fechado, cuando se imprimió la línea | `workflow`, `job_name`, `run` | `line`, `head_branch` | Apagado por omisión: pon `every.joblogs`. Es texto y no una medida, así que se excluye del destino de InfluxDB por omisión y el exportador de Prometheus lo salta; Loki es donde le corresponde. Los dashboards exportados llevan un panel de texto, "Where failure output went", en el sitio que ocuparían las líneas, porque quien los importa puede no tener Loki; `cmd/publish_dashboard -loki ` publica el dashboard con las líneas leídas de Loki en el lugar de ese panel (ver [los dashboards](/ghchronicle/es/dashboards/panels/#delivery-and-access)). Solo jobs fallidos, y solo las últimas cuarenta líneas de cada uno. La salida de un job correcto son miles de líneas que nadie va a leer, cada log cuesta una petición, y la cola es donde un fallo se explica. GitHub guarda los logs exactamente noventa días y responde 410 después, así que no hay forma de rellenarlos. `workflow` es aquí la misma ruta de fichero, por el mismo ayudante, así que una línea de log se une con la ejecución que la imprimió; y `branch` pasó a ser el campo `head_branch` por las mismas dos razones que en la ejecución. `run` es a propósito una serie por ejecución, que es a lo que de verdad pertenece una línea de log, y solo sale a cuenta porque esta familia está apagada por omisión. Los códigos de color se eliminan y se quita la marca de orden de bytes que GitHub escribe antes de la primera marca de tiempo, para que buscar una palabra no falle porque la palabra estuviera coloreada. La lista de fallos se pide solo para las ejecuciones creadas en los treinta y un días anteriores a que se abriera la ventana, redondeado al día hacia abajo. Treinta y un días porque una reejecución conserva el created_at de su primer intento y GitHub permite reejecutar durante treinta días: medido el 2026-09-11, el fallo más nuevo del repositorio más activo medido era el tercer intento de una ejecución creada dos horas antes de terminar, y un margen de la duración de un job habría perdido toda reejecución de un fallo de más de una mañana. Sin filtrar eran los cien fallos más nuevos que el repositorio haya tenido nunca, seiscientos kilobytes por repositorio y pasada para una ventana de una hora casi siempre vacía; filtrada, un mes de fallos, sesenta y ocho filas y un megabyte descomprimido en ese repositorio, unas pocas filas o ninguna en la mayoría. El redondeo mantiene la URL, y con ella el ETag, igual entre los pasadas de un día, que es lo que convierte la repetición en un 304 gratuito y no en un 200 cobrado sobre una URL nueva; dentro del día la página solo cambia cuando se crea o se reejecuta un fallo. El corte en la ventana en sí se sigue haciendo aquí, por cuándo terminó la ejecución. ## Coste | Medida | Fechado | Etiquetas | Campos | | ------------------ | ---------------- | --------------------------------------- | --------------------------------------------------------------- | | `gh_billing_usage` | fechado, por día | `product`, `sku`, `unit`, `repo`, `org` | `quantity`, `price_per_unit`, `gross`, `discount`, `net`, `url` | `unit` es el propio `unitType` de GitHub, con la mayúscula con la que GitHub lo manda: `Minutes`, `GigabyteHours`, `AICredits`, `Requests`. Se pasa tal cual, sin normalizar, y los paneles que leen minutos filtran por esa mayúscula. `net` no siempre es cero. En la cuenta con la que se desarrolló esto lleva el crédito mensual, que es por lo que se guardan el bruto, el descuento y el neto en vez de derivar uno de los otros. La fila se sella al inicio de su día: el `date` de GitHub llega como el primer minuto facturado del día en la mitad de las filas, lo que habría duplicado la fila si GitHub informara de otro minuto en la siguiente lectura. ## Columnas que solo existen una vez escritas InfluxDB 3 crea una columna la primera vez que una fila la trae, y una consulta que nombra una columna que ninguna fila ha escrito falla al planificarse en vez de responder nulo: el panel entero se pone en rojo. Así que un campo que solo se escribe cuando GitHub tiene valor para él no existe en una base donde eso nunca ha pasado. Los que más probablemente falten en una base recién creada: `gh_discussion.state_reason`, `seconds_to_answer` y `seconds_to_close`; `gh_milestone.days_to_due` y `seconds_to_close`; `gh_ruleset_rule.ref_exclude`; y `gh_workflow_run.initial_actor`. La misma regla cubre todo `seconds_to_*` que necesita un cierre, toda `url` de un ítem para el que GitHub no envía dirección, los campos opcionales del aviso en una alerta, `checks_total` y `checks_failed` en un commit por el que pasó una puerta, `label_names` en un ítem con etiqueta, `resolved_by` en un hilo resuelto, `queued_seconds` en el primer intento de una ejecución, y `pull_request`, `pull_requests`, `headline` y `head_repo` en una ejecución que GitHub enlazó, describió o tomó de un fork. Dos más conviene nombrarlas, porque las tablas de arriba las listan junto a campos que sí están siempre: `gh_dependabot_alert_item.dismissed_comment`, que solo se escribe cuando quien descartó una alerta tecleó un motivo, y `gh_event.commits`, que solo se escribe en un evento de push. Ninguna de las dos columnas existe en la base de datos de producción contra la que se comprobó esta documentación. `gh_label` solo escribe las etiquetas que alguien ha usado; `gh_repo_total.labels` es el recuento declarado. --- # Elegir almacén Diez almacenes, qué puede y qué no puede responder cada uno, y la propiedad que decide entre ellos. Source: https://jmrplens.github.io/ghchronicle/es/sinks/ Diez destinos, y usar más de uno es lo normal. Todos envían por push: la herramienta está pensada para correr donde sea cómodo y alcanzar sus almacenes desde ahí, no para que la consulten. El exportador de Prometheus es la única excepción, y existe porque Prometheus insiste. ## La comparación | Almacén | Guarda | Bueno para | Configuración | | ----------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------ | ------------------------------- | | [InfluxDB](/ghchronicle/es/sinks/influxdb/) | la historia fechada | "a qué velocidad fusionábamos en julio" | `url`, `token`, `org`, `bucket` | | [PostgreSQL](/ghchronicle/es/sinks/postgres/) | la historia fechada, como SQL que pasas a psql | quien usa Grafana con un Postgres y sin InfluxDB | `dialect`, `path` | | [Graphite](/ghchronicle/es/sinks/graphite/) | la historia fechada | un Graphite que ya está ahí | `addr`, `prefix` | | [Elasticsearch](/ghchronicle/es/sinks/elasticsearch/) | la historia fechada, como documentos | buscar en todo lo recogido | `url`, `prefix`, `api_key` | | [Prometheus](/ghchronicle/es/sinks/prometheus/) | el valor actual | alertas, y un número en una pared | `listen`, `path` | | [OpenTelemetry](/ghchronicle/es/sinks/otlp/) | una cosa u otra, según el backend | un pipeline de collector ya existente | `endpoint`, `raw` | | [Loki](/ghchronicle/es/sinks/loki/) | los eventos, como líneas de log | "qué pasó, en orden" | `url`, `labels`, `max_age` | | [Telegraf](/ghchronicle/es/sinks/telegraf/) | lo que guarden sus salidas | alcanzar todo lo que alcance Telegraf | `url` | | [Fichero y stdout](/ghchronicle/es/sinks/file/) | line protocol o JSON | un agente de envío que ya tengas, y un búfer | `path`, `format` | No hay destino para ningún proveedor gestionado concreto, y es deliberado. A un backend gestionado se llega por uno de los dos destinos que existen para eso: Telegraf, cuyas salidas cubren Datadog, New Relic, Wavefront, Azure Monitor y cien más, u OpenTelemetry, que la mayoría ya acepta directamente. Un destino por proveedor es una clave que rotar, una API que seguir y un test que necesita una cuenta de pago, para un salto que esos dos ya hacen. ## Lo que lo decide todo Un punto lleva la fecha en que ocurrió la cosa. Una estrella se fecha cuando se dio, una ejecución de workflow cuando terminó, un día de tráfico en la fecha de ese mismo día. InfluxDB indexa un punto por medida, conjunto de etiquetas y marca de tiempo, así que escribir la misma ventana de tráfico de catorce días cada seis horas converge en la respuesta correcta en vez de acumular copias. Eso es lo que hace funcionar todo el diseño del relleno histórico, y es por lo que InfluxDB es el destino que guarda la historia. Prometheus no puede hacer eso. Sella una muestra en el instante del scrape y rechaza cualquier cosa apreciablemente más vieja: medido contra Prometheus 3.14 con el receptor OTLP activado y una ventana de desorden de treinta minutos, una muestra fechada dos días atrás vuelve como HTTP 400. Así que el exportador reduce las filas por elemento a valores actuales antes de servirlas. El argumento completo, y qué le hace la reducción a cada medida, está en [la fecha del punto](/ghchronicle/es/how/dating/). ## Solo se escribe lo que ha cambiado Una pasada ofrece la misma historia cada vez: la ventana de tráfico de catorce días, cada pull request abierta, el calendario de contribuciones. Volver a escribirlo todo es inofensivo para lo que guarda el almacén, porque la fila se indexa por serie y marca de tiempo y simplemente se sobrescribe, y es así como se repara un colector que estuvo caído un día. No es inofensivo para los ficheros del almacén. InfluxDB 3 Core escribe un fichero Parquet por partición y por petición de escritura, no los compacta nunca, y rechaza cualquier consulta que abriría más ficheros que su límite. Medido antes del arreglo de abajo y con el límite entonces en diez mil: `gh_notification` tenía poco más de una fila por fichero Parquet, y una consulta sobre catorce días volvió con "Query would scan 10000 Parquet files, exceeding the file limit". Pedir un intervalo más grueso no ayuda, porque el límite cuenta los ficheros que abre el planificador, antes de cualquier agregación. Así que la herramienta mantiene un pequeño registro de lo que ya ha escrito y envía solo los puntos cuyos valores se han movido: ```yaml sinks: dedupe_file: /var/lib/ghchronicle/state-written.bin # por omisión: junto a state_file dedupe_horizon: 720h # olvida un punto que ya nadie ofrece influxdb: dedupe: true # el valor por omisión, aquí y en telegraf, graphite, sql y elasticsearch ``` El registro guarda dos hashes de 64 bits y un día por punto, así que una cuenta grande cuesta unos pocos megabytes. Va indexado por destino, así que un almacén que estuvo inalcanzable recibe todo en su siguiente escritura. Perderlo, o poner `dedupe_file: off`, cuesta una pasada de reescritura y nada más, que es justo lo que quiere un almacén que se ha vaciado y hay que volver a llenar. Es el segundo fichero que conviene poner en una ruta persistente, junto al [fichero de estado](/ghchronicle/es/configuration/). Una ejecución que termina cuando termina su pasada no abre el registro. `-once`, `-backfill` y el dibujo de una tarjeta escriben una vez y salen, así que no hay nada que guardar ni nada que podar, y cada una de ellas vuelve a ofrecer la historia entera. Para eso está el relleno histórico; y por eso una tarea programada con `-once`, que es la forma en que corre [la Action](/ghchronicle/es/install/actions/), escribe todos los puntos cada vez. Donde eso importe, hay que correr el bucle. El registro de la pasada dice lo que esto ha ahorrado: ```text level=INFO msg=written sink=influxdb family=events points=0 unchanged=300 ``` Si los ficheros ya se han acumulado, el arreglo del escritor detiene el crecimiento pero no los quita: sube `--query-file-limit` en el servidor, reescribe las tablas afectadas, o pasa a InfluxDB 3 Enterprise, que compacta por su cuenta y es gratis para uso doméstico. ## Por dónde empezar - [Quieres la historia](/ghchronicle/es/sinks/influxdb/): InfluxDB es la implementación de referencia y el dashboard está hecho contra ella. PostgreSQL, Graphite y Elasticsearch guardan los mismos hechos con su propia forma. - [Quieres alertas](/ghchronicle/es/sinks/prometheus/): Prometheus sirve el valor actual de todo lo que tiene uno. Úsalo junto a un almacén de historia, no en su lugar. - [Quieres leer qué pasó](/ghchronicle/es/sinks/loki/): Loki convierte veintidós de las medidas en líneas de log que se leen como frases y llevan detrás cada etiqueta en logfmt. - [Ya tienes un pipeline](/ghchronicle/es/sinks/telegraf/): Telegraf y OpenTelemetry entregan los puntos a algo que ya sabe a dónde deben ir. ## Usar varios Es normal, y es barato: la recolección ocurre una vez y los puntos se entregan a todos los destinos configurados. La disposición habitual es un almacén de historia más uno de los de valor actual. ```yaml sinks: influxdb: url: http://localhost:8181 token: ${INFLUX_TOKEN} org: default bucket: github prometheus: listen: 127.0.0.1:9605 path: /metrics ``` > **Una combinación que hay que evitar** > > `sinks.sql.path: "-"` y `sinks.stdout: true` escriben ambos por la salida > estándar, y entrelazar sentencias SQL con line protocol produce un flujo que > no puede leer ni psql ni Telegraf. Elige uno. ## Qué hace cada destino ante un fallo Un destino que falla se registra y la pasada continúa; que una base de datos esté caída no detiene la recolección, y con el destino de fichero configurado los datos siguen en disco cuando vuelva. Dos fallos se informan de forma especial en vez de como errores, porque son éxitos parciales: - **líneas rechazadas**, donde se escribió todo lo analizable y las líneas rechazadas se registran una a una con la razón que dio el almacén. - **entradas descartadas**, que es el horizonte de antigüedad de Loki dejando fuera lo que su ventana de desorden habría rechazado, en vez de perder el envío entero. --- # InfluxDB El almacén de referencia para la historia fechada, por qué volver a recoger converge, y el único error de escritura que conviene reconocer. Source: https://jmrplens.github.io/ghchronicle/es/sinks/influxdb/ ```yaml sinks: influxdb: url: http://localhost:8181 token: ${INFLUX_TOKEN} org: default bucket: github batch: 5000 # exclude: [gh_job_log] ``` ## Lo que va por el cable Line protocol enviado al **endpoint de escritura v2**, que sirven tanto InfluxDB 2 como InfluxDB 3, así que un solo destino cubre ambos. El token va en una cabecera `Authorization`, y `org` y `bucket` en la cadena de consulta. La precisión es de **nanosegundos**, porque los puntos de tráfico son días y los de workflows son segundos y una sola precisión tiene que cubrir ambos. Los lotes son de 5000 líneas por petición por omisión. `batch` lo baja para un servidor con un límite de cuerpo menor. ## Qué puede responder que los demás no Todo lo fechado, que es casi todo el proyecto: - El tráfico de un martes concreto, meses después. - La curva de estrellas desde la primera, dibujada con una fila por estrella. - El tiempo de fusión de una pull request cerrada en julio. - Líneas añadidas y quitadas por commit, por autor, a lo largo de años. InfluxDB indexa un punto por medida, conjunto de etiquetas y marca de tiempo, así que **reescribir un punto que ya existe no es un duplicado**. Repetir la misma ventana de tráfico de catorce días cada seis horas converge en vez de acumular, que es lo que hace funcionar todo el diseño del [relleno histórico](/ghchronicle/es/how/backfill/). Los [dashboards](/ghchronicle/es/dashboards/) se generan contra este almacén primero; los otros cuatro conjuntos de consultas son traducciones de él. ## Reescribir sale gratis en filas y no en ficheros InfluxDB 3 Core escribe un fichero Parquet por partición y por petición de escritura, no los compacta nunca, y rechaza cualquier consulta que abriría más ficheros que su límite. Así que la fila que se sobrescribe sin daño cuesta de todos modos un fichero, y una pasada que ofrece la misma historia cada seis horas compra un "Query would scan 10000 Parquet files, exceeding the file limit" unas semanas después. Por eso existe el registro de escrituras, por eso `dedupe` viene encendido aquí, y por eso apagarlo es una decisión y no una limpieza: [solo se escribe lo que ha cambiado](/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). ## `exclude` Nombra las medidas que este destino no debe recibir. Por omisión es `gh_job_log`, que es texto destinado a un almacén de logs: escribir miles de líneas de salida de compilación en una base de datos de métricas es mucho almacenamiento para algo que nadie va a consultar como número. ## Los tipos de columna se fijan al primer contacto > **InfluxDB 3 no deja que un nombre cambie de bando** > > InfluxDB 3 fija una columna como etiqueta o como campo la primera vez que la ve, > y rechaza las escrituras posteriores que no coincidan. Si alguna versión de esta > herramienta llegara a mover un nombre de un lado al otro, hay que borrar la > tabla en vez de repararla: > > ```http > DELETE /api/v3/configure/table > ``` > > Esta es la causa habitual de `influx write: 400`. ## Líneas rechazadas Una escritura que vuelve 400 se biseca: el destino parte el lote por la mitad, reintenta y acota hasta las líneas concretas que el servidor se niega a analizar. Esas se registran una a una con la razón del propio servidor, todo lo demás se escribe, y la pasada informa de un aviso y no de un fallo. ```text level=WARN msg="sink rejected some lines" sink=influxdb family=actions rejected=2 ``` Ese comportamiento importa porque un lote son cinco mil líneas. Fallar el lote entero por un valor malformado perdería cuatro mil novecientos noventa y nueve puntos buenos. ## Grafana Usa el **datasource de InfluxDB 3 en modo SQL** para el dashboard incluido, y apúntalo a la base de datos en la que escribe el destino. El fichero del dashboard declara `DS_INFLUXDB` como entrada, así que al importarlo te pide elegir tu propio datasource en vez de arrastrar el uid de otra persona. ```sql SELECT time, "count" FROM gh_traffic WHERE kind = 'views' AND repo = 'ghchronicle' ``` ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara InfluxDB con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Prometheus Un exportador, no un emisor, y qué le hace a cada medida la reducción a valores actuales. Source: https://jmrplens.github.io/ghchronicle/es/sinks/prometheus/ ```yaml sinks: prometheus: listen: 127.0.0.1:9605 path: /metrics ``` Un exportador, no un emisor: apúntale un scrape. Es el único destino del proyecto que no es saliente, y existe porque Prometheus insiste en tirar de los datos. ## Qué sirve Los nombres de métrica son `github__`, con las etiquetas como labels. ```text github_repo_stars{repo="ghchronicle",language="Go",visibility="public"} 283 github_workflow_runs_count{repo="ghchronicle",conclusion="success"} 412 ``` ## Qué no puede servir La historia fechada, y no por elección. Prometheus sella una muestra en el instante del scrape y rechaza cualquier cosa apreciablemente más vieja: medido contra Prometheus 3.14 con `--web.enable-otlp-receiver` y una ventana de desorden de treinta minutos, una muestra fechada dos días atrás vuelve como **HTTP 400**. A través de este destino, la ventana de tráfico de catorce días de GitHub se colapsa a su día más reciente, y la historia de estrellas al total actual. Eso merece decirse claro en vez de esconderse. Úsalo junto a un almacén de historia, no en su lugar; ambos pueden correr a la vez y la recolección ocurre una sola vez. ## La reducción Antes de servir, `Summarize` reduce cada medida según una regla. | Regla | Qué sobrevive | | ---------- | ------------------------------------------------------------------- | | `keepLast` | El valor más reciente de cada conjunto de etiquetas. Instantáneas | | `sum` | El lote sumado. Ventanas, como las visitas de los catorce días | | `count` | Un recuento más la media de cada campo numérico. Elementos fechados | | `skip` | Nada | Una medida sin regla se salta, así que un colector nuevo no puede inundar en silencio el exportador con una serie por estrella. El reductor publica además `total`, un recuento acumulado de elementos distintos por serie. Eso es lo que permite que un dashboard de Prometheus diga "por día" mediante `increase()`, ya que no tiene filas que contar. ## Dos medidas se saltan por tamaño El punch card de commits es una serie por repositorio, día de la semana y hora, y los ficheros de release una por cada fichero publicado alguna vez. Medido, juntas eran **cuatro quintas partes de toda la salida del exportador**. Ambas se dibujan bien en el dashboard de InfluxDB. Otras ocho se saltan por un motivo distinto del tamaño, siete por ser historia y una por ser texto, así que diez medidas en total no llegan nunca al exportador. La lista, con el motivo de cada una, está en [la fecha del punto](/ghchronicle/es/how/dating/#las-diez-que-no-se-sirven-nunca). El exportador solo tiene lo que recogió la última pasada, y para las ejecuciones de workflows eso son las treinta más nuevas por repositorio entre builds: una pasada ordinaria lee la lista de ejecuciones en páginas de treinta, y solo pasa de página mientras una página venga llena de ejecuciones más nuevas que su ventana de dos horas. Los almacenes guardan cada ejecución que el recorrido vio alguna vez; este es el único sitio donde la página más pequeña se nota. Los jobs de workflow son el otro: una pasada lleva los jobs de las ejecuciones que listó por primera vez, así que `gh_workflow_jobs` cuenta los jobs nuevos para este proceso y no los de las veinte ejecuciones más nuevas, y una pasada en el que no terminó ninguna ejecución no lleva ninguno, con lo que el último recuento se mantiene hasta que caduca un día después. El `total` de al lado no cambia, porque cuenta cada job distinto que el proceso ha visto alguna vez; los almacenes tampoco, porque un job se escribe una vez y se fecha cuando terminó. > **Una etiqueta llamada job chocaría** > > Prometheus añade etiquetas `job` e `instance` en el scrape, y el receptor OTLP > sobrescribe un atributo `job` con el nombre del servicio. Por eso los jobs de > workflow se etiquetan `job_name`. ## Cómo consultarlo ```yaml scrape_configs: - job_name: ghchronicle static_configs: - targets: ["127.0.0.1:9605"] ``` El exportador guarda sus muestras en memoria, así que un reinicio lo vacía. Por eso la primera pasada tras el arranque corre todas las familias activas diga lo que diga el fichero de estado: sin ella, una familia de doce horas dejaría sus paneles a cero medio día. ```yaml sinks: prometheus: listen: 0.0.0.0:9605 path: /metrics no_prime: true # deja la primera pasada en su horario normal ``` `no_prime: true` apaga esa pasada de cebado, para una cuenta con la cuota tan justa que una pasada completa en cada reinicio no salga a cuenta. El precio es exactamente el comportamiento que el cebado existe para evitar: hasta que a cada familia le toque su cadencia, los paneles que la leen no tienen nada, y en una familia de doce horas eso es medio día de ceros. Los almacenes no se ven afectados en ningún caso, porque conservan lo que se recogió antes del reinicio. Una serie que lleva **24 horas** sin reescribirse se descarta, para que un repositorio que sale de la pasada deje de informarse como si siguiera ahí. El horizonte no se configura. ## Errores al arrancar, no en una goroutine ```text prometheus exporter: listen tcp :9605: bind: address already in use ``` Se informa al arrancar el proceso en vez de quedar sepultado en una goroutine de fondo, para que un choque de puerto no te deje con un colector corriendo y un exportador ausente en silencio. ## En un contenedor El ejemplo escucha en `127.0.0.1`, que dentro de un contenedor es el loopback del propio contenedor e inalcanzable desde la máquina anfitriona. Ahí usa `0.0.0.0:9605` y deja que la publicación del puerto decida quién llega. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara Prometheus con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # OpenTelemetry OTLP sobre HTTP con codificación JSON, y por qué raw vale false por omisión. Source: https://jmrplens.github.io/ghchronicle/es/sinks/otlp/ ```yaml sinks: otlp: endpoint: http://collector:4318/v1/metrics service: ghchronicle headers: Authorization: Bearer ${OTLP_TOKEN} raw: false batch: 2000 ``` ## Lo que va por el cable OTLP sobre HTTP con **codificación JSON**, que todo receptor que merezca la pena acepta en el mismo endpoint. Es una decisión deliberada: JSON ocupa más en el cable, y mantiene un generador de código, un runtime de protobuf y sus dependencias transitivas fuera de una herramienta cuya lista entera de dependencias es un analizador de YAML. `endpoint` es la URL completa de la ruta de métricas, no una base. Las `headers` van en cada petición, que es donde corresponde una clave de API o un id de inquilino. `service` se convierte en el atributo de recurso por el que agrupa el backend. `batch` es cuántos puntos de datos van en una petición, 2000 salvo que se baje para un receptor con un límite de cuerpo más pequeño. Los nombres de métrica usan puntos, siguiendo la convención de OpenTelemetry: `github.workflow.run.duration.seconds`. Un receptor que reexporte hacia Prometheus los convierte solo; al revés no puede. ## `raw` - **raw: false (por omisión)** Envía los mismos valores actuales reducidos que el exportador de Prometheus. Es seguro con cualquier backend, incluido el propio receptor OTLP de Prometheus, porque nada de la carga es más viejo que la pasada. - **raw: true** Envía los puntos fechados. Que sobrevivan lo decide enteramente el backend: los puntos de datos OTLP llevan una marca de tiempo explícita, así que un almacén que acepte fechas viejas guarda la historia, y uno que no las rechaza. > **El receptor OTLP de Prometheus es uno de los que no** > > Medido: contra Prometheus 3.14 con `--web.enable-otlp-receiver` y > `out_of_order_time_window: 30m`, una muestra fechada dos días atrás vuelve > como HTTP 400. Con `raw: true` apuntando a Prometheus, más o menos la mitad de > cada pasada se rechaza. Usa ahí `raw: false`, o envía a un almacén que guarde > las fechas. ## `repeat` Un gauge afirmado una vez y nunca más se desvanece de los dashboards, y una familia que corre cada doce horas sería una línea plana con un punto. Con `repeat` puesto, el destino sigue afirmando el valor más nuevo de cada serie que ha visto hasta que la siguiente pasada lo reemplace. ```yaml sinks: otlp: endpoint: http://collector:4318/v1/metrics repeat: 1m ``` Esto importa para un backend que responde una consulta instantánea con la última muestra dentro de una ventana de retroceso. ## Un collector delante La disposición habitual es un collector que recibe de aquí y reexporta, que es lo que hace que este destino merezca la pena: la herramienta habla un protocolo y el pipeline decide dónde acaban los datos. ```yaml receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 exporters: prometheusremotewrite: endpoint: http://prometheus:9090/api/v1/write service: pipelines: metrics: receivers: [otlp] exporters: [prometheusremotewrite] ``` Con ese pipeline, deja `raw: false`: el exportador del otro extremo es Prometheus, y la restricción sigue a los datos y no al protocolo. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara OpenTelemetry con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # PostgreSQL Sentencias INSERT que se pasan a psql, el esquema que declaran y por qué la cláusula de conflicto actualiza en vez de no hacer nada. Source: https://jmrplens.github.io/ghchronicle/es/sinks/postgres/ ```yaml sinks: sql: dialect: postgres path: /var/lib/ghchronicle/points.sql max_bytes: 67108864 keep: 5 ``` Sentencias INSERT en el dialecto de PostgreSQL, escritas a un fichero rotatorio o, con `path: "-"`, a la salida estándar. ```sh ghchronicle -config config.yaml -once | psql "$DATABASE_URL" ``` Este es el destino para quien usa Grafana con un PostgreSQL o TimescaleDB propio y sin InfluxDB. La herramienta no puede hablar el protocolo de cable de PostgreSQL sin un driver, y un driver es una dependencia que este repositorio no asume, así que emite el SQL y deja la conexión a psql. El datasource de PostgreSQL de Grafana tiene entonces un esquema real que consultar. ## El esquema es el contrato - Una tabla por medida, con su nombre: `gh_repo`, `gh_traffic`, `gh_workflow_run`. - `time TIMESTAMPTZ NOT NULL`, la fecha en que ocurrió la cosa. - Una columna `TEXT NOT NULL DEFAULT ''` por etiqueta, con cadena vacía donde el punto no tenía valor. - Una columna por campo, tipada a partir del valor: `BIGINT` para un entero, `DOUBLE PRECISION` para un flotante, `BOOLEAN`, `TEXT`, y `TIMESTAMPTZ` para un campo que es en sí una hora. - `PRIMARY KEY (time, )`. - Todo identificador va entre comillas dobles, porque `user`, `type` y `state` son aquí nombres de etiqueta y allí palabras reservadas. Esa clave primaria es la clave de serie de InfluxDB escrita como restricción, y es lo que hace que una reescritura de la ventana de tráfico de catorce días converja en vez de acumular. ```sql CREATE TABLE IF NOT EXISTS "gh_traffic" ("time" TIMESTAMPTZ NOT NULL, "full_name" TEXT NOT NULL DEFAULT '', "kind" TEXT NOT NULL DEFAULT '', "owner" TEXT NOT NULL DEFAULT '', "repo" TEXT NOT NULL DEFAULT '', "count" BIGINT, "uniques" BIGINT, "url" TEXT, PRIMARY KEY ("time", "full_name", "kind", "owner", "repo")); INSERT INTO "gh_traffic" ("time", "full_name", "kind", "owner", "repo", "count", "uniques", "url") VALUES ('2026-09-07T00:00:00Z'::timestamptz, 'acme/telemetry', 'views', 'acme', 'telemetry', 41, 12, 'https://github.com/acme/telemetry/graphs/traffic') ON CONFLICT ("time", "full_name", "kind", "owner", "repo") DO UPDATE SET "count" = EXCLUDED."count", "uniques" = EXCLUDED."uniques", "url" = EXCLUDED."url"; ``` > **DO UPDATE, no DO NOTHING** > > La fila de tráfico de hoy se reescribe con un recuento mayor en cada pasada. > Una fila congelada en su primer valor sería justo el error que todo el diseño > del punto fechado existe para evitar. ## Cómo llegan las declaraciones El `CREATE TABLE IF NOT EXISTS` se emite la primera vez que se ve una medida en un fichero, con la unión de las columnas que lleva ese lote. Una columna que aparece en un lote posterior llega como `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. Un fichero rotado empieza otra vez sus declaraciones, así que cualquier fichero se puede reproducir por su cuenta. Una etiqueta vista por primera vez después de declarar la tabla no puede unirse a la clave primaria sin reescribirla, así que se convierte en una columna normal. Eso solo ocurre cuando un colector cambia su conjunto de etiquetas entre pasadas. ## TimescaleDB Convierte cada tabla en hypertable una vez exista. La clave primaria ya incluye `time`, que es la única condición que le pone TimescaleDB. ```sql SELECT create_hypertable('gh_traffic', 'time', if_not_exists => TRUE); SELECT create_hypertable('gh_workflow_run', 'time', if_not_exists => TRUE); ``` Nada del dashboard cambia. ## Cómo montarlo 1. Apunta el destino a un fichero, o a la salida estándar para una tubería directa. 2. Cárgalo. ```sh psql "$DATABASE_URL" -f /var/lib/ghchronicle/points.sql ``` O, para la disposición en flujo, ejecuta el colector con `-once` desde un planificador y encadénalo directamente. 3. Apunta el datasource de PostgreSQL de Grafana a la base de datos e importa `ghchronicle-postgres.json`. ```sql SELECT time, "count" FROM gh_traffic WHERE kind = 'views' AND repo = $repo ``` `dashboards/ghchronicle-postgres.json` tiene los mismos 152 paneles que el de InfluxDB, con cada consulta traducida a PostgreSQL contra este esquema. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara PostgreSQL con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Graphite El protocolo de texto plano sobre TCP, y la ruta métrica exacta, que es el contrato del dashboard. Source: https://jmrplens.github.io/ghchronicle/es/sinks/graphite/ ```yaml sinks: graphite: addr: graphite:2003 prefix: github batch: 1000 ``` El protocolo de texto plano sobre TCP: `ruta valor marca-de-tiempo`, una línea por campo numérico. Los campos de texto se saltan, porque Graphite no tiene forma de sostener uno. Graphite guarda un punto en la hora que se le dio, así que los puntos fechados salen tal cual y la ventana de tráfico cae en sus propios días. Escribir el mismo punto dos veces llena la misma ranura del mismo fichero whisper, que es exactamente lo que quiere una reescritura de la ventana. La conexión se reabre tras una escritura fallida, una vez, antes de informar la escritura como fallida. ## La ruta es el contrato del dashboard ```text ... ``` - Cada etiqueta es un nodo con su valor. Los nodos se ordenan por la **clave** de la etiqueta, alfabéticamente, nunca por el orden en que las puso un colector. - Un valor de etiqueta vacío se escribe como `none`, para que la profundidad de una medida no cambie de un punto al siguiente y `github.repo.*.*.*.*.*.*.*.*.*.stars` siga casando. - Un nodo conserva letras ASCII, dígitos, `_`, `-` y `:`. Todo lo demás pasa a `_`: el punto, el espacio, la coma y la barra de `owner/repo`. Un punto partiría el nodo y una barra anidaría un directorio. Así, un punto `gh_repo` etiquetado `archived=false default_branch=main fork=false full_name=acme/edge-cache language=Go license=MIT owner=acme repo=edge-cache visibility=public` con un campo `stars` queda como: ```text github.repo.false.main.false.acme_edge-cache.Go.MIT.acme.edge-cache.public.stars 37 1757280000 ``` Un campo que comparte nombre con una etiqueta se salta, la misma regla que aplica el line protocol: gana la etiqueta, porque es la que permite agrupar. > **Una etiqueta nueva cambia la profundidad de la ruta** > > Añadir una etiqueta a un colector inserta un nodo en cada ruta de esa medida, > así que todos los objetivos de dashboard existentes dejan de casar. Este es el > precio de un almacén sin esquema, y por eso `TAGS` en la especificación del > dashboard refleja el conjunto de etiquetas de cada medida. ## Qué no puede responder Graphite guarda los puntos fechados pero no tiene filas. Una serie es una ruta y un número, así que: - No se puede construir una tabla que necesite varios campos de una misma fila. El dashboard incluido conserva la columna por la que ordena y dice en la descripción qué columnas descartó. - Un booleano no es una métrica allí en absoluto, ni tampoco un título ni ninguna otra cadena. Esos paneles lo dicen. Todo lo que tiene forma de tiempo funciona con normalidad, que es la mayor parte del dashboard. ## El dashboard `dashboards/ghchronicle-graphite.json` tiene los mismos 152 paneles que el de InfluxDB, escritos contra estas rutas con el prefijo por omisión. Necesita **Graphite 1.1 o posterior** para las funciones que usa, y cualquier panel que una serie no pueda llevar lo dice en su descripción. Cambia `prefix` y los objetivos del dashboard tienen que cambiar con él, ya que el prefijo es el primer nodo de cada ruta. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara Graphite con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Elasticsearch La API bulk, un índice por medida, y un id de documento que hace que una reescritura reemplace en vez de duplicar. Source: https://jmrplens.github.io/ghchronicle/es/sinks/elasticsearch/ ```yaml sinks: elasticsearch: url: http://elasticsearch:9200 prefix: ghchronicle api_key: ${ES_API_KEY} # o username y password batch: 1000 ``` La API `_bulk`, que comparten Elasticsearch y OpenSearch, así que el mismo destino sirve a ambos, y a Kibana o a los dashboards de OpenSearch encima de cualquiera de los dos. ## Credenciales - **Clave de API** ```yaml api_key: ${ES_API_KEY} ``` Se envía como `Authorization: ApiKey`. - **Básica** ```yaml username: ghchronicle password: ${ES_PASSWORD} ``` Se envía como autenticación básica. No las dos. Configurar una clave de API junto a un usuario falla la validación al arrancar con `sinks.elasticsearch: set either api_key or username and password, not both`. ## Los documentos Un índice por medida, llamado `-`: `ghchronicle-gh_repo`, `ghchronicle-gh_traffic`. Un documento por punto, con la hora como `@timestamp` en RFC 3339, `measurement`, y cada etiqueta y cada campo como clave de primer nivel, para que no haya que desanidar nada antes de poder filtrar por ello. ```json { "@timestamp": "2026-09-07T00:00:00Z", "measurement": "gh_traffic", "owner": "acme", "repo": "telemetry", "full_name": "acme/telemetry", "kind": "views", "count": 220, "uniques": 131 } ``` ## Por qué aquí también converge volver a recoger El id del documento es el **SHA-256 de la medida, las etiquetas que tienen valor y la marca de tiempo**, y la acción es `index` y no `create`. Así que escribir la misma ventana de tráfico de catorce días cada seis horas reemplaza catorce documentos en vez de añadir otros catorce, que es la misma convergencia que InfluxDB da de balde. > **Una petición bulk responde 200 aunque fallen elementos** > > Elasticsearch informa de los veredictos por elemento dentro de una respuesta > correcta. El destino los lee, registra cada documento rechazado con la razón > del propio clúster e informa la cuenta como aviso y no como fallo, porque todo > lo demás del lote se escribió. ## No se escribe ningún mapping El destino no crea plantilla de índice. El mapping dinámico da a cada campo de texto un subcampo `.keyword`, que es sobre lo que agrega el dashboard. Si quieres mappings explícitos, crea las plantillas de índice antes de la primera escritura. Nada del destino depende de ellas; solo la elección de `.keyword` en los paneles. ## El dashboard `dashboards/ghchronicle-elasticsearch.json` tiene los mismos 152 paneles que el de InfluxDB, como filtros Lucene y agregaciones sobre **un solo** datasource apuntando a `-*`, porque cada objetivo nombra su propio índice en su consulta. Pon el campo de tiempo del datasource a `@timestamp`. Una tabla por elemento allí son los documentos más nuevos en sí; todo lo demás es una agregación por cubos. OpenSearch funciona con el mismo plugin. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara Elasticsearch con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Loki Las veintidós medidas que son eventos y no números, y el horizonte de antigüedad que evita que se rechace un envío. Source: https://jmrplens.github.io/ghchronicle/es/sinks/loki/ ```yaml sinks: loki: url: http://loki:3100/loki/api/v1/push tenant_id: "" labels: job: ghchronicle max_age: 1h batch: 1000 ``` ## Medidas y eventos Parte de lo que informa GitHub es una medida y parte es un evento. "El repositorio tiene 148 estrellas" es una medida. "Alguien le dio una estrella a las 03:03, se publicó esta release, aquel workflow falló en main, se abrió esta alerta" son eventos: cada uno ocurrió una vez, en un momento conocido, y lo que uno quiere después es leerlos en orden y buscarlos, no promediarlos. **Veintidós medidas tienen una representación de evento**: estrellas en ambas direcciones, forks, releases, versiones de paquete publicadas, pull requests, revisiones, hilos de revisión, issues, commits, ejecuciones de workflows, logs de jobs, actividad del repositorio, alertas de Dependabot, análisis de code scanning, el feed de eventos, las notificaciones, las discusiones, las entregas de webhook, los despliegues, las versiones de ruleset y las contribuciones externas. Todo lo demás es un gauge disfrazado y no se envía. Una medida sin representación se descarta en silencio, lo cual está bien para un gauge y mal para un evento que nadie ha llegado a escribir: los despliegues y los hilos de revisión fueron eventos fechados sin línea de log durante meses y nada lo dijo. Por eso ahora toda medida fechada tiene que aparecer en una de las dos tablas de `internal/sink/loki.go`, la de representaciones o la de rechazos, y cada rechazo lleva el motivo por el que no es una línea de log. Una prueba falla ante una medida fechada que no aparezca en ninguna de las dos. ## El formato de línea Cada línea se lee primero como una frase y lleva detrás cada etiqueta y cada campo en logfmt, así que la misma línea es greppable en una terminal y consultable en Grafana sin mantener dos copias de los datos. ```text someone starred acme/telemetry full_name="acme/telemetry" user="someone" starred=1 ``` `batch` es cuántas entradas van en un envío, 1000 salvo que se baje. La etiqueta de stream es `kind`, que es por lo que se filtra primero. ```text {job="ghchronicle", kind="workflow_run"} |= "failure" {job="ghchronicle", kind="job_log"} ``` > **Deja pocas etiquetas más** > > Loki indexa las etiquetas, y una etiqueta de cardinalidad alta cuesta mucho > más que una línea ancha. `labels` en la configuración es para las fijas que > identifican a este colector, no para nada que varíe por punto. ## `max_age`, y por qué la razón no es la obvia Loki rechaza un envío entero cuando una entrada es anterior a `reject_old_samples_max_age`, una semana por omisión, y la mitad de lo que produce este colector es más viejo que eso a propósito: una estrella de 2020, un pull request de 2024. Pero el límite que de verdad muerde es el otro. Loki también rechaza una entrada que esté más atrasada que su ventana de desorden respecto a la entrada más nueva que ya hay en ese stream, unas dos horas por omisión. **Medido contra un Loki 3 real**: una vez que el stream tenía una entrada de las 19:14, una de las 00:35 del mismo día volvió como "entry too far behind". Así que el horizonte se aplica de tres formas: 1. contra el reloj de pared, 2. contra la entrada más nueva de cada stream dentro del lote, 3. contra la entrada más nueva que se ha enviado antes a ese stream. Lo que cae fuera se deja fuera y se cuenta, a nivel de depuración, en vez de costar el envío entero. `max_age` vale una hora por omisión, que está dentro de la ventana por defecto de Loki. Súbelo solo si has subido `out_of_order_time_window` a juego. ## Para qué no sirve Loki Para la historia fechada. De eso se encarga un almacén de métricas, y por eso los dos van juntos y no uno en lugar del otro. Un log responde "qué pasó recientemente, en orden"; una serie temporal responde "cuánto, en qué periodo". ## Los logs de jobs pertenecen aquí `every.joblogs` recoge las últimas cuarenta líneas de cada job fallido de GitHub Actions. Es texto y no una medida, así que el destino de InfluxDB lo excluye por omisión y el exportador de Prometheus lo salta. Loki es donde le corresponde, y la consulta es: ```text {job="ghchronicle", kind="job_log"} ``` Los dashboards exportados no lo muestran, porque un dashboard atado a un datasource no puede consultar dos y quien lo importa puede no tener Loki: llevan un panel de texto, "Where failure output went", con esa consulta. En un Grafana que sí tiene un datasource de Loki, `cmd/publish_dashboard -loki ` publica el dashboard con las líneas leídas de Loki en el lugar de ese panel, las más recientes primero, filtradas por la variable de repositorio del dashboard allí donde la variable del almacén se puede leer como expresión regular. Los pasos están en `dashboards/PUBLISHING.md`. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara Loki con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Telegraf Line protocol enviado a http_listener_v2, y la razón de que esto sea un destino en vez de cien. Source: https://jmrplens.github.io/ghchronicle/es/sinks/telegraf/ ```yaml sinks: telegraf: url: http://telegraf:8186/telegraf username: "" password: "" batch: 5000 ``` Line protocol enviado a la entrada `http_listener_v2` de Telegraf, como `text/plain`, con autenticación básica cuando hay un usuario puesto. > **Una URL sin ruta recibe /telegraf** > > El listener responde 404 a `/` sin decir por qué, así que a una URL con ruta > vacía se le pone la ruta por omisión de la entrada en vez de mandarla a un > sitio que fallará en silencio. ## La entrada correspondiente ```toml [[inputs.http_listener_v2]] service_address = ":8186" paths = ["/telegraf"] data_format = "influx" ``` ## Por qué existe como un solo destino Telegraf tiene una salida para Kafka, Datadog, New Relic, Graphite, Loki, Elasticsearch, Wavefront, Azure Monitor, Google Cloud Monitoring y otras cien. En vez de hacer crecer aquí un destino por cada una, los puntos van a Telegraf como el line protocol que ya habla, y sus propios bloques `[[outputs.*]]` los encaminan. Ese es todo el argumento. Es un destino que alcanza todo lo que alcanza Telegraf, y no le cuesta a este proyecto ninguna dependencia nueva ni ningún formato de cable nuevo. ## Qué pasa con las fechas Telegraf conserva las marcas de tiempo tal como se las dan, así que la historia fechada sobrevive hasta donde permita cada salida. Una salida que sella al recibir, o una que rechaza muestras viejas, la pierde, exactamente igual que harían esos backends si esta herramienta les escribiera directamente. Así que la pregunta no es "¿conserva Telegraf la historia?" sino "¿la conserva la salida que he configurado?". Kafka e InfluxDB sí. Un remote write de Prometheus no. ## Un ejemplo que reparte ```toml [[inputs.http_listener_v2]] service_address = ":8186" paths = ["/telegraf"] data_format = "influx" [[outputs.influxdb_v2]] urls = ["http://influxdb:8181"] token = "$INFLUX_TOKEN" organization = "default" bucket = "github" [[outputs.kafka]] brokers = ["kafka:9092"] topic = "github-metrics" ``` Una pasada, dos destinos, y el colector no sabe de ninguno de los dos. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara Telegraf con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Fichero y stdout Un fichero rotatorio para un agente de envío que ya tengas, el búfer duradero más simple que hay, y line protocol por la salida estándar. Source: https://jmrplens.github.io/ghchronicle/es/sinks/file/ ## Fichero ```yaml sinks: file: path: /var/log/ghchronicle/points.lp format: influx # o json max_bytes: 67108864 keep: 5 ``` Para las instalaciones que ya ejecutan un agente de envío. Telegraf lee line protocol, Promtail y Vector leen JSON, y ninguno necesita que este proceso sepa nada de su backend. Es además el búfer duradero más simple que hay: cuando la base de datos está caída, el fichero sigue teniendo los datos. ### Rotación Por tamaño, con sufijos numerados, así que la retención es un recuento y dos rotaciones en el mismo segundo no pueden chocar. El contador de tamaño se lee del fichero al arrancar, así que un reinicio no lo pone a cero dejando que el fichero crezca sin límite. - **influx** ```text gh_traffic,full_name=acme/telemetry,kind=views,owner=acme,repo=telemetry count=220i,uniques=131i 1788739200000000000 ``` - **json** ```json {"time":"2026-09-07T00:00:00Z","measurement":"gh_traffic","tags":{"full_name":"acme/telemetry","kind":"views","owner":"acme","repo":"telemetry"},"fields":{"count":220,"uniques":131}} ``` Un objeto por línea en JSON, que es la forma que quiere un agente de envío orientado a líneas. ## Salida estándar ```yaml sinks: stdout: true stdout_format: influx # o json ``` Line protocol por la salida estándar, para encadenarlo con Telegraf o para ver qué se escribiría antes de apuntar esto a una base de datos. Es la forma más rápida de responder "¿qué recoge esto en realidad?". ```sh ghchronicle -config config.yaml -once | head -20 ghchronicle -config config.yaml -once | grep gh_workflow_run ``` `stdout_format: json` imprime en su lugar un objeto por línea, la misma forma que escribe el destino de fichero. > **No lo combines con el destino SQL en salida estándar** > > `sinks.sql.path: "-"` también escribe por la salida estándar. Entrelazar > sentencias SQL con line protocol produce un flujo que no puede leer ni psql ni > Telegraf. ## Como búfer El destino de fichero es la respuesta a "qué pasa cuando la base de datos está caída". Úsalo junto al almacén real: la escritura a la base de datos falla y se registra, la pasada continúa, y los puntos están en disco. Reproducirlos después es un `curl` para InfluxDB, o un `psql -f` para el destino SQL, y converge en vez de duplicar porque los puntos llevan sus propias marcas de tiempo. ```sh curl -s -XPOST "http://localhost:8181/api/v2/write?org=default&bucket=github&precision=ns" \ -H "Authorization: Token $INFLUX_TOKEN" \ --data-binary @/var/log/ghchronicle/points.lp ``` ## Permisos El volcado se crea `0600` dentro de un directorio `0750`. Una pasada sobre una cuenta privada mete en él nombres de repositorios privados, severidades de Dependabot y líneas enteras de logs de jobs, así que no se crea legible para toda la máquina solo porque vaya a venir un agente de envío a leerlo. ### Dejar que el agente de envío lo lea Telegraf, Promtail, Vector y Fluent Bit leen todos con su propio usuario. Ninguno documenta un modo obligatorio, y todos documentan el mismo remedio, que es un grupo: sus propias respuestas dicen `usermod -aG adm`. Así que la concesión es tuya, y son dos órdenes: ```sh usermod -aG ghchronicle telegraf # el agente de envío entra en el grupo del servicio chmod 0640 /var/log/ghchronicle/points.lp # o créalo así de antemano ``` La concesión se mantiene. El destino nunca cambia el modo de un fichero que ya existe, y una rotación crea el sustituto con el modo del fichero que está renombrando, así que el `chmod` no se deshace la primera vez que el volcado se llena. > **El fallo que esto evita es silencioso** > > Un agente de envío que no puede abrir el fichero que sigue no lo anuncia, y > este proceso sigue escribiendo en un descriptor que ya tenía abierto. Ninguno > de los dos registra nada, así que el primer síntoma es un dashboard que se > paró hace días. Si prefieres que el volcado pertenezca al grupo del propio agente de envío, haz que el directorio también sea suyo y ponle el bit setgid, que es lo que hace que todo fichero creado dentro herede el grupo, rotaciones incluidas: ```sh install -d -o ghchronicle -g telegraf -m 2750 /var/log/ghchronicle ``` Eso decide de qué grupo es el volcado, no qué puede hacer ese grupo con él, así que el `chmod 0640` de arriba sigue siendo la otra mitad. Crear el directorio a mano compensa vayas por donde vayas: el que crea este proceso cuando no existe es `0750` menos lo que se lleve la umask, y un agente de envío que no puede atravesar el directorio falla tan en silencio como uno que no puede leer el fichero. Una ACL no se arrastra, porque pertenece al fichero sobre el que se puso y una rotación crea otro, así que concede por grupo en lugar de con `setfacl` sobre el volcado. Lo que una rotación sí arrastra es el modo, no el propietario: este proceso no puede hacer `chown` de un fichero a un usuario que no es. El registro de escrituras que hay detrás de `sinks.dedupe_file` es el caso contrario y recibe la respuesta contraria: nada salvo este proceso debe leerlo, así que se escribe `0600` y cada guardado lo devuelve a `0600`. ### Dónde llega a poder escribir Bajo la unidad de systemd blindada, el directorio tiene que estar en `ReadWritePaths`. En un contenedor tiene que ser escribible por el uid 65532. Ambos son el mismo error con dos ropajes: al proceso se le permite escribir casi en ningún sitio a propósito. ## Por dónde seguir - [Elegir almacén](/ghchronicle/es/sinks/) compara el destino de fichero con los otros nueve, y lleva el registro de escrituras que todos comparten. - [Los dashboards](/ghchronicle/es/dashboards/) dice cuál de los cinco se dibuja contra cada almacén, y en qué se convierte un panel que un almacén no puede responder. --- # Importar Cinco dashboards de Grafana generados, uno por almacén, y cómo importar cada uno. Source: https://jmrplens.github.io/ghchronicle/es/dashboards/ Cinco dashboards, todos en inglés, todos en el formato de exportación compartible de Grafana: el datasource es un marcador `${DS_...}` y el bloque `__inputs` pide a quien importa que elija el suyo. | Fichero | Paneles | Almacén | | -------------------------------- | ------- | ------------------------------------------------------------- | | `ghchronicle-influxdb.json` | 152 | InfluxDB 3, consultado con SQL | | `ghchronicle-prometheus.json` | 152 | Prometheus | | `ghchronicle-postgres.json` | 152 | PostgreSQL o TimescaleDB, desde el destino SQL | | `ghchronicle-graphite.json` | 152 | Graphite, desde el destino de Graphite | | `ghchronicle-elasticsearch.json` | 152 | Elasticsearch u OpenSearch, desde el destino de Elasticsearch | Las cinco tienen los mismos paneles en el mismo orden. Lo que cambia es cuántos puede responder el almacén que hay detrás de cada una. Cada celda son los paneles que ese almacén responde con una consulta, sobre los paneles de esa sección. Un panel que un almacén no puede responder se publica como panel de texto con el mismo título, así que todas las dashboards tienen los mismos 152 paneles; los 2 que son prosa en los cinco quedan fuera. | Sección | InfluxDB | PostgreSQL | Elasticsearch | Graphite | Prometheus | Paneles | | --- | --- | --- | --- | --- | --- | --- | | Overview | 4 | 4 | 4 | 4 | 4 | 4 | | Lifetime | 5 | 5 | 5 | 5 | 5 | 5 | | Audience | 6 | 6 | 6 | 6 | 5 | 6 | | Stars and forks | 5 | 5 | 5 | 5 | 5 | 5 | | Contributions | 11 | 11 | 10 | 10 | 6 | 11 | | Pull requests and issues | 14 | 14 | 14 | 14 | 10 | 14 | | Continuous integration | 14 | 14 | 13 | 13 | 10 | 14 | | Code | 9 | 9 | 8 | 8 | 8 | 9 | | Planning and community | 10 | 10 | 10 | 10 | 8 | 10 | | Delivery and access | 13 | 13 | 13 | 13 | 13 | 13 | | Releases | 4 | 4 | 3 | 4 | 2 | 4 | | Security | 14 | 14 | 14 | 13 | 13 | 14 | | Cost | 6 | 6 | 6 | 6 | 6 | 6 | | Activity | 9 | 9 | 9 | 8 | 7 | 9 | | Inventory | 16 | 16 | 15 | 15 | 14 | 16 | | Profile and sponsorship | 8 | 8 | 8 | 8 | 8 | 8 | | The collector itself | 2 | 2 | 2 | 2 | 2 | 2 | | **Total** | **150** | **150** | **145** | **144** | **126** | **150** | ![El dashboard de InfluxDB sobre noventa días de la base de datos de demostración: el selector de repositorio y el rango arriba, el Overview con el distintivo de ghchronicle y cuatro grupos de tarjetas que dicen 5 repositorios con 350 estrellas y 51 forks, 37,5 mil visitas con 21,1 mil visitantes únicos y 19,6 mil clones, 117 seguidores y 58 seguidos con 4 patrocinadores y 2 patrocinados, y 3,22 mil contribuciones en 7,78 años, después la cabecera plegada de Lifetime y la sección Audience con visitas, visitantes únicos y clones por día, los referrers principales, las rutas más visitadas y la amplificación de clones](../../../../assets/dashboard-influxdb-demo.png) La cuenta de esa captura es la inventada que usan todas las capturas de esta documentación, `acme` y cinco repositorios, descrita junto a [lo que muestran los paneles](/ghchronicle/es/dashboards/panels/). Todas las secciones salvo el Overview se envían plegadas, por eso Lifetime es ahí una cabecera. ## Importar desde la interfaz 1. En Grafana, ve a **Dashboards**, luego **New**, luego **Import**. 2. Sube el `ghchronicle-.json` del almacén que estés usando. 3. Elige el datasource que pide Grafana. - **InfluxDB** El datasource de InfluxDB 3 para la base de datos en la que escribe el destino, **en modo SQL**. - **Prometheus** El Prometheus que consulta el exportador de ghchronicle. - **PostgreSQL** El datasource de PostgreSQL para la base de datos a la que se enviaron las sentencias del destino SQL. TimescaleDB es el mismo datasource con el interruptor de TimescaleDB activado; las consultas no cambian. - **Graphite** El Graphite en el que escribe el destino. Las rutas suponen el prefijo por omisión, `github`, y las funciones necesitan Graphite 1.1 o posterior. - **Elasticsearch** Un datasource de Elasticsearch cuyo patrón de índice sea `ghchronicle-*` y cuyo campo de tiempo sea `@timestamp`. Un solo datasource sirve a todos los paneles, porque cada objetivo nombra su propio índice en la consulta. OpenSearch funciona con el mismo plugin. ## Importar con la API Nombra la entrada que declara el fichero: ```sh curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"dashboard\": $(cat ghchronicle-influxdb.json), \"inputs\": [ {\"name\":\"DS_INFLUXDB\",\"type\":\"datasource\",\"pluginId\":\"influxdb\",\"value\":\"\"}], \"overwrite\": true}" \ "$GRAFANA/api/dashboards/import" ``` Las entradas son `DS_INFLUXDB` (`influxdb`), `DS_PROMETHEUS` (`prometheus`), `DS_POSTGRES` (`grafana-postgresql-datasource`), `DS_GRAPHITE` (`graphite`) y `DS_ELASTICSEARCH` (`elasticsearch`). ## El uid Cada fichero lleva un `uid` fijo (`ghchronicle-`). Eso es deliberado para una importación desde el repositorio, donde un uid estable significa una URL estable y una reimportación actualiza en el sitio en vez de duplicar. Importar dos de estos en un mismo Grafana no da problemas, porque los uid difieren por almacén y no pueden chocar. ## Son generados, nunca editados a mano `cmd/internal/dashboards` tiene una lista ordenada de secciones y paneles, y cada panel lleva un conjunto de consultas por almacén. El generador elige un conjunto y emite el JSON, así que todos los ficheros tienen los mismos paneles en los mismos sitios con los mismos títulos, y se niega a escribir ficheros cuyas disposiciones se hayan separado. ```sh go run ./cmd/gen_dashboards # escribe los cinco ficheros go run ./cmd/gen_dashboards -check # no escribe nada, falla si están caducos ``` Edita `cmd/internal/dashboards/sections_*.go`, no el JSON. `panels.go` tiene los constructores de panel que usa, `query.go` los ayudantes de consulta de cada almacén, y `stores.go` solo elige un conjunto de consultas y un datasource. > **Ningún generador se da por bueno sin ejecutar las consultas** > > La API cruda de la base de datos acepta cosas que luego el plugin de Grafana no > sabe dibujar, así que los verificadores pasan por la propia ruta de consulta de > Grafana allí donde existe un datasource. > > ```sh > GRAFANA_TOKEN=... go run ./cmd/check_dashboards influxdb > GRAFANA_TOKEN=... go run ./cmd/check_prometheus > go run ./cmd/check_postgres > ``` > > `check_dashboards` informa de cada panel como correcto, vacío o fallido. > `check_prometheus` comprueba además cada nombre de métrica contra un volcado > real del propio `/metrics` del exportador, porque una errata en un nombre de > métrica no es un error de sintaxis: PromQL la analiza tan contento y no > devuelve nada para siempre. > > Los dos, y `cmd/publish_dashboard`, leen dos variables: `GRAFANA_TOKEN` para la > credencial y `GRAFANA_URL` para el servidor. El valor compilado por omisión es > `http://localhost:3000`, que es la dirección con la que viene Grafana, así que > cualquier otra hay que nombrarla: el primer síntoma de no nombrarla es una > conexión rechazada. ## Publicar en el directorio de Grafana Los ficheros ya tienen la forma que exige el directorio: `__inputs` declara el datasource que quien importa debe elegir, `__requires` nombra la versión de Grafana y el plugin, y no hay clave `id`, que el directorio asigna al publicar. Conserva nombres de listado distintos, porque cinco dashboards con el mismo título son indistinguibles en los resultados de búsqueda: "ghchronicle for InfluxDB", "ghchronicle for Prometheus", "ghchronicle for PostgreSQL and TimescaleDB", "ghchronicle for Graphite", "ghchronicle for Elasticsearch and OpenSearch". Publicar de nuevo contra el mismo listado añade una revisión en vez de reemplazarlo, así que una regeneración que cambie paneles es una revisión nueva de los mismos cinco listados, no cinco listados nuevos. Eso es lo que necesita saber quien lee. El paso a paso, con los nombres de los listados, qué debe mostrar cada captura y qué hacer cuando rechazan una revisión, es tarea de quien mantiene el proyecto y vive en [`dashboards/PUBLISHING.md`](https://github.com/jmrplens/ghchronicle/blob/main/dashboards/PUBLISHING.md) dentro del repositorio. --- # Qué muestran Las diecisiete secciones y sus ciento cincuenta y dos paneles, una captura de cada una, y qué cambia cuando el almacén no puede responder. Source: https://jmrplens.github.io/ghchronicle/es/dashboards/panels/ Un dashboard, renderizado una vez por almacén. Diecisiete secciones, ciento cincuenta y dos paneles, en los mismos sitios y con los mismos títulos sea cual sea la base de datos elegida. Abajo hay una captura por sección, en el orden en que el dashboard las coloca. Todas las secciones salvo Overview se abren plegadas. Abiertas, las siete primeras eran veintitrés pantallas de teléfono antes de que el lector supiera que había diez más; plegadas, la segunda pantalla de un teléfono es el índice de las dieciséis, cada una a un toque, y Grafana conserva en la URL lo que se abrió. En un escritorio cuesta un clic por sección, y la primera carga pide los paneles de Overview y no todos. > **Las capturas de sección son de una base de datos de demostración** > > Todas las capturas de sección de abajo son del dashboard de InfluxDB sobre > noventa días de una base de datos de demostración, generada para que todos los > paneles tengan algo que dibujar. Una cuenta real deja varios legítimamente > vacíos: no ha habido ninguna estrella esta semana, ningún workflow fallido en > la ventana, ningún hito abierto, y una captura de un panel vacío no enseña > nada. La base de datos la llenó un generador que no forma parte de este > repositorio, y tomó cada medida, etiqueta, campo y tipo de columna de la > [referencia de medidas](/ghchronicle/es/collectors/measurements/) y de una > base de datos en producción, así que la forma es real. Lo único inventado es > la cuenta: `acme` y cinco repositorios que son claramente ejemplos. La única > captura que no sale de ahí es la última de esta página, del dashboard de > Prometheus, que viene de la suite end-to-end en contenedores, y el texto que > la acompaña lo dice. Ninguna captura de esta página es de una cuenta real. ## Overview Una cabecera y no un panel: la marca, grande y centrada, el nombre debajo, y bajo el nombre un botón a esta documentación y otro al código, sobre la propia página y sin caja alrededor. Después cuatro grupos de cifras: Repositories (repositorios, estrellas, forks), Traffic in range (visitas, visitantes únicos, clones), Community (seguidores, seguidos, patrocinadores, patrocinados) y Account (contribuciones del último año, edad de la cuenta, vigilados, estrellas dadas, gists, paquetes). Un grupo es un panel de estadística con varios valores y no una tarjeta por cifra: en un escritorio se lee como la fila de tarjetas a la que sustituye, y en un teléfono, donde cada panel es una columna, dieciséis tarjetas eran cuatro pantallas de cifras sueltas y cuatro grupos son una. La variable de repositorio que hay al lado filtra todas las secciones a la vez. Estrellas y forks suman la fila más reciente de cada repositorio y no todas las del rango, porque ambas son estado actual y una suma sobre el rango contaría cada pasada. ![La fila Overview: el selector de repositorio y el rango de 90 días en la parte superior, el distintivo de ghchronicle con sus botones Docs y Source, y luego cuatro grupos de tarjetas con 5 repositorios, 350 estrellas y 51 forks; 37,5 mil visitas, 21,1 mil visitantes únicos y 19,6 mil clones; 117 seguidores, 58 seguidos, 4 patrocinadores y 2 patrocinados; y una cuenta con 3,22 mil contribuciones y 7,78 años de antigüedad](../../../../assets/dashboards/overview.png) Lee `gh_account`, `gh_repo`, `gh_traffic` y `gh_contributions_total`. ## Lifetime Seis cifras que son ciertas desde que existe la cuenta, en un solo panel, y una fila por repositorio con su vida entera: pull requests fusionadas y revisadas alguna vez, commits totales, issues abiertas, lo fusionado en repositorios ajenos y lo comentado allí. ![La sección Lifetime: un grupo de tarjetas con 1,18 mil pull requests fusionadas, 386 revisadas, 6,68 mil commits, 148 issues abiertas, 27 fusionadas fuera y 96 comentarios fuera; la tabla de todos los repositorios de siempre con commits, fusionadas, issues, releases, estrellas, ramas y etiquetas; y debajo los repositorios creados, el único repositorio archivado y las ejecuciones de workflow de siempre como una barra por repositorio](../../../../assets/dashboards/lifetime.png) Todas las demás secciones cuentan filas dentro del rango del dashboard. Estas no: las cuenta GitHub, en una búsqueda o un campo de GraphQL cada una, y el colector guarda la respuesta como una sola fila. Por eso son instantáneas y por eso ya son correctas en la primera pasada de una instalación nueva, cosa que un contador acumulado por esta herramienta no sería. Es además la forma que un almacén puede responder sin leer todo lo que guarda: InfluxDB 3 Core rechaza una consulta que abra más ficheros que su tope, cuarenta mil donde esto se midió, y "cuántas en total" a partir de una fila por hecho es exactamente esa consulta. Lee `gh_account_total` y `gh_repo_total`. ## Audience Visitas, visitantes únicos y clones en el tiempo, los referrers y las rutas principales. GitHub sirve catorce días y reescribe la ventana entera en cada pasada, así que las series se extienden tan atrás como lleve corriendo el colector y no catorce días. Los referrers y las rutas no llevan fecha propia, de modo que son la instantánea más reciente de esa ventana y no una serie. ![La sección Audience: visitas, visitantes únicos y clones por día como barras apiladas por repositorio, la tabla de referrers principales encabezada por Google con 250 visitas, la tabla de rutas principales con el título de cada ruta, y la tabla de amplificación de clones con los clones por clonador, alrededor de 1,3 en todos los repositorios](../../../../assets/dashboards/audience.png) > **Los clones no cuentan personas** > > La integración continua clona un repositorio miles de veces por cada visita > humana. Medido en un repositorio, setenta y tres clones por cada clonador > único. El último panel de la sección divide uno entre otro, que es la única > cifra que separa adopción de maquinaria. Lee `gh_traffic`, `gh_traffic_referrer` y `gh_traffic_path`. ## Stars and forks Estrellas ganadas en el tiempo, la curva acumulada, estrellas por repositorio, las cincuenta estrellas más recientes con el usuario y el momento, y forks en el tiempo. Se nombran los ocho repositorios que más ganaron en el rango y el resto van a `other`: de los diecisiete que ganaron alguna estrella en dos años, nueve ganaron entre una y tres, y diecisiete entradas de leyenda escondían un tercio del trazado. La curva acumulada se remonta a la primera estrella porque el recorrido de stargazers recogió cada una con su propio `starred_at`, no porque el colector lleve corriendo tanto tiempo. Los forks en el tiempo se dibujan igual desde `gh_fork`, cada fork con la fecha en que se hizo, así que también se remontan más allá del día en que arrancó el colector en vez de empezar en la primera instantánea. Las dos curvas se dibujan hasta los dos bordes del rango: un recuento acumulado sobre filas fechadas solo tiene punto donde hay fila, así que una curva de forks sobre un mes tranquilo era una línea del primer fork al último y nada a los lados, lo que se lee como que la recogida se paró. Cada extremo lleva un bucket de cero, así que la línea mantiene su valor hasta el final del rango. ![La sección Stars and forks: estrellas ganadas por día como barras por repositorio, la curva acumulada subiendo hasta 350, estrellas por repositorio como gráfico de barras, una tabla de estrellas recientes con marcas de tiempo y usuarios, y forks en el tiempo subiendo hasta 51](../../../../assets/dashboards/stars-and-forks.png) Lee `gh_star` y `gh_repo`. ## Contributions El calendario de contribuciones como serie y después como la rejilla que dibuja GitHub, una columna por semana y una fila por día de la semana, con el tono de cada celda en función del recuento de ese mismo día; commits por semana, los totales del último año y la mezcla que forman esos totales, los cuatro tipos de contribución como partes de su suma, que es el radar que dibuja el perfil, al lado de la rejilla como lo pone el perfil; commits por hora del día y por día de la semana, commits por repositorio y una fila por cada año pasado. La rejilla es un panel de historial de estado sobre un campo por día de la semana, porque Grafana no tiene panel de calendario y ese es el único panel del núcleo que dibuja una rejilla de celdas coloreadas por valor. Es la rejilla de GitHub medida en la página del perfil el 2026-09-14 y copiada: diez unidades de ancho por cinco dibujan la celda del propio perfil, diez píxeles en cuadro en una ventana de 1920, con una separación de tres a cuatro píxeles a lo ancho y de cinco a seis a lo alto, donde la del perfil son tres en ambos ejes; los cuatro verdes son los del tema oscuro de GitHub leídos de esa misma página ese mismo día, sobre el gris de un día vacío; tres filas llevan nombre, lunes, miércoles y viernes, como las nombra el perfil; y el tono son los quintos del día más activo del año, la regla que reprodujo los 366 cuadrados del perfil al aplicarla a los recuentos del propio GitHub. El panel mantiene ese año sea cual sea el rango del dashboard, igual que la rejilla del perfil es siempre de un año: a cinco años esas mismas 53 columnas serían 262 de seis píxeles, y a una semana, dos barras. Tres cosas que un panel del núcleo de Grafana no puede copiar. El nombre del mes sobre la primera semana de cada mes: un historial de estado construye sus propias marcas del eje x, una cada varias columnas, así que una marca siempre cae en una semana y el paso depende del ancho en píxeles del panel, que a tres semanas escribe el mismo mes dos veces; en su lugar las marcas nombran el domingo en que empieza cada columna, que no se repite en ninguna otra. El cuadrado a cualquier tamaño: la celda es una fracción de la banda que recibe su fila, así que el cuadrado aguanta a 1920 y otra vez a 768, donde el panel ocupa todo el ancho de un teléfono, y entre medias se mantienen los diez píxeles de alto mientras el ancho se estrecha, ocho a 1600, siete a 1280, cinco a 430; y donde el panel está en el dashboard se estira hasta una barra alta al maximizarlo, 27 por 67 píxeles en una ventana de 1920 por 900 y 27 por 84 en una más alta. Y un tooltip que diga el día y el recuento, porque la celda se colorea por el valor que lleva, así que ese valor tiene que ser el tono y una columna es una semana. Los dos punch cards son estado actual y no historia: cada pasada reescribe la rejilla entera, así que una suma sobre un rango cuenta todas las pasadas y lo que se lee en esos paneles es la forma. ![La sección Contributions: contribuciones por día, el calendario de contribuciones dibujado como la propia rejilla de GitHub para el último año con sus etiquetas Mon, Wed y Fri, la mezcla de contribuciones con un 70,1 por ciento de commits, commits por semana con los propios superpuestos, la tabla de totales, el histograma por hora con el pico en la tarde, las barras por día de la semana, commits por repositorio, la tabla de cinco años, commits por día y repositorio, y el gráfico que separa los commits que el perfil esconde en propios y ajenos, públicos y privados](../../../../assets/dashboards/contributions.png) Lee `gh_contribution_day`, `gh_commits_week`, `gh_contributions_total`, `gh_commit_punchcard`, `gh_contribution_repo` y `gh_contribution_year`. ## Pull requests and issues Catorce paneles, y la sección donde la recolección por elemento se paga sola. Fusionados, tiempo hasta fusionar, tiempo hasta la primera revisión, issues cerrados, tiempo hasta cerrar y líneas cambiadas como un grupo de seis valores; luego el mismo reparto por estado en el tiempo, las pull requests fusionadas más grandes y los desgloses por autor, por repositorio y por revisor. Un elemento que sigue abierto se escribe una vez al día mientras lo esté, y esas filas se quedan cuando se cierra, así que los paneles fechados cuentan los abiertos como números distintos bajo "Open that day" y los dibujan como una línea junto a la pila de lo que se cerró, y las tablas de "open the longest" leen cada elemento de su fila más reciente. ![La sección Pull requests and issues: un grupo de tarjetas con 467 pull requests fusionadas, 10,3 horas hasta fusionar, 9,81 horas hasta la primera revisión, 137 issues cerradas, 1,75 días hasta cerrar una y 75 líneas por pull request; pull requests e issues por día separados por estado; tiempo hasta fusionar y tamaño de la pull request en el tiempo; las fusionadas más grandes con su título, asociación y etiquetas; pull requests por autor; la tabla por repositorio; la tabla de revisores encabezada por review-bot con 209 revisiones; revisiones por día; las dos tablas de los que llevan más tiempo abiertos; hilos de revisión por día separados en bot y humano; y la tabla de deuda de revisión](../../../../assets/dashboards/pull-requests-and-issues.png) > **Cuidado al leer el tiempo hasta la primera revisión** > > El panel cuenta la primera revisión de alguien que no es el autor ni un bot, > así que en una cuenta revisada solo por bots dice No data en vez de los pocos > segundos de los bots. Medido, nueve de cada diez pull requests tuvieron una > revisión de un bot en menos de un minuto; la tabla Reviewers lo enseña, con > cada bot marcado como tal y las respuestas del propio autor en una sola fila. Lee `gh_pull_request`, `gh_pull_request_review` y `gh_issue`. ## Continuous integration Catorce paneles: número de ejecuciones, tasa de éxito sobre las ejecuciones que acabaron bien o mal, las canceladas y omitidas al lado, duración, espera en cola, almacenamiento de artefactos y tamaño de la caché, las siete como un solo grupo; luego las mismas cifras en el tiempo, los workflows, los jobs y los pasos más lentos, y luego los seis que dicen qué hacer al respecto: minutos gastados en ejecuciones que fallaron, los workflows que fallan siempre, el paso que falla en vez del job, los workflows declarados que nunca se han ejecutado, los artefactos creados en el tiempo y cuánto del almacenamiento de artefactos se llegó a contar. ![La sección Continuous integration: un grupo de tarjetas con 1,51 mil ejecuciones, un 90,2 por ciento de éxito, 84 ejecuciones sin resolver, 15,2 minutos por ejecución, 34 segundos de espera en cola, 141 mebibytes de artefactos y 3,08 gibibytes de caché; ejecuciones por día según su resultado; duración y espera en cola en el tiempo; almacenamiento de artefactos en el tiempo con una línea por repositorio; y las tablas de workflows, jobs más lentos, pasos más lentos, minutos gastados en ejecuciones fallidas, workflows que fallan una y otra vez, pasos que fallan y artefactos](../../../../assets/dashboards/continuous-integration.png) La espera en cola es una cifra de job. La de la ejecución mete la espera dentro de la duración, así que una ejecución que tardó veinte minutos porque un job esperó dieciocho por un runner es idéntica a una que pasó dieciocho ejecutando. Dos de ellos son los primeros que hay que mirar. "Workflows that keep failing" no va de flakiness: medido, dos workflows habían fallado en todas y cada una de sus ejecuciones, decenas de ejecuciones cada uno, y nadie los había apagado. "Workflows that never ran" es la otra cara de lo mismo, y necesita `gh_workflow` y `gh_workflow_run` a la vez, que es por lo que es el único panel aquí que solo pueden responder los dos almacenes SQL. "Artifact storage counted" existe porque el total es un suelo. GitHub dice cuántos artefactos tiene un repositorio, el colector anota cuántos recorrió de verdad, y cuando el segundo es menor el tamaño vivo se queda corto: en un repositorio de aquí, por un factor de cincuenta y seis. Lee `gh_workflow_run`, `gh_workflow_job`, `gh_workflow_step`, `gh_workflow`, `gh_artifact`, `gh_artifact_total` y `gh_actions_cache`. ## Code Commits, líneas añadidas y eliminadas, la proporción de commits firmados, líneas cambiadas en el tiempo, actividad del repositorio por tipo, commits por autor y por firma, y los force push con quién los hizo y en qué rama. ![La sección Code: un grupo de tarjetas con 752 commits, 49,0 mil líneas añadidas, 20,0 mil eliminadas y un 55,6 por ciento firmados en los últimos 90 días; líneas añadidas y eliminadas por día; actividad del repositorio por tipo; la tabla de commits por autor; commits por firma; la tabla de force push; commits por día según el estado de la puerta de CI; la tabla de checks que no son de Actions; y la tabla de commits que quedan detrás de una rama en rojo](../../../../assets/dashboards/code.png) `signature` distingue `unsigned`, que significa que no había firma en absoluto, de una firma que no se pudo verificar. Son hechos distintos y el gráfico de barras los mantiene separados. Los tres últimos paneles van de la puerta, no del código. "Commits by gate state" no dice lo mismo que una ejecución fallida: una ejecución dice que falló un job, el rollup dice que el commit salió en rojo, y en la cuenta medida veintisiete de cincuenta commits de la rama principal salieron así. "Checks that are not Actions" guarda lo que las dos secciones anteriores no ven, el servicio de calidad de código y el bot de dependencias. Y "Commits behind a red branch" une los dos por el hash del commit, que es el único panel que justifica guardar `oid` y `head_sha`. > **Esta sección tiene su propio rango** > > Los seis paneles sobre `gh_commit` están fijados a noventa días diga lo que > diga el selector de tiempo, y Grafana lo indica junto a cada título. Una fila > por commit es un fichero Parquet por commit en InfluxDB 3 Core, que rechaza > una consulta que abriría más de cuarenta mil: medido, un rango de 270 días > abría justo por debajo de ese tope y respondía, 300 días se rechazaban, y el > rechazo le llega al lector como un panel vacío y no como un error. Noventa > días abren un tercio del tope, lo que deja sitio para que la historia siga > creciendo. Lee `gh_commit`, `gh_commit_check`, `gh_workflow_run` y `gh_repo_activity`. ## Planning and community Las etiquetas con cuánto se usa cada una, los hitos con su progreso, los forks ganados en el tiempo, la lista de forks, las discusiones por categoría y según estén respondidas o no, y junto a ese recuento las discusiones mismas: las cincuenta más recientes una a una, con su categoría, cuántos comentarios tienen, si están respondidas y un enlace a cada una, sea cual sea el rango, porque una cuenta tiene un puñado y un rango de un mes las escondía todas menos una bajo una fila que decía "Ideas". ![La sección Planning and community: la tabla de etiquetas encabezada por dependencies, la tabla de hitos con barras de progreso, forks por día, la lista de forks con quién forkeó y si llegó a subir algo, la tabla de discusiones por categoría, las discusiones más recientes con su estado de respuesta, las transiciones de issues por día, los comentarios dejados por repositorio, las respuestas en discusiones y la tabla de respuestas fuera](../../../../assets/dashboards/planning-and-community.png) La lista de forks lleva `advanced`, que es lo que separa una derivación real de un marcador. La mayoría de los forks son marcadores. Otros tres paneles van de la conversación y no del plan. Las transiciones por día son cuándo se etiquetó, se cerró, se reabrió o se renombró algo, que el estado de una issue no registra: una reapertura no existe en ninguna otra medida. Las dos tablas de comentarios cuentan lo escrito en cualquier sitio, incluidos los repositorios que la cuenta no posee, que es donde ocurre casi todo: cuando se midió, los comentarios dejados en repositorios ajenos superaban en más de un orden de magnitud a las discusiones de dentro de la cuenta. Junto al recuento por repositorio de comentarios en discusiones, los comentarios mismos: cada uno dejado en una discusión de un repositorio ajeno, del más reciente al más antiguo, con si el mantenedor lo aceptó como respuesta y un enlace al comentario en su hilo. Lee `gh_label`, `gh_milestone`, `gh_fork`, `gh_discussion`, `gh_issue_event`, `gh_issue_comment` y `gh_discussion_comment`. ## Delivery and access La tasa de fallo de los webhooks, las entregas por código de estado, los endpoints ordenados por fallos, los rulesets y las claves de despliegue con cuánto tiempo llevan sin usarse. ![La sección Delivery and access: un medidor con 20,1 por ciento de fallo de webhooks, entregas por hora según el código de estado, la tabla de endpoints con legacy.example.net fallando la mayoría de sus entregas, la tabla de rulesets, la de claves de despliegue, el panel de texto sobre la salida de los jobs fallidos, los webhooks configurados, las tablas de entornos y de ramas estancadas, las reglas de protección de rama por repositorio, las reglas de ruleset con sus excepciones, los cambios de ruleset, los despliegues por día y entorno, y la tabla de despliegues por entorno](../../../../assets/dashboards/delivery-and-access.png) Los webhooks fallan en silencio. Medido, un hook llevaba respondiendo 403 en setenta y ocho de sus últimas cien entregas y no había nada en ninguna parte que lo dijera. Solo se guarda el host de la URL del webhook, porque la ruta suele llevar un secreto. Un panel es texto en los ficheros exportados, "Where failure output went", porque la salida de un job fallido es texto y su sitio es un almacén de logs, y quien importa el dashboard puede no tener ninguno: un dashboard atado a un solo datasource no puede consultar dos. Publicado en un Grafana que sí tiene un datasource de Loki, con `cmd/publish_dashboard -loki `, el mismo panel dibuja las últimas líneas de cada job fallido leídas de Loki, las más recientes primero, con el workflow, el job y la ejecución de cada línea en su cola logfmt. La variable de repositorio se aplica en los dashboards de InfluxDB, PostgreSQL y Prometheus; las variables de Graphite y Elasticsearch usan el asterisco de glob como valor de "All", que no es una expresión regular, así que allí el panel muestra todos los repositorios y lo dice. Los dos últimos son inventarios y no tráfico. "Webhooks configured" existe porque la tabla de endpoints se construye desde las entregas, así que un hook que nunca ha entregado nada no aparece en ella, y un hook activo sin tráfico es justo la fila interesante. "Environments" le hace la pregunta de las claves de despliegue a los destinos de despliegue: un entorno de aquí llevaba mil ciento setenta y siete días sin tocarse. Lee `gh_webhook_delivery`, `gh_webhook`, `gh_ruleset`, `gh_deploy_key` y `gh_environment`. ## Releases Descargas totales, descargas por release y cada asset con su tamaño y su propio recuento de descargas. ![La sección Releases: 11,6 mil descargas en 14 releases, un gráfico de barras de descargas por etiqueta de release, la tabla de assets con descargas y tamaños por asset, y la tabla de descargas ganadas en el rango](../../../../assets/dashboards/releases.png) Los tres primeros son estado actual. GitHub da un total acumulado por asset y nunca una historia, así que la serie que necesitaría un panel de descargas por día no existe para recogerla. El cuarto panel es lo que sí se puede recuperar de ahí: la diferencia entre el primer y el último valor dentro del rango, que es lo que cada asset ganó de verdad. Un día de eso en la cuenta medida mostró que el noventa y siete por ciento de las descargas van a un único binario de Linux y a su fichero de checksums, que es un instalador y no una persona. Lee `gh_release` y `gh_release_asset`. ## Security Alertas abiertas de Dependabot y de code scanning, los desgloses por severidad y por ecosistema, las alertas abiertas en el tiempo, la tabla de funciones y el tiempo que se tarda en resolver una alerta según su severidad. ![La sección Security: la tarjeta de alertas abiertas con 25 de Dependabot y 29 de code scanning, alertas por severidad y por ecosistema, alertas abiertas por severidad en el tiempo, la tabla de funciones de seguridad con on y off por repositorio, ejecuciones de code scanning por día y herramienta, la tabla de tiempo hasta resolver con cada aviso y su CVSS, las alertas de escaneo resueltas, los resultados por herramienta, las alertas abiertas más antiguas, las tablas de ajustes de seguridad y de configuración por defecto de code scanning, los permisos del token de los workflows y la tabla de rotación de secretos](../../../../assets/dashboards/security.png) La tabla de funciones es lo que distingue "no hay alertas" de "la función está apagada". Sin `gh_security_feature`, un repositorio con Dependabot desactivado es indistinguible de uno que no tiene nada que arreglar. Los recuentos de alertas son estado actual, reescritos en cada pasada, así que todos los paneles de aquí toman la fila más reciente de cada serie y suman esas, no el rango. Sumar el rango cuenta cada alerta una vez por pasada: antes de arreglarlo la tarjeta marcaba 3,61 mil donde la cuenta tiene unas pocas decenas. Los dos últimos paneles son información nueva, no otra vista. El tiempo hasta resolver una alerta de code scanning sale de unas fechas que se descargaban y se tiraban, así que hasta ahora solo existía el recuento de abiertas; de treinta y cuatro alertas de un repositorio, treinta estaban arregladas y tres descartadas, y las treinta y tres eran invisibles. "Scan results by tool" dice qué encontró un análisis en vez de que se ejecutó, que es lo que explica un salto en el número de alertas. Lee `gh_dependabot_alert`, `gh_dependabot_alert_item`, `gh_code_scanning_alert`, `gh_code_scanning_alert_item`, `gh_code_scanning_analysis` y `gh_security_feature`. ## Cost Bruto, la parte cubierta por el plan, lo que de verdad se facturó, los minutos de Actions, el coste en el tiempo por producto, los minutos en el tiempo por SKU y el uso por repositorio. ![La sección Cost: un grupo de tarjetas con 360 dólares brutos, 249 cubiertos por el plan, 111 facturados de verdad y 21,9 mil minutos de Actions; coste por día y producto; minutos por día y SKU; la tabla de uso por repositorio con SKU, cantidad, unidad y precio; las entradas de caché por clave; y la caché frente al tope](../../../../assets/dashboards/cost.png) Bruto, descuento y neto se guardan los tres en vez de derivar uno de los otros, porque el neto no siempre es cero y el descuento es donde aparece el crédito mensual. El precio por unidad está en la tabla por el mismo motivo: es lo que explica que treinta mil minutos de macOS cuesten más que doscientos cuarenta mil de Linux. Un repositorio puede salir en esa tabla y en ningún otro panel, porque la lista que factura y la que se recorre no son la misma. Los dos paneles de caché van del techo. GitHub limita cada repositorio a diez gigabytes y expulsa la entrada menos usada al pasarlo, así que la barra es cada repositorio contra ese tope y el panel se lee por lo que queda de margen; la tabla de entradas dice qué clave se está tirando y cuál lleva una semana sin tocarse. Lee `gh_billing_usage`, `gh_actions_cache` y `gh_actions_cache_entry`. ## Activity Eventos a lo largo del tiempo, eventos por tipo y por repositorio, notificaciones por motivo y por tipo, notificaciones a lo largo del tiempo y el trabajo hecho en repositorios de otras personas. Los dos paneles fechados agrupan según el rango, no en una hora ni en un día fijos. ![La sección Activity: eventos por día y tipo, el donut de eventos por tipo encabezado por PushEvent con un 39 por ciento, eventos por repositorio, la tabla de notificaciones por motivo, notificaciones por día, las notificaciones más recientes, la tabla de trabajo fuera con pull requests e issues en repositorios de otras personas, el gráfico de lenguajes marcados como favoritos y la tabla de favoritos recientes](../../../../assets/dashboards/activity.png) Los eventos y las notificaciones son ventanas, no historias. GitHub guarda los últimos trescientos eventos sean de cuando sean y descarta rápido las notificaciones leídas, así que lo que queda guardado es lo que había cuando pasó la pasada. El donut lleva la parte de cada tipo en su leyenda y no escribe nada sobre los sectores, porque Grafana omite la etiqueta del sector en el que no cabe y los sectores finos nunca tenían la suya. La leyenda es una lista debajo del gráfico y no una columna a su lado, por medida y no por gusto: por debajo de 992 píxeles Grafana pone toda leyenda debajo del gráfico y la limita al 35 por ciento del panel, pida lo que pida el panel, y una leyenda colocada al lado se dibuja allí como columna, una entrada por línea, que en un teléfono se cortaba tras siete entradas; una lista colocada debajo se ajusta en varias líneas, y con la altura que tiene el pastel los once tipos de un mes caben enteros a 360 píxeles. Los dos últimos paneles son el espejo de la sección de estrellas: las estrellas que dio esta cuenta, no las que recibió, por lenguaje y por proyecto, fechadas cuando dio cada una. Si los proyectos son pequeños o famosos es otra pregunta distinta de cuántos son, así que la segunda tabla lleva sus propias estrellas. Lee `gh_event`, `gh_notification`, `gh_external_contribution` y `gh_star_given`. ## Inventory Código por lenguaje, la puntuación del perfil de comunidad de cada repositorio, la tabla de repositorios, los temas, los paquetes, los gists y las etiquetas de contenedor con el momento en que se publicó cada una. ![La sección Inventory: código por lenguaje, la tabla de perfil de comunidad, la tabla de repositorios con estrellas, forks, tamaño, edad y licencia, las tablas de temas, paquetes y gists, las etiquetas de contenedor publicadas, las tablas de ajustes del repositorio y de claves de la cuenta, dependencias por licencia, las tablas de cuentas sociales, cambios de configuración, ficheros de política y ecosistemas de Dependabot, y dependencias por ecosistema y cambios de dependencias](../../../../assets/dashboards/inventory.png) `open_issues` en la tabla de repositorios es el campo propio de GitHub, y GitHub cuenta las pull requests dentro. `gh_issue` es con lo que se cuentan las issues. El perfil de comunidad enseña las casillas que hay detrás de la nota además de la nota, porque el porcentaje esconde cuál falta: en la cuenta medida ocho de treinta y cinco repositorios no tienen licencia. La casilla de plantilla de issue no es la de GitHub: la bandera `issue_template` de la API solo informa del fichero único heredado `ISSUE_TEMPLATE.md`, no de un directorio de plantillas, mientras que la página de comunidad y `health_percentage` sí cuentan el directorio, así que la bandera decía "no" en todos los repositorios de una cuenta cuyos repositorios puntúan 100 con cuatro formularios cada uno (el hueco está reportado en community/community#207706). La columna es el número de plantillas que el repositorio tiene de verdad, formularios y Markdown, de `gh_repo_policy.issue_templates`, unido a la fila del perfil por repositorio en los dashboards de InfluxDB, PostgreSQL y Prometheus; Graphite y Elasticsearch no pueden unir dos medidas en un panel y conservan la bandera de la API, diciéndolo. La tabla de ajustes es la misma idea para lo que permite un repositorio, y `codeowners_errors` es la entrada que falla en silencio, porque un fichero CODEOWNERS roto deja de pedir revisiones y no dice nada. La tabla de claves de la cuenta es donde aparece una clave SSH que no se ha usado nunca, y donde queda apuntada la caducidad de la que firma todos los commits. El último panel está vacío casi siempre, y esa es la gracia: una fila en "Configuration changes" significa que un repositorio se renombró, se archivó, se hizo privado, cambió de licencia o movió su rama por defecto. La columna de identidad cuenta valores distintos de `repo_id`, que es la única forma de distinguir un renombrado de un repositorio nuevo, porque GitHub no publica ningún historial de renombrados. Lee `gh_repo`, `gh_repo_language`, `gh_repo_community`, `gh_repo_topic`, `gh_repo_policy`, `gh_package`, `gh_package_version`, `gh_gist`, `gh_key`, `gh_social_account` y `gh_dependency_license`. ## Profile and sponsorship Lo que anuncia el perfil y lo que mueve Sponsors: el dinero que entra y sale, los patrocinios en ambos sentidos, los niveles ofrecidos, los ítems fijados, las banderas del perfil, las listas de estrellas y las insignias de logros con la distancia de cada una a su siguiente escalón. ![La sección Profile and sponsorship: las cuatro cifras de patrocinio, 1,28 K dólares recibidos de por vida, 65 al mes, 32,5 en el próximo pago y 24 gastados patrocinando, después la tabla de patrocinios junto a la de niveles, los ítems fijados junto a las banderas del perfil, las listas de estrellas, los logros encabezados por Pull Shark en oro, y la tabla de progreso de logros con una barra por insignia](../../../../assets/dashboards/profile-and-sponsorship.png) Las cuatro cifras son la lectura más reciente de una instantánea que se reescribe en cada pasada, no una suma sobre el rango, que informaría del total de por vida una vez por pasada. El total de por vida es el que conserva la historia: la estimación mensual se va a cero en cuanto caduca el último patrocinio, y el dinero que sí llegó deja de verse en ningún otro sitio. Lo gastado patrocinando no es el gasto de la sección Cost, que es lo que GitHub cobra por Actions, paquetes y almacenamiento; esto es dinero que se va al trabajo de otra persona. Las tablas de debajo son inventarios y no historias, y lo dicen nombrando su propia ventana: un patrocinio hecho en 2021 aparece con los treinta días por defecto, donde un panel al rango del dashboard dibujaría un eje vacío sobre un puñado de filas repartidas por años. Un patrocinio se fecha el día en que empezó y no el día de ningún pago, ambas conexiones se leen con `activeOnly` desactivado para recuperar uno caducado, y el patrocinable es la palabra literal private cuando la otra parte queda oculta, sin adivinar ningún enlace. Los niveles se anclan al comienzo del día UTC por la misma razón: fechados en su creación caerían todos fuera de cualquier rango y se leerían como que no hay ninguno. Los ítems fijados llevan la posición como campo y no como etiqueta, porque un repositorio que pasa del hueco dos al tres es el mismo fijado, y como etiqueta cada recolocación bifurcaría la serie. El filtro de repositorio de la cabecera del dashboard no llega a ese panel: un fijado se llama owner/name, o es un gist, y la variable no guarda ninguna de las dos cosas. Los logros se leen una vez al día de la página pública del perfil, porque ninguna API los lista. "Next tier at" es el umbral observado por la comunidad (Schweinepriester/github-profile-achievements) y no un número que GitHub publique, así que la última columna dice si la página del perfil coincide con el escalón que implica el recuento; una fila que no coincide es una regla que la página contradice, y no lleva objetivo ni barra. Los dos paneles de logros están vacíos hasta que esa familia haya corrido una vez. Lee `gh_sponsors_listing`, `gh_sponsorship`, `gh_sponsors_tier`, `gh_pinned_item`, `gh_profile_flag`, `gh_star_list`, `gh_achievement` y `gh_achievement_progress`. ## The collector itself Lo que le queda al colector por gastar: el presupuesto de cada uno de los quince límites de GitHub en el tiempo, y una tabla con todos ellos ordenada por cuánto se ha gastado de cada uno. ![La sección The collector itself: el presupuesto de peticiones usado por cubo en el rango, y la tabla de todos los cubos con su límite, el máximo usado y el mínimo restante, encabezada por core con 3134 usados de 5000 y 1866 restantes](../../../../assets/dashboards/the-collector-itself.png) El gráfico muestra solo los cubos con más de treinta peticiones, porque el de búsqueda tiene treinta por minuto y aplastaría el eje. Leer todo esto no cuesta nada: `GET /rate_limit` es el único endpoint que GitHub no cobra. Sin él, una familia saltada por falta de presupuesto es indistinguible de una que no tenía nada que contar. Lee `gh_rate_limit`. ## La columna Link Toda tabla cuyas filas son ítems de GitHub selecciona una columna llamada Link, la `url` de la fila, y la oculta: el enlace cuelga de la primera columna de la tabla, el nombre de la cosa, y abre la página de la propia fila en una pestaña nueva. En un teléfono la columna del extremo derecho se alcanzaba en dos tablas de veintiocho, y la primera columna siempre está en pantalla; por la misma razón la columna por la que se ordena una tabla es la segunda, y una fecha se muestra al minuto y no al segundo. Unas pocas tablas enlazan desde otra url que lleva la fila: Recent stars abre a la persona que dio la estrella, Top referrers el host que envió las visitas, Deployments by environment tiene una segunda columna, Live, con la dirección del propio entorno. Release assets conserva su fichero bajo Download, que descarga el binario, y añade la página de la release como su Link; un clic en una barra de Downloads by release también abre la release. Cuando la página es una que GitHub solo muestra al propietario, los ajustes de un repositorio, su gráfico de tráfico, una alerta, el título al pasar el ratón por el enlace lo dice. La columna llega a un almacén solo donde su consulta la devuelve: los dos almacenes SQL la seleccionan por nombre y Elasticsearch agrupa por `url.keyword`, así que un documento sin ella cae en una celda vacía y no fuera de la tabla. Prometheus no lleva url y Graphite no guarda cadenas, y cada una de sus tablas dice en su descripción que la columna Link del dashboard de InfluxDB está ausente allí. `cmd/check_dashboards` somete cada columna de enlace de un dashboard renderizado a esa regla: la columna tiene que volver, y no contener más que urls absolutas y celdas vacías, un nulo de los almacenes SQL o la cadena vacía de ese bucket. ## Cuando un almacén no puede responder Los almacenes no pueden responder las mismas preguntas, y los paneles lo dicen en vez de fingir. - **InfluxDB y PostgreSQL** guardan una fila por hecho, fechada cuando ocurrió, así que dibujan el tráfico de un martes concreto, la curva de estrellas desde 2018 y el tiempo de fusión de una pull request cerrada en julio. El juego de PostgreSQL es el SQL de InfluxDB traducido, porque el destino SQL escribe los mismos hechos como tablas. - **Prometheus** sella cada muestra en el momento del scrape, así que el exportador reduce las filas por elemento a valores actuales más, para las medidas que se cuentan, un `_total` monótono de elementos distintos vistos desde que arrancó. `increase()` sobre eso es como se responden los paneles "por día" y "sobre el rango". - **Graphite** conserva los puntos fechados pero no tiene filas: una tabla ahí es un número por serie reducido sobre el rango, así que una tabla que necesita varios campos de una fila se queda con la columna por la que ordena y dice cuál ha soltado, y un booleano allí ni siquiera es una métrica. - **Elasticsearch** conserva los documentos fechados, así que una tabla por elemento son los documentos más recientes y todo lo demás es una agregación por buckets, sobre el subcampo `.keyword` de cada etiqueta. Cada panel cuyo gemelo en otro almacén es más rico lo dice en una frase de su descripción. ## Veinticuatro paneles no tienen respuesta en Prometheus Top paths, el calendario de contribuciones, los dos punch cards de commits y los dos repartos por repositorio del calendario, las pull requests más grandes, pull requests por autor, las issues abiertas más tiempo, la deuda de revisión, los pasos más lentos, los pasos que fallan, los workflows que nunca se ejecutaron, los artefactos creados a lo largo del tiempo, los commits que dejaron la rama en rojo, las últimas discusiones y las respuestas dejadas fuera, los assets de release, lo que ganó cada asset, las alertas abiertas más antiguas, los eventos por repositorio, las últimas notificaciones, las etiquetas de contenedor y la configuración que cambió. El exportador o se salta la medida, o descarta la identidad de la que trata el panel, o el panel es un cruce entre dos medidas. Adónde fue la salida de los fallos es un panel de texto en todos los almacenes, este incluido: es una nota sobre dónde mirar, no una consulta. Tres de esos son cruces o diferencias de conjuntos y tampoco tienen respuesta en Graphite ni en Elasticsearch, por lo mismo: una agregación corre dentro de un índice o de un árbol, y estos necesitan dos. El calendario no la tiene en ninguno de los dos por otra razón: la rejilla necesita la semana en un eje y el día de la semana en el otro, y un histograma de fechas o un summarize agrupan por un solo intervalo. Cada uno de los dos pierde algo más por su cuenta: Graphite las alertas abiertas más antiguas y las últimas notificaciones, porque guarda números y no las cadenas de las que están hechas esas tablas, y Elasticsearch lo que ganó cada asset, que es la diferencia entre el primer y el último valor del rango. Se emiten igualmente, como paneles de texto con el mismo título que dicen qué mostrarían y por qué el almacén no puede, para que los diseños sigan siendo idénticos. ![El dashboard de Prometheus sobre diez minutos: el Overview con los valores actuales del juego de datos de octocat (42 repositorios, 80 estrellas, 9 forks; 361 visitas, 54 visitantes únicos, 19 clones; 1,20 mil seguidores; 1,11 mil contribuciones en 15,6 años), después la sección Audience en la que Views, Unique visitors y Clones over time dicen No data mientras Top referrers y Clone amplification traen filas y Top paths es el panel de texto que explica que el exportador se salta gh_traffic_path, las catorce cabeceras de sección plegadas, y al pie el Rate budget used del propio colector dibujado como una línea plana al 0,06 por ciento](../../../../assets/dashboard-prometheus-e2e.png) Esa captura no es de ninguna cuenta. Es el dashboard de Prometheus de la suite end-to-end en contenedores (`test/e2e/docker`), donde el colector recorre el GitHub falso de `test/e2e/fakegh`, cuya cuenta es `octocat` con un solo repositorio, y Prometheus hace scrape del exportador cada cinco segundos. El rango son los diez minutos que llevaba recolectando cuando se tomó la captura, que es lo que hace que merezca la pena enseñarla: cada número del Overview, y las dos tablas de la sección Audience, es un valor actual, porque un valor actual es todo lo que el exportador puede servir; `Rate budget used` es una línea plana, porque un gauge repetido en cada scrape es una línea plana; `Top paths` es el panel de texto descrito arriba; y `Views`, `Unique visitors` y `Clones over time` dicen "No data" porque su bucket tiene un suelo de un día y este Prometheus lleva minutos. En un servidor que lleve meses haciendo scrape esos tres dibujan, empezando el día en que arrancó el exportador. --- # Resumen Un SVG autocontenido para un README de perfil, dibujado con los mismos puntos que reciben las bases de datos. Source: https://jmrplens.github.io/ghchronicle/es/card/ ```sh ghchronicle -config config.yaml -card card.svg -card-only ``` > **Esto es una función secundaria** > > El objetivo del proyecto es la ingesta. La tarjeta existe porque los números > ya estaban ahí, y se dibuja exactamente con los mismos puntos que reciben las > bases de datos, así que la tarjeta y el dashboard no pueden contradecirse. ## Las opciones | Opción | Hace | | --------------------------------------- | --------------------------------------------------------------------- | | `-card ` | Ejecuta una pasada y escribe el SVG ahí | | `-card-only` | Escribe el SVG y nada más, así que no hace falta base de datos | | `-card-layout ` | Uno de los trece [diseños](/ghchronicle/es/card/layouts/) | | `-card-theme ` | Both escribe la tarjeta clara y una gemela `_dark` en una sola pasada | | `-card-motion ` | Cómo se mueve un diseño animado; los demás la ignoran | | `-card-fields ` | Separados por comas, en orden de dibujo | | `-card-width ` | Sin ella, el ancho propio del diseño; en `activity-heatmap` compra semanas | | `-card-speed <0 a 1>` | A qué velocidad se reproduce un diseño animado; `0.5`, el valor por omisión, es el ritmo de siempre | | `-card-layouts` | Imprime los diseños con sus campos y sus anchos y termina | Sin `-card-only` la tarjeta se escribe **además de** todo lo que recibirían normalmente los destinos, que es la disposición para una máquina que ya está recogiendo y quiere además una imagen. ## La pasada de la tarjeta y el fichero de estado Una pasada con `-card` recoge **todas las familias**, digan lo que digan las cadencias, porque cada número de la tarjeta sale de los puntos de esa única pasada y una familia saltada por no tocarle sería un cero en la imagen. Con `-card-only` además deja el [fichero de estado](/ghchronicle/es/configuration/#state_file) tal y como lo encontró. Nada de lo que recogió esa pasada llegó a un almacén, así que nada de lo que aprendió puede decirle a la siguiente recogida que una familia ya está hecha. Sí lo lee, que es lo que le permite saltarse el recorrido único del historial de estrellas: lo que el estado recuerda abarata la pasada, nunca encoge la tarjeta. Así que una tarjeta, una segunda tarjeta y una recogida pueden compartir un `state_file`, y cada una de ellas es la cuenta entera. ## Qué dibuja Estrellas, forks, seguidores y número de repositorios; contribuciones del último año; visitas y visitantes únicos de la ventana de tráfico de catorce días de GitHub; un sparkline del calendario de contribuciones; y los repositorios con más estrellas junto a su lenguaje. Las estrellas y los forks se **suman de los repositorios que recogió la pasada**, porque el endpoint de cuenta de GitHub no informa de ninguno de los dos totales. Eso significa que excluir forks o archivados en `targets` se nota también en la tarjeta, que es la lectura honesta y no una discrepancia oculta. ## Los campos `-card-fields` acepta una lista separada por comas, en orden de dibujo, entre: `stars`, `forks`, `followers`, `repos`, `contributions`, `views`, `visitors`, `clones`, `commits`, `pull_requests`, `reviews`, `issues`, `languages`, `top_repos`, `sparkline`. Un diseño se salta un campo que no sabe dibujar. Un nombre desconocido es un error que lista los válidos, en vez de un salto silencioso: de lo contrario, una errata quitaría un número y nadie se daría cuenta hasta tener la tarjeta ya subida. ```sh ghchronicle -config config.yaml -card card.svg -card-only \ -card-layout github-stats -card-theme dark \ -card-fields repos,stars,forks,followers,commits,pull_requests,languages ``` Vacío significa el conjunto por defecto del diseño. ## Temas - **dark y light** Una paleta cada uno, y la opción para un README. `-card-theme both` (o `card-theme: both` en la Action) escribe la tarjeta clara en la ruta dada y la oscura a su lado con `_dark` antes de la extensión, en la misma pasada, así que las dos no pueden contradecirse nunca. Pon las dos en un ``, que es como GitHub documenta mostrar una imagen distinta según el tema: ```html Mi tarjeta de GitHub ``` El README de este mismo proyecto lo hace así con sus tarjetas. - **auto** Ambas paletas en un fichero, tras una consulta `prefers-color-scheme` dentro de la imagen. Esa consulta sigue al sistema operativo de quien lee y no al tema que eligió en la página, y un navegador no la vuelve a evaluar de forma fiable dentro de una imagen: en GitHub se vieron tarjetas auto cambiar de paleta en una página oscura al salir de la pestaña y volver. Sirve en una página sin tema propio. Para un README, usa los dos ficheros. ## Tres restricciones que respeta **Autocontenida.** Sin hoja de estilos externa, sin webfont, sin `` apuntando a una URL, sin script. El proxy camo de GitHub sirve las imágenes de un README desde su propio dominio y bloquea todo eso, así que cualquier cosa externa sencillamente no se dibujaría. **Determinista.** La misma entrada produce un fichero idéntico byte a byte, así que un job programado que haga commit de la tarjeta no produce un diff en cada ejecución. **Escrita atómicamente.** Se dibuja en un fichero temporal y se renombra, así que quien esté mirando la ruta nunca ve medio documento. ## Movimiento La animación, en los diseños que la tienen, es CSS dentro del SVG, y nunca hay script. Toda animación termina en la tarjeta estática completa, así que un renderizador que ignora la animación muestra el estado final, y `prefers-reduced-motion` la desactiva en todos los modos. | `-card-motion` | Qué hace la tarjeta | | -------------- | ----------------------------------------------------------------------------- | | `once` | Se reproduce al cargar y se queda quieta. Es el valor por defecto | | `loop` | Lo mismo, y lo que el diseño tenga que no termine sigue moviéndose para siempre | | `off` | Sin animación, y un fichero más pequeño | ### Un bucle nunca repite La animación aquí revela contenido: una cifra cuenta hasta lo que vale, una barra crece hasta su porcentaje, una línea se dibuja sola. Reproducir eso otra vez sería quitarle a quien lee algo que ya se le había mostrado, y una tarjeta que esconde sus propias cifras cada pocos segundos es peor que una quieta. Así que `loop` no significa "y otra vez". La revelación se reproduce una sola vez y se asienta en los dos modos, y lo único que puede seguir para siempre es el movimiento que no pone nada en la tarjeta ni se lo lleva. Dos diseños tienen algo así: - `terminal`, cuyo cursor parpadea en el prompt desde que se dibuja la ventana. Las cifras se siguen tecleando una sola vez. Con `once`, en cambio, el cursor espera a la última, parpadea un par de veces y se queda encendido. - `ticker`, cuya banda de píldoras sigue desplazándose. No desaparece nada; las mismas píldoras vuelven a pasar. En cualquier otro diseño `loop` dibuja exactamente la tarjeta que dibuja `once`, byte a byte. Se acepta en vez de rechazarse, así que un workflow puede fijarlo una vez y cambiar `card-layout` con libertad. `once` sigue siendo la opción respetuosa para un perfil, y más que antes. Una tarjeta en bucle se mueve para todo el que abre el README, y ninguno de los dos que pueden hacerlo tiene pausa: el cursor parpadea y la banda se desplaza sin descanso entre pasadas, donde las tarjetas en bucle que esto sustituye se quedaban quietas siete segundos entre reproducciones. Un README no le da a quien lee ninguna forma de pararla. Su única salida es `prefers-reduced-motion`, que se la desactiva, pero que es un ajuste de toda su máquina y no un control sobre tu tarjeta. Es la misma razón por la que la página de diseños nunca muestra una tarjeta en bucle hasta que se pulsa el botón (WCAG 2.2.2, Pausar, detener, ocultar). ![El diseño terminal reproducido una vez: una ventana de terminal cuyas líneas de salida tienen cada una un número que se teclea solo, con un cursor de bloque encendido en el prompt de abajo que parpadea cuando aterriza la última cifra, y la página también puede reproducirlo en bucle, donde el tecleo sigue ocurriendo una vez y solo sigue el cursor, parpadeando desde el principio](../../../../assets/card-terminal.svg) - **Binario** ```sh ghchronicle -card-layout terminal -card-theme both -card-only -card card.svg -config config.yaml ``` La imagen en bucle: ```sh ghchronicle -card-layout terminal -card-theme both -card-only -card card.svg -config config.yaml -card-motion loop ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: terminal card-theme: both ``` La imagen en bucle: ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: terminal card-theme: both card-motion: loop ``` ### A qué velocidad se reproduce `-card-speed` es un decimal de 0 a 1, y 0.5 es el valor por omisión. Es un solo número para toda la tarjeta: todos los diseños animados se escalan con él, los dos movimientos continuos junto con las revelaciones, así que una tarjeta más lenta tiene a la vez una banda que tarda más en dar la vuelta y un cursor que parpadea más despacio. | `-card-speed` | Qué hace la tarjeta | | ------------- | ---------------------------------------------------------------------------- | | `0` | La animación más lenta, el doble de larga que la de por omisión | | `0.5` | Exactamente la tarjeta que este renderizador dibujó siempre, byte a byte. Es el valor por omisión | | `1` | La más rápida, la mitad de larga que la de por omisión | Hay un mando y no uno por diseño por la misma razón que hay un ancho y no uno por diseño: los ciclos de aquí se calibraron unos contra otros, y escalarlos juntos es lo que conserva el ritmo con el que se diseñó el movimiento. Una velocidad fuera del rango se rechaza antes de la pasada, nombrando los dos extremos. > **0 es la animación más lenta, no la ausencia de animación** > > Un rango que empieza en cero parece un interruptor, y este no lo es. Una > tarjeta dibujada en `0` se sigue animando, tan despacio como este renderizador > la dibuje. La que no dibuja animación ninguna es `-card-motion off`, que es > además la que hace el fichero más pequeño. `prefers-reduced-motion` no se ve afectado por nada de esto: a quien le ha pedido a su máquina menos movimiento no le queda animación que frenar ni que acelerar. ## En un README ```html Mis estadísticas de GitHub ``` El workflow que la mantiene al día, para un README de perfil o cualquier otro, está en [Una tarjeta en el README de tu perfil](/ghchronicle/es/install/actions/#una-tarjeta-en-el-readme-de-tu-perfil). ## No hay biblioteca Go El renderizador vive en `internal/render`, y Go rechaza esa importación desde fuera del módulo, así que no hay manera de dibujar una tarjeta desde un programa propio llamando a este. Un programa que quiera un SVG ejecuta el binario con `-card` y lee el fichero, igual que ejecutaría cualquier otra orden: ver [llamarlo desde un programa](/ghchronicle/es/reference/subprocess/). --- # Diseños Trece diseños en dos familias visuales, con lo que dibuja cada uno por omisión, cuáles se animan y cuáles pueden seguir moviéndose. Source: https://jmrplens.github.io/ghchronicle/es/card/layouts/ ```sh ghchronicle -card-layouts ``` Los imprime con los campos que muestra cada uno por omisión. Cada diseño de abajo tiene su propia sección, que dice su familia, su movimiento, el ancho al que se dibuja y esos campos, sobre una imagen de la tarjeta. Cada tarjeta se muestra en la paleta en la que está esta página, clara u oscura; todos los diseños dibujan las dos, y `auto` mete ambas en un fichero. Donde un diseño se anima, la animación se reproduce una vez y se asienta en la tarjeta estática completa, así que cada imagen de abajo es ese fotograma final y no un instante intermedio. Cómo se fija y qué la desactiva está en [Movimiento](/ghchronicle/es/card/#movimiento). ## Las dos familias La familia **chronicle** es el aspecto propio de esta herramienta. La familia **github** usa la paleta Primer de GitHub y números monoespaciados para que la tarjeta encaje en un README de perfil como si la hubiera dibujado GitHub. > **La orden bajo cada tarjeta** > > Bajo cada imagen, **Orden para** abre la orden que dibuja esa tarjeta a partir > de tu propia cuenta, y la misma tarjeta como paso de la > [Action](/ghchronicle/es/install/actions/#una-tarjeta-en-el-readme-de-tu-perfil). > `config.yaml` es tu configuración, y `-card-theme both` escribe dos ficheros, > `card.svg` y `card_dark.svg`, que es como una página o un README muestran la > tarjeta en su propia paleta. En las dos tarjetas que hacen bucle, pulsar el > botón de bucle añade `-card-motion loop`. Los números que dibuja una tarjeta > se eligen con `-card-fields`, y todas las opciones están en [la > tarjeta](/ghchronicle/es/card/#las-opciones). ## summary La tarjeta original: título, dos filas de números, un sparkline y los repositorios con más estrellas. - **Familia**: chronicle - **Movimiento**: quieto - **Ancho**: 495 px, se dibuja de 300 a 1200 - **Campos por omisión**: `stars`, `forks`, `followers`, `repos`, `contributions`, `views`, `visitors`, `sparkline`, `top_repos` ![El diseño summary: un título, dos filas de números grandes, un sparkline de contribuciones y una lista de los repositorios con más estrellas](../../../../assets/card-summary.svg) - **Binario** ```sh ghchronicle -card-layout summary -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: summary card-theme: both ``` ## github-stats La caja propia de GitHub: una banda de cabecera, filas de cuatro números monoespaciados y una barra de reparto de lenguajes con su leyenda. El más ancho de todos, y el que parece más nativo en un README de perfil. Los números cuentan hacia arriba, y la barra crece desde su borde izquierdo en cuanto se asientan. - **Familia**: github - **Movimiento**: se reproduce una vez - **Ancho**: 800 px, se dibuja de 600 a 1200 - **Campos por omisión**: `repos`, `stars`, `forks`, `followers`, `commits`, `pull_requests`, `views`, `clones`, `languages` ![El diseño github-stats reproducido una vez: una banda de cabecera sobre filas de cuatro números monoespaciados que cuentan, y una barra horizontal de reparto de lenguajes que crece desde la izquierda junto a su leyenda](../../../../assets/card-github-stats.svg) - **Binario** ```sh ghchronicle -card-layout github-stats -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: github-stats card-theme: both ``` ## github-compact Una fila de números monoespaciados bajo una banda de cabecera fina. - **Familia**: github - **Movimiento**: quieto - **Ancho**: 495 px, se dibuja de 300 a 1200 - **Campos por omisión**: `stars`, `forks`, `followers`, `repos`, `commits` ![El diseño github-compact: una banda de cabecera fina sobre una única fila de números monoespaciados](../../../../assets/card-github-compact.svg) - **Binario** ```sh ghchronicle -card-layout github-compact -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: github-compact card-theme: both ``` ## badge-row Una fila de píldoras de 20 píxeles, una por número, para una línea de README. - **Familia**: chronicle - **Movimiento**: quieto - **Ancho**: sigue al contenido - **Campos por omisión**: `stars`, `forks`, `followers`, `repos`, `contributions` ![El diseño badge-row: una fila horizontal de píldoras pequeñas, cada una con una etiqueta y un número](../../../../assets/card-badge-row.svg) - **Binario** ```sh ghchronicle -card-layout badge-row -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: badge-row card-theme: both ``` ## wide-banner Un banner de ancho completo y 60 píxeles de alto: el login a la izquierda, los números repartidos, y el sparkline dibujándose solo por detrás. - **Familia**: chronicle - **Movimiento**: se reproduce una vez - **Ancho**: 800 px, se dibuja de 500 a 1200 - **Campos por omisión**: `stars`, `forks`, `followers`, `contributions`, `sparkline` ![El diseño wide-banner reproducido una vez: un banner ancho y bajo con el login a la izquierda, números repartidos y un sparkline que se dibuja detrás](../../../../assets/card-wide-banner.svg) - **Binario** ```sh ghchronicle -card-layout wide-banner -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: wide-banner card-theme: both ``` ## sparkline-hero El sparkline de contribuciones es toda la tarjeta, con hasta tres números superpuestos. La línea se dibuja sola al cargar. - **Familia**: chronicle - **Movimiento**: se reproduce una vez - **Ancho**: 495 px, se dibuja de 300 a 1200 - **Campos por omisión**: `contributions`, `stars`, `followers`, `sparkline` ![El diseño sparkline-hero reproducido una vez: un sparkline de contribuciones grande que se dibuja llenando la tarjeta con tres números superpuestos](../../../../assets/card-sparkline-hero.svg) - **Binario** ```sh ghchronicle -card-layout sparkline-hero -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: sparkline-hero card-theme: both ``` ## language-ring Un donut de reparto de lenguajes con la leyenda al lado y una fila de números destacados. Cada porción se dibuja alrededor del anillo detrás de la anterior, y la leyenda aparece cuando el donut está completo. - **Familia**: github - **Movimiento**: se reproduce una vez - **Ancho**: 495 px, se dibuja de 400 a 1200 - **Campos por omisión**: `languages`, `stars`, `repos` ![El diseño language-ring reproducido una vez: un gráfico de donut cuyas porciones de lenguaje se dibujan una detrás de otra, con una leyenda que aparece al lado y una fila de números destacados](../../../../assets/card-language-ring.svg) - **Binario** ```sh ghchronicle -card-layout language-ring -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: language-ring card-theme: both ``` ## repo-list Los repositorios con más estrellas como contenido principal: punto de lenguaje, estrellas y una barra por fila, con los totales debajo. - **Familia**: github - **Movimiento**: quieto - **Ancho**: 495 px, se dibuja de 300 a 1200 - **Campos por omisión**: `top_repos`, `stars`, `forks`, `repos` ![El diseño repo-list: una fila por repositorio con un punto de lenguaje, el número de estrellas y una barra proporcional, con totales debajo](../../../../assets/card-repo-list.svg) - **Binario** ```sh ghchronicle -card-layout repo-list -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: repo-list card-theme: both ``` ## activity-heatmap Todo el calendario de contribuciones que quepa en el ancho, hasta un año de él, como los cuadrados verdes de GitHub, con hasta tres números al lado. El número de semanas no es una cifra fija: la rejilla ocupa el sitio que le dejan los números de al lado, así que termina donde termina la tarjeta en vez de quedarse a un tercio del final. Con el ancho que declara este diseño son veintitrés semanas, con su mínimo dieciséis, y `-card-width` en el extremo lejano que dicen sus datos dibuja el año entero que guarda el recolector. Una cuenta con cifras de siete dígitos se lleva una columna más ancha y le deja a la rejilla una o dos semanas menos, que es la misma regla vista del otro lado: con el ancho que declara este diseño, un millón de contribuciones son veintidós semanas en vez de veintitrés, y seis dígitos siguen cabiendo dentro de las etiquetas. El extremo lejano es donde cae el año para los tres números que este diseño dibuja por omisión, así que una tarjeta a la que se le piden menos, o números con etiquetas más cortas, llega al año antes de él y le sobra sitio al final: `-card-fields sparkline` dibuja su año entero bastante antes del extremo lejano. Las semanas aparecen desde la izquierda, y la ola cruza la rejilla en 0,22 s tenga las semanas que tenga, así que el calendario se llena como una ola de la misma duración a cualquier ancho. - **Familia**: github - **Movimiento**: se reproduce una vez - **Ancho**: 495 px, se dibuja de 400 a 891 - **Campos por omisión**: `sparkline`, `contributions`, `commits`, `pull_requests` ![El diseño activity-heatmap reproducido una vez: veintitrés semanas de cuadrados de contribución en la escala verde de GitHub que aparecen desde la izquierda, con tres números al lado](../../../../assets/card-activity-heatmap.svg) - **Binario** ```sh ghchronicle -card-layout activity-heatmap -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: activity-heatmap card-theme: both ``` ## animated-counters Números que cuentan hacia arriba al cargar sobre un sparkline que se dibuja solo, asentándose en la tarjeta estática. - **Familia**: chronicle - **Movimiento**: se reproduce una vez - **Ancho**: 495 px, se dibuja de 300 a 1200 - **Campos por omisión**: `stars`, `forks`, `followers`, `repos`, `contributions`, `views`, `sparkline` ![El diseño animated-counters reproducido una vez: una rejilla de números grandes que cuentan sobre un sparkline de contribuciones que se dibuja](../../../../assets/card-animated-counters.svg) - **Binario** ```sh ghchronicle -card-layout animated-counters -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: animated-counters card-theme: both ``` ## terminal Una ventana de terminal con la marca del proyecto en su barra de título, una línea de salida por número y otra por repositorio. Los números se teclean solos, línea a línea, bajo una tapa pintada del color de fondo de la propia tarjeta. El cursor del prompt es el único movimiento sin final de la tarjeta, y hace algo distinto en cada modo. Con `once` se queda encendido mientras llegan las cifras, parpadea un par de veces cuando aterriza la última y se queda encendido, que es el estado en el que descansa la tarjeta terminada. Con `loop` parpadea desde que se dibuja la ventana y no para, porque un cursor parpadea por la razón por la que una terminal está abierta, no por la razón por la que una tarjeta ha terminado. El tecleo ocurre una sola vez en los dos casos. - **Familia**: chronicle - **Movimiento**: se reproduce una vez, o en bucle - **Ancho**: 495 px, se dibuja de 360 a 1200 - **Campos por omisión**: `stars`, `forks`, `followers`, `repos`, `contributions`, `top_repos` ![El diseño terminal reproducido una vez: una ventana de terminal con la marca del proyecto en su barra de título, cuyas líneas de salida tienen cada una un número que se teclea solo bajo un cursor de bloque encendido que empieza a parpadear cuando aterriza la última cifra, y la página también puede reproducirlo en bucle, donde el tecleo sigue ocurriendo una vez y el cursor parpadea desde el principio y no para](../../../../assets/card-terminal.svg) - **Binario** ```sh ghchronicle -card-layout terminal -card-theme both -card-only -card card.svg -config config.yaml ``` La imagen en bucle: ```sh ghchronicle -card-layout terminal -card-theme both -card-only -card card.svg -config config.yaml -card-motion loop ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: terminal card-theme: both ``` La imagen en bucle: ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: terminal card-theme: both card-motion: loop ``` ## ticker Una banda de píldoras, una por número y otra por repositorio, que se desplaza de derecha a izquierda. El contenido se repite de punta a punta y la banda avanza exactamente una copia, así que la imagen al final de una pasada es la imagen de su comienzo y el bucle no tiene salto. Reproducido una vez, hace una sola pasada y vuelve al inicio. La banda se desplaza a velocidad fija, así que una tarjeta con más contenido tarda en dar la vuelta más de lo que cualquier otro diseño tarda en asentarse. - **Familia**: chronicle - **Movimiento**: se reproduce una vez, o en bucle - **Ancho**: 800 px, se dibuja de 400 a 1200 - **Campos por omisión**: `stars`, `forks`, `followers`, `repos`, `contributions`, `commits`, `views`, `top_repos` ![El diseño ticker reproducido una vez: una banda ancha de píldoras redondeadas, una por número y otra por repositorio, que se desplaza de derecha a izquierda bajo el nombre de la cuenta, y la página también puede reproducirlo en bucle](../../../../assets/card-ticker.svg) - **Binario** ```sh ghchronicle -card-layout ticker -card-theme both -card-only -card card.svg -config config.yaml ``` La imagen en bucle: ```sh ghchronicle -card-layout ticker -card-theme both -card-only -card card.svg -config config.yaml -card-motion loop ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: ticker card-theme: both ``` La imagen en bucle: ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: ticker card-theme: both card-motion: loop ``` ## language-bars Una barra de ancho completo por lenguaje, cada una creciendo desde su propio borde izquierdo detrás de la anterior, con el nombre y el porcentaje llegando en cuanto su barra se detiene. Es el diseño que da a cada lenguaje una línea propia, donde `language-ring` los muestra todos en un donut y `github-stats` en una sola barra. - **Familia**: github - **Movimiento**: se reproduce una vez - **Ancho**: 495 px, se dibuja de 360 a 1200 - **Campos por omisión**: `languages` ![El diseño language-bars reproducido una vez: una barra de ancho completo por lenguaje que crece desde su borde izquierdo, una detrás de otra, con el nombre del lenguaje y su porcentaje llegando detrás de cada barra](../../../../assets/card-language-bars.svg) - **Binario** ```sh ghchronicle -card-layout language-bars -card-theme both -card-only -card card.svg -config config.yaml ``` - **GitHub Action** ```yaml - uses: jmrplens/ghchronicle@v1 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: card card: generated/card.svg card-layout: language-bars card-theme: both ``` ## Movimiento Cada sección de arriba dice el movimiento de su diseño, y hay un hecho que decide lo que esa línea puede decir: una revelación nunca se repite. Una cifra que ya ha contado, una barra que ya ha crecido y una línea que ya se ha dibujado no se quitan, así que `-card-motion loop` dibuja exactamente la tarjeta que dibuja `once` en todos los diseños salvo los dos cuyo movimiento no termina, el cursor parpadeante de `terminal` y la banda que se desplaza de `ticker`. Esos dos son los que llevan el botón de bucle bajo su imagen, y el razonamiento está en [Un bucle nunca repite](/ghchronicle/es/card/#un-bucle-nunca-repite). ## Ancho Cada diseño declara el ancho con el que se dibuja y los dos extremos fuera de los cuales se niega a hacerlo, y su propia sección de arriba dice los tres. `-card-width` en el binario, y `card-width` en la Action, piden otro: cualquiera entre los dos extremos de ese diseño. Un ancho fuera de ahí se rechaza antes de la pasada, nombrando los dos, y `-card-layouts` los imprime. Si no se dice nada, la tarjeta sale con el ancho de su diseño, que es el ancho con el que salían todas antes de que existiera la opción. `badge-row` no declara ninguno de los tres, porque una fila de píldoras estirada a un ancho fijo tendría huecos; su ancho sigue al contenido, y la opción ni se lo cambia ni la rechaza. El extremo cercano es donde una columna deja de caber. El lejano casi siempre es solo una defensa contra una errata, porque a un diseño con más sitio lo que le pasa es que reparte el mismo contenido por él, y uno al que se le pedían veinte mil se dibujaba de veinte mil unidades. `activity-heatmap` es el que tiene uno de verdad, y es la razón de que los extremos sean los de cada diseño y no un par para todos: lee el ancho en vez de limitarse a que lo dimensione, y calcula cuántas semanas del calendario de contribuciones caben en el sitio que le deja, así que la misma tarjeta son dieciséis semanas en su extremo cercano, veintitrés con el ancho que declara y el año entero que guarda el recolector en su extremo lejano. Más allá ya no hay calendario que dibujar, así que el extremo lejano es justo el ancho en el que cae el año y a la tarjeta nunca se le pide que llene un sitio para el que no tiene nada. ## Campos que un diseño no puede dibujar Cada diseño declara qué campos soporta. Pedir uno que no, como `top_repos` en `github-compact`, lo descarta en silencio. Pedir un nombre que no está en el vocabulario en absoluto es un error que lista los válidos. Doce de los quince campos son números, y todos los diseños admiten los doce. Solo están restringidos los tres que necesitan sitio propio: | Campo | Lo dibujan | | ------------ | ------------------------------------------------------------------ | | `languages` | `summary`, `github-stats`, `language-ring`, `language-bars` | | `top_repos` | `summary`, `github-stats`, `repo-list`, `terminal`, `ticker` | | `sparkline` | `summary`, `github-stats`, `wide-banner`, `sparkline-hero`, `activity-heatmap`, `animated-counters` | `ghchronicle -card-layouts` imprime los diseños con los campos que dibuja cada uno por omisión. ## Por dónde seguir - [La tarjeta](/ghchronicle/es/card/) es lo que las dibuja, y cómo poner una en un README. - [Llamarlo desde un programa](/ghchronicle/es/reference/subprocess/) es la forma soportada de sacar una desde otro lenguaje. --- # Límites de la API GitHub tiene quince presupuestos independientes; aquí importan tres, y el freno se escala a cada uno. Source: https://jmrplens.github.io/ghchronicle/es/api/ GitHub no tiene un límite de peticiones. Tiene quince, y cada respuesta dice a cuál acaba de cargar en la cabecera `x-ratelimit-resource`. ## Los tres que importan aquí | Cubo | Límite | Lo gasta | | --------- | -------------------- | ---------------------------------------------------------------- | | `core` | 5000 por hora | Toda llamada REST | | `graphql` | 5000 puntos por hora | Las consultas de cuenta, commits, discusiones, etiquetas e hitos, y desde el 2026-09-11 las estrellas y forks más nuevos, la lista de estrellas dadas, las búsquedas de outbound y diez de los once contadores de totals | | `search` | 30 por minuto | El contador de commits de `totals`, y nada más: la búsqueda GraphQL no tiene tipo COMMIT | Dos más los carga una familia cada uno: `webhook_deliveries` (500 por minuto) por la lista de entregas de cada hook en `settings`, y `dependency_sbom` (100 por minuto) por el SBOM en `deps`. Ninguno de los dos está entre los quince que informa `/rate_limit`; existen solo en las cabeceras de los endpoints que los cargan, y los dos se nombran en la [tabla de coste](/ghchronicle/es/api/cost/) donde aplican. El freno mira los tres de arriba, no el cubo que se cargó en último lugar, lo que importa por la razón de abajo. ## El freno `github.reserve_rate` es cuántas llamadas no se gastan nunca. Por omisión son 500. El colector detiene una familia antes que cruzar esa línea, para que lo demás que use el mismo token siga funcionando. ```yaml github: token: ${GITHUB_TOKEN} reserve_rate: 500 ``` La reserva se escala a cada cubo: una quinta parte de su límite, o el valor configurado, el menor de los dos. > **El escalado es una corrección de error, no un refinamiento** > > Search permite treinta peticiones por minuto. Una llamada deja "29 restantes", > y comparar eso con una reserva plana de 500 se leía como agotado, así que se > saltaban todas las familias que quedaban de la pasada. Juzgar un cubo de > treinta peticiones con una reserva de cinco mil deja al colector parado en > seco. Cuando un cubo baja de su reserva, el colector espera al reinicio que la respuesta ya le indicó, en vez de dormir un intervalo adivinado y reintentar. En una pasada normal la familia se salta con un aviso: ```text level=WARN msg="rate limit reserve reached, family skipped" family=artifacts ``` Una vez está bien. En cada pasada significa que las cadencias son demasiado rápidas para el número de repositorios; ver [coste de una pasada](/ghchronicle/es/api/cost/) para saber qué alargar primero. ## Los ETags, y por qué un 304 es gratis Cada respuesta se cachea por su ETag. Una petición repetida envía `If-None-Match`, y GitHub responde 304 Not Modified cuando nada ha cambiado. **Un 304 no cuesta cuota en absoluto.** Eso no es un detalle de optimización, es lo que hace asequibles las cadencias cortas. La mayor parte de lo que esto recoge apenas se mueve: el desglose de lenguajes de un repositorio, sus temas, su perfil de comunidad, la lista de workflows. Preguntarlos cada hora sería inasumible si cada pregunta costara una llamada. Preguntar si cambiaron no cuesta nada. La consecuencia es que el coste medido de una pasada en la página siguiente es una cota superior que se alcanza en la primera pasada y tras un cambio, no la cifra en régimen estable. Lo que la caché guarda junto a cada ETag es el valor que el colector decodificó, codificado de nuevo, no el cuerpo que envió GitHub. Una página de cien ejecuciones de workflow ocupa 1,4 MB de los que el colector conserva unos cientos de bytes por ejecución, así que la caché de una pasada ocupa alrededor de la novena parte de lo que ocuparían los cuerpos crudos, y un 304 decodifica la novena parte de los bytes. Un test ejecuta cada colector REST dos veces contra un GitHub falso que responde 304 a la repetición y falla en el primer punto que difiera, que es lo que mantiene la repetición como la misma respuesta que la original. Cada entrada se indexa por la URL y por el tipo que la decodificó, así que la única URL que dos colectores leen de forma distinta, `GET /repos/{owner}/{repo}` (cuatro indicadores para el descubrimiento de un repositorio nombrado en `targets.repos`, cuarenta campos para la familia repo), tiene una entrada por lector y ninguno recibe la del otro. La caché está acotada, y la cota son 256 MB. Es un LRU, y a cada entrada se le cobran los bytes que guarda más doscientos por la casilla del mapa, el elemento de la lista y la estructura que los rodea, para que una caché llena de respuestas pequeñas se contabilice por lo que de verdad cuesta y no por la mitad. No hay clave para ella en el fichero de configuración: un despliegue cuyo conjunto vivo no quepa la sube desde Go con `SetCacheLimit`, y `CacheStats().Evicted` es el número que dice si ha hecho falta, porque se queda a cero mientras quepa el conjunto vivo de una pasada. De ahí salen dos cosas. Una cota por debajo del conjunto vivo de una pasada no es una caché más pequeña sino ninguna caché, porque cada pasada expulsaría lo que la siguiente está a punto de pedir. Y la cota es memoria residente en cuanto la caché se llena, que es el número que hay que saber antes de meter el proceso en un contenedor con límite de memoria: puede retener esa cantidad de cuerpos decodificados además de su propia huella. ## Un rechazo también se recuerda Un 403 o un 404 es como GitHub dice que una función está apagada: Dependabot en un repositorio que no lo usa, code scanning donde nunca se activó, un foro en un repositorio con las discusiones apagadas. Eso no es un error, y el colector lo convierte en "aquí no hay nada". Lo que no es, es gratis. Un rechazo no lleva ETag, así que donde una página que no ha cambiado no cuesta nada, una función apagada se cobra entera en cada pasada, para siempre. Por eso un rechazo se recuerda un día, indexado por familia, repositorio y endpoint, y las pasadas de ese día no preguntan nada. Dos consecuencias que conviene saber: - Enciende una función y se nota como muy tarde al día siguiente, no en la pasada siguiente. - Reiniciar el proceso vuelve a preguntar en el acto. La memoria vive en el proceso, como la caché de ETags, no en el fichero de estado. Una cuota agotada también es un 403 y nunca se recuerda como tal: el cliente lo tipa aparte precisamente para que un límite de peticiones no se lea como una función apagada. ## El presupuesto en el log ```text level=INFO msg="rate budget" bucket=core remaining=4354 limit=5000 ``` Merece una alerta el aviso de arriba, no esta línea. Un presupuesto que baja es normal; una familia saltada en cada pasada es un problema de configuración. ## Un relleno histórico invierte la regla Un [relleno histórico](/ghchronicle/es/how/backfill/) es la intención contraria y lo dice. Cuando un cubo se agota espera a que la ventana se reinicie en vez de rendirse, porque un relleno que se detiene a medias ha gastado la parte cara del presupuesto y conserva solo las familias que terminó: cada una se escribe y se marca en cuanto acaba, y el resto hay que volver a lanzarlo. --- # Coste de una pasada El precio medido de una pasada, por familia, y qué familias alargar cuando el presupuesto aprieta. Source: https://jmrplens.github.io/ghchronicle/es/api/cost/ Medido el 2026-09-11 con el binario real contra la API viva, con todas las familias encendidas y cada petición registrada con las cabeceras `x-ratelimit-resource` y `x-ratelimit-used` de su propia respuesta. **Frío** es la primera pasada de una instalación nueva o de un servicio reiniciado: caché de ETag vacía, un mes de ejecuciones de workflows. **Estable** es la tercera pasada del mismo proceso unos minutos después, cuando cuatro quintas partes de sus peticiones se respondieron con 304 y no costaron nada; la segunda pasada es la que paga el relleno que describe la fila de actions. Un 304 es gratis, y solo existe para REST: GraphQL no lleva ETag, así que su columna es la misma en las dos pasadas. Las cifras son por repositorio donde la familia pregunta por repositorio, y por pasada donde pregunta por la cuenta. `core` es el cubo de REST, `pt` un punto de GraphQL. Las familias con alguna petición que no es `core` dicen a qué cubo fue: `search` (30 por minuto), `webhook_deliveries` (500 por minuto) y `dependency_sbom` (100 por minuto) tienen cada uno el suyo. Una fila que la ronda del 2026-09-11 cambió describe las peticiones que el código hace ahora y dice qué midieron las dos pasadas donde difiere. No hay fila de total a propósito: un total pertenece a una cuenta y envejece con ella, así que multiplica las filas por repositorio por los repositorios que imprime `-list` y tendrás el tuyo. ## Por familia | Familia | Cadencia | Frío | Estable | Notas | | ----------- | -------- | --------------------------------------------------- | --------------------------------- | ----- | | account | 12h | 1 pt | 1 pt | una consulta: el calendario, los totales, los pins, el bloque de sponsors y las listas de estrellas; 66 KB, nunca comprimida, nunca condicional | | achievements | 24h | 1 página fuera de presupuesto, 1 pt, más el recorrido de coautoría | lo mismo | las insignias salen de la página pública del perfil, leída sin el token y sin cargarse a ningún cubo. Las filas de progreso de al lado sí salen de la API: una consulta de cuentas, y un recorrido por las pull requests fusionadas cuyos commits en coautoría no expone ninguna cuenta. La búsqueda se detiene a los mil resultados, así que el recorrido pregunta por la vida entera de la cuenta y parte en dos cualquier rango que se pase, un punto por página y uno por partición, que son decenas de puntos en una cuenta antigua y uno en una nueva. Todo ello una vez al día | | totals | 12h | 1 core, 1 search, 3 pt | 0 core, 1 search, 3 pt | el perfil es un 304 desde la segunda pasada; diez de los once contadores son una consulta GraphQL de diez alias, 406 bytes a coste 1, medida idéntica a las respuestas REST del mismo minuto; solo el contador de commits sigue siendo una búsqueda, cobrada cada vez; una consulta de contadores rechazada se repite contador a contador, diez puntos solo en esa pasada, así que una búsqueda fallida pierde un número, como en REST | | ratelimit | 15m | 1 pt | 1 pt | `GET /rate_limit` es gratis; el punto es la mitad GraphQL de la misma pregunta | | events | 30m | 3 core | 1 core | antes de la ronda: tres páginas de un feed que cambia cada vez, así que `If-None-Match` nunca acierta. Ahora: el recorrido se detiene en la página que lleva el evento más reciente de la pasada anterior, recordado como `last_event` en el fichero de estado, que suele ser la primera página: 1 core por pasada, 96 al día menos; una primera pasada y un backfill siguen leyendo las tres | | notifs | 30m | 20 core | 1 core | antes de la ronda: veinte páginas de cincuenta; una notificación nueva desplaza todas las páginas, así que el ETag nunca acierta, para seis o siete filas nuevas. Ahora: `since=` el `updated_at` más reciente visto menos dos cadencias (`last_notified` en el fichero de estado), que es una página para una ventana de dos horas: 1 core por pasada, y las veinte una vez al día (`last_full`), en un backfill y en la primera pasada tras una actualización, hilos leídos incluidos (`all=true`): eso acota a un día lo que el filtro de GitHub haya dejado fuera de las lecturas con ventana, y es la lectura que cierra un hilo, porque una pasada lista solo los hilos no leídos y un hilo leído sin respuesta desaparece de ese listado en vez de volver con su etiqueta `unread` cambiada. 912 core al día menos | | billing | 6h | 2 core | 1 core | una por mes recorrido; el mes en curso cambia, el anterior es un 304 | | profile | 12h | 4 core más 1 por paquete | 0 core | el perfil, las cuentas sociales, los gists, los paquetes y una página de versiones por paquete; todo 304 tras la primera pasada | | outbound | 12h | 8 pt | 8 pt | todo GraphQL: la lista de estrellas dadas es una consulta de cien (13,5 KB frente a 617 KB descomprimidos por REST), las cinco búsquedas un punto cada una (7 KB frente a 100 KB), los dos recorridos de comentarios uno cada uno; nada de esto lleva ETag por ninguna de las dos vías, así que las dos columnas son iguales | | history | apagada | 1 pt por año pasado | igual | el calendario entero de la cuenta, una vez, a un punto por año que lleve existiendo | | keys | 24h | 2 core | 0 core | las claves SSH y GPG | | traffic | 6h | 4 core por repo | 0 core | visitas, clones, referrers, rutas; la ventana de catorce días es un 304 hasta que se mueve | | repo | 1h | 3 core por repo, 2 pt por cada 10 repos | 0 core en un repo que no cambió, 2 pt por cada 10 repos | el repositorio, su perfil de comunidad y una página de releases cada uno; lenguajes, temas, rulesets y protección de rama viajan en una consulta GraphQL por cada diez repositorios | | branches | 24h | 1 pt por cada 14 repos | 1 pt por cada 14 repos | una consulta por cada catorce repositorios | | stars | 6h | 1 core por repo | 1 pt por cada 10 repos | el recorrido completo por REST la primera vez que se ve un repositorio, y después las cien estrellas más nuevas de cada repositorio en una consulta GraphQL por cada diez, alrededor de un kilobyte por repositorio; un reinicio con fichero de estado empieza en esa consulta. Antes de la ronda se pedía la última página de cada repositorio en cada pasada, todas menos una con 304 | | issues | 1h | hasta 8 pt por repo | 1 a 2 pt por repo | antes de la ronda: una página de cincuenta con diez hilos de revisión cada uno, 8 puntos, que el gateway rechazaba con un 502 una vez por pasada en el repositorio más activo. Ahora: 2 puntos de GraphQL por repo para lo que cambió en dos cadencias, de diez en diez (1 punto donde el repositorio tiene cinco o menos), y una vez por día UTC una página completa dimensionada al repositorio desde los últimos totales (5, 10, 20 o 50 cuestan 1, 2, 3 u 8). La pasada que lleva la página diaria es la cara, y los repositorios más activos siguen sacando el 502 o 504 del gateway a cincuenta una vez al día y se reintentan a veinticinco | | issueevents | 1h | varios pt por repo para el mes, más 1 core por pull request en pila | 1 pt por repo, 0 a 1 core | antes de la ronda: una página de cien eventos cada uno, hasta un megabyte por repositorio porque cada evento incrusta su issue entera; un 304 en todos menos el repositorio que se movió. Ahora: una consulta GraphQL por repositorio, la cronología de las diez issues y diez pull requests actualizadas más recientemente para los eventos de las dos últimas cadencias (1 pt, de 2 a 6 KB, una segunda página solo cuando se movieron más de diez), más un core por pull request en una pila, cuyo evento `added_to_stack` la cronología no sabe nombrar; treinta días en una primera pasada, una vez, que son minutos y no segundos donde las pull requests están casi todas en pila, porque casi todas las actualizadas en el mes van por la vía por ítem a por su `added_to_stack`; una pasada estable es un punto por repositorio y como mucho una lectura de esas. Y desde una cadencia antes de la última ejecución tras un hueco, para que un proceso parado no deje sus horas fuera de la serie. Medido contra la lista durante una semana de dos repositorios: todos los eventos de todos los tipos coinciden, campo a campo, salvo un commit que referencia una issue que nadie ha tocado, 3 de 2.217, que no mueve la issue y por eso no se pregunta. Un backfill recorre `/issues/{n}/events` por ítem en vez de la lista, 1,4 KB comprimidos por doce eventos frente a 45 KB por evento | | actions | 15m | un mes de ejecuciones por repo, más 1 core por ejecución | 1 core por repo que tuvo una ejecución, más 1 por ejecución terminada desde entonces | antes de la ronda: páginas de cien ejecuciones hasta un mes atrás, un listado de jobs por ejecución, y las cachés y workflows de cada repositorio, que eran tres quintas partes de los bytes de la pasada fría, y esos listados de jobs otra vez en cada pasada, casi todos 304. Ahora: páginas de 30, una en un repositorio tranquilo y hasta 7 mientras vengan llenas de ejecuciones más nuevas que la ventana, más una por ejecución aún sin expandir, jobs listados una vez por intento, como mucho 20 ejecuciones nuevas por pasada; páginas de 100 en la primera pasada y en un backfill. Medido en tres pasadas de un mismo proceso, el segundo es el que paga el relleno, porque la página de treinta es una URL nueva para cada repositorio y trae ejecuciones que las veinte del primero no cubrieron; desde ahí una pasada cuesta una página por repositorio que tuvo una ejecución y un listado de jobs por ejecución terminada desde entonces, así que lo que cuesta es cuántas ejecuciones termina la cuenta | | artifacts | 1h | hasta 5 core por repo | 0 a 5 core por repo | un repositorio activo llena sus páginas, y las cinco se cobran otra vez cada vez que se añadió o caducó un artefacto: 5 en una pasada, 0 en el siguiente | | security | 1h | 2 core por repo | 0 core | alertas de Dependabot y de code scanning; la mayoría son el 403 de un repositorio con Dependabot apagado o el 404 de uno sin code scanning, y un rechazo no lleva ETag, así que cada uno se volvía a cobrar en cada pasada antes de que la ronda los recordara. Ahora: un rechazo se contesta de memoria durante un día, por familia, repositorio y endpoint, así que la cifra estable es 0 core en la segunda pasada y una petición por rechazo una vez al día. El resto son peticiones condicionales, todas 304 | | stats | 12h | 3 core por repo | 0 core | participación y punch card; GitHub los recalcula despacio y responde 304 | | discussions | 2h | 2 pt por repo con foro | igual | antes de la ronda: cincuenta hilos con veinte comentarios y veinte respuestas cada uno, 11 puntos, pedidos a todos los repositorios, incluidos todos los que no tienen foro. Ahora: 2 puntos por repo con foro, los diez hilos actualizados más recientemente con los mismos veinte comentarios y veinte respuestas cada uno (los comentarios llegan del más antiguo al más nuevo, así que una página más corta ahí dejaría de registrar el undécimo comentario de un hilo); un repositorio con el foro apagado no se consulta | | commits | 1h | 1 pt por repo | 1 pt por repo | las dos últimas cadencias de la rama por defecto; un mes atrás en la primera pasada, que son unos cientos de kilobytes para un repositorio activo | | activity | 30m | 2 core por repo | 0 a 2 core por repo | el log del repositorio, cien entradas por página; se cobra solo en un repositorio cuyo log se movió, 2 en una pasada y 0 en el siguiente | | analyses | 6h | 1 core por repo | 0 core | la mayoría son el 403 y el 404 de repositorios sin code scanning, cobrados otra vez en cada pasada antes de la ronda; ahora recordados un día, así que la pasada estable son peticiones condicionales y 0 core | | forks | 12h | 1 core por repo | 1 pt por cada 10 repos | una página cada uno por REST en la primera pasada de una instalación nueva y en un backfill, y después los cien forks más nuevos de cada repositorio en una consulta GraphQL por cada diez, menos de un kilobyte por repositorio; un repositorio del que el lote informa que tiene más de cien forks se recorre también por REST, porque las estrellas y days_since_push de una fila de fork cambian y el lote no puede refrescar las filas más allá de su página. Antes de la ronda: una petición por repositorio y pasada, todas 304 | | planning | 6h | 1 pt por repo | 1 pt por repo | etiquetas e hitos | | joblogs | apagada | 1 core por repo, 1 blob por job fallido | 0 core | las ejecuciones fallidas de la última hora por repositorio, pedidas a una lista filtrada al mes al que puede llegar una reejecución, y un blob por job fallido desde el almacenamiento de objetos, fuera de la cuota de la API; la URL del filtro cambia una vez al día, así que un día cuesta una página cobrada por repositorio y el resto son 304 | | settings | 6h | 2 core por repo, 1 webhook_deliveries por hook | 0 core, 1 webhook_deliveries por hook que se movió | webhooks, entornos y claves de despliegue; las entregas de cada hook se cobran a su propio cubo | | rulesets | 24h | 1 core por repo más 1 por ruleset | 0 core | un listado por repositorio y un historial por ruleset, los dos con ETag; nada de eso se cobra un día en que nadie editó un ruleset, cuando la pasada estable hace las mismas preguntas y todas son condicionales | | inventory | 24h | 4 core por repo | 0 core | la política del token de workflow, los secretos, la configuración de code scanning; los rechazos eran el 403 de los repositorios sin ella, ahora recordados un día, que a esta cadencia es la pasada siguiente de todos modos; el resto son peticiones condicionales, todas 304 | | deployments | 1h | 1 pt por cada 5 repos | 1 pt por cada 5 repos | una consulta por cada cinco repositorios, los cien despliegues más recientes de cada uno | | policyfiles | 24h | 1 pt por cada 5 repos | 1 pt por cada 5 repos | una consulta por cada cinco repositorios, el historial de cuatro rutas de cada uno | | deps | apagada | 1 core y 1 dependency_sbom por repo | 0 core, 1 dependency_sbom por repo que recibió un commit | una lectura de commit y un SBOM por repositorio; el SBOM tiene su propio cubo y la mayoría fueron 404, ahora recordados un día. GitHub regenera el SBOM en cada lectura y su ETag nunca acierta, así que la fotografía solo se toma cuando la cabeza se movió desde la última pasada (la cabeza en sí es un 304 gratis cuando no lo hizo): 0 dependency_sbom en un repositorio sin commit, uno por repositorio que lo recibió, y una de las lecturas de SBOM expiró en el lado de GitHub en cada uno de las tres pasadas medidas | Lo que pesaba antes de la ronda no era REST sino GraphQL: `issues` y `discussions` eran el 88 % de los puntos, porque las dos consultas estaban dimensionadas para un backfill y se pedían en cada pasada. La única búsqueda es el contador de commits de `totals`, una por pasada contra un presupuesto de treinta por minuto. La ronda del 2026-09-11 rebajó varias de estas filas, y la primera medida es la base contra la que se mide: los listados de jobs de una ejecución ya expandida no se vuelven a pedir, la página de ejecuciones es de treinta en una pasada ordinaria, `discussions` solo se pregunta a los repositorios con foro y por diez hilos, `pulls` se dimensiona al repositorio y se acota a dos cadencias, las notificaciones y el feed de eventos se detienen en lo que vio la pasada anterior, un 403 o 404 se recuerda un día en vez de cobrarse otra vez cada hora, y las estrellas y forks más nuevos, la lista de estrellas dadas, las búsquedas de outbound y diez de los once contadores de totals pasaron de REST a GraphQL, las mismas filas por un punto cada una en vez de una petición cada una. Medido otra vez después de la ronda, tres pasadas de un mismo proceso la tarde del mismo día, la pasada estable cobra alrededor de un tercio del `core` y un tercio de los puntos de GraphQL que cobraba antes, mueve la mitad de los bytes por el cable y responde con 304 unas cuatro quintas partes de sus peticiones. Proyectado a un día a las cadencias de fábrica, eso es más o menos una octava parte de las llamadas REST y un tercio de los puntos, más un listado de jobs por ejecución de workflow terminada, que es el único término que crece con lo activos que sean los repositorios y no con cuántos haya. Dos cosas no bajaron. La pasada fría cobra más `core` que antes, porque la primera pasada de eventos de issue lee ahora el mes entero por la cronología y, donde las pull requests están en pila, una lista por ítem para casi cada pull request actualizada en él: minutos, una vez por proceso. Y un pasada que ejecuta todas las familias a la vez sigue tardando minutos y no segundos, porque sus peticiones condicionales cuestan un tercio de segundo cada una y sus consultas GraphQL nueve décimas, una tras otra; las pasadas que hace producción son el de 15 minutos y el de cada hora, cada uno una fracción del total. ## GraphQL es el barato, por mucho Una consulta devuelve el calendario de contribuciones completo de 366 días, todos los totales de contribución, el desglose de commits por repositorio y las cuentas sociales, por **un punto de un presupuesto de cinco mil**. Lo mismo por REST serían docenas de llamadas y no incluiría el calendario en absoluto, porque el calendario no existe en ningún otro sitio. Por eso la familia de cuenta corre con una cadencia de doce horas y aun así no cuesta casi nada, y por eso las familias caras son las de REST que crecen con lo activos que estén los repositorios. ## Dos familias crecen con la actividad, no con el tamaño Todo lo demás cuesta un número de llamadas más o menos fijo por repositorio. Dos no: - **`actions`** cuesta una página de treinta ejecuciones, hasta siete mientras vengan llenas de ejecuciones más nuevas que la ventana, más una petición por cada ejecución cuyos jobs este proceso aún no ha escrito, como mucho veinte por pasada. Un repositorio con integración continua en cada push genera ejecuciones sin parar; uno tranquilo cuesta esa página, contestada con 304, y nada más. - **`artifacts`** recorre hasta cinco páginas por repositorio, y un repositorio activo las llena. > **Qué alargar primero** > > Si el presupuesto aprieta, alarga `artifacts` y después `actions`. Son las dos > únicas cuyo coste crece con lo activos que estén los repositorios y no con > cuántos haya, así que también son las dos únicas en las que alargar la > cadencia recupera una cantidad variable y no una fija. ## Apagar una familia Pon su intervalo a `0`. ```yaml every: families: artifacts: 0 joblogs: 0 ``` Una familia apagada no escribe nada y no cuesta nada. Sus paneles del dashboard se quedan vacíos, que es la lectura honesta. Ver [cadencias](/ghchronicle/es/configuration/cadences/). ## Hacer la cuenta para tu propia cuenta La primera pasada es la cara: el recorrido completo de estrellas, un mes de ejecuciones de workflows y, con `every.history` puesto, el calendario de contribuciones de cada año pasado. Después, multiplica las filas por repositorio de arriba por el número de repositorios que imprime `-list`, y divide el presupuesto por hora entre la cadencia. Una [tarjeta](/ghchronicle/es/card/) se paga como una pasada en frío digan lo que digan las cadencias: la pasada recoge todas las familias, porque cada número que dibuja sale de esa única pasada, y su proceso arranca con la caché de ETags vacía. Así que N tarjetas son N pasadas, y un workflow que dibuja tres paga tres. La señal de que la suma salió mal es un aviso, no una conjetura: ```text level=WARN msg="rate limit reserve reached, family skipped" family=actions ``` De vez en cuando está bien. En cada pasada significa que las cadencias son demasiado rápidas para el número de repositorios. --- # Lo que GitHub no da Los endpoints que se ha verificado que no funcionan en una cuenta personal, escritos para que nadie los vuelva a descubrir. Source: https://jmrplens.github.io/ghchronicle/es/api/limits/ Cada entrada de aquí se comprobó contra la API real. Está escrita para que nadie pierda una tarde volviendo a averiguarlo, y para que un panel que falta se pueda distinguir de un colector roto. ## Estadísticas que no llegan nunca `stats/code_frequency` y `stats/contributors` responden **202 con cuerpo vacío, indefinidamente**, en una cuenta personal. Un 202 normalmente significa "aún se está calculando, vuelve a preguntar", y para estos dos la siguiente respuesta es otro 202. No se llaman a propósito. Las líneas añadidas y quitadas que habría dado `code_frequency` salen en su lugar del colector de commits, por commit y no por semana, atribuidas a un autor y fechadas en el commit. `stats/participation` y `stats/punch_card` sí funcionan y se usan. Diez segundos bastan para verlo en tu propia cuenta: ```sh curl -s -o /dev/null -w '%{http_code}\n' \ -H "Authorization: Bearer $GITHUB_TOKEN" \ https://api.github.com/repos/OWNER/REPO/stats/code_frequency # 202, para siempre curl -s -o /dev/null -w '%{http_code}\n' \ -H "Authorization: Bearer $GITHUB_TOKEN" \ https://api.github.com/repos/OWNER/REPO/stats/participation # 200 ``` ## Facturación | Endpoint | Respuesta | | --------------------------------------- | --------- | | `/settings/billing/actions` | 410 Gone | | `/settings/billing/packages` | 410 Gone | | `/settings/billing/shared-storage` | 410 Gone | | `/user/settings/billing/usage` | 404 | | `/users/{login}/settings/billing/usage` | funciona | Solo la última forma funciona para una cuenta personal, y devuelve marcas de tiempo RFC 3339 completas en un campo que su documentación describe como fecha. ## Solo para organización o empresa Propiedades personalizadas de repositorio, proyectos clásicos, centros de coste y el registro de auditoría. Una cuenta personal no puede ver ninguno, sean cuales sean los permisos del token. ## Endpoints que responden, pero no dicen nada - **`workflows/{id}/timing`** devuelve 200 con un objeto `billable` siempre vacío. Parece la fuente de los minutos por workflow y no lo es. - **`stargazers/history`** devuelve solo las últimas treinta semanas. No sustituye al recorrido de `starred_at`; se comprobó en tres repositorios. - **`/user/installations`** devuelve 403 sin una GitHub App. ## GraphQL se equivoca con los paquetes GraphQL informa de cero paquetes para una cuenta mientras REST los lista. Aquí la consulta bonita está sencillamente equivocada, así que los paquetes vienen de REST, a una llamada por paquete para sus versiones. ## El tráfico se queda obsoleto, no vacío Un repositorio sin tráfico no devuelve una ventana vacía. GitHub sigue devolviendo los últimos catorce días que _tuvieron_ datos, así que la ventana puede terminar hace semanas. El colector anota lo que le dicen. > **Por esto existe gh_security_feature** > > Varias de las entradas de arriba tienen la misma forma: un endpoint que no > responde nada es indistinguible de una función apagada, que es indistinguible > de un repositorio sin nada que informar. `gh_security_feature` anota > explícitamente qué funciones están activadas, para que "sin alertas" y "sin > datos" dejen de parecerse en un dashboard. ## Qué significa esto para una pasada Nada de lo anterior se trata como un fallo. `ghapi.UnavailableError` (403 o 404: la función está apagada) y `ghapi.NotReadyError` (202: GitHub aún está calculando) significan ambos "aquí no hay nada", y la pasada sigue con el siguiente repositorio. Los feeds de actividad tienen su propia versión de esto: pasado su techo GitHub responde **422 "pagination is limited for this resource"**, que se lee como el final de los datos y no como un error. ## Por dónde seguir - [Límites de la API](/ghchronicle/es/api/) es la otra mitad de esto: lo que cuestan las llamadas que sí funcionan, y por qué un 304 no cuesta nada. - [Coste de una pasada](/ghchronicle/es/api/cost/) pone precio a cada familia. --- # La línea de órdenes Las opciones, qué hace cada una y cuáles imprimen algo y salen. Source: https://jmrplens.github.io/ghchronicle/es/reference/cli/ El binario acepta estas opciones y ningún subcomando. Todo lo demás está en el fichero de configuración, porque un horario no es algo que se reescriba a mano. ```sh ghchronicle -config /etc/ghchronicle/config.yaml ``` ## Todas las opciones | Opción | Por omisión | Qué hace | | ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `-config` | `config.yaml` | Ruta del fichero de configuración | | `-once` | apagada | Hace una pasada y sale en vez de quedarse programando; una familia a la que no le toca por cadencia se salta igual | | `-list` | apagada | Imprime los repositorios que se recogerían, y cuáles quedan aparte, y sale | | `-version` | apagada | Imprime la versión, el commit y la fecha de compilación, y sale | | `-groups` | apagada | Imprime los grupos con las familias de cada uno, y sale | | `-backfill` | apagada | Llega tan atrás como permita cada superficie, esperando a que se reponga el límite en vez de parar | | `-backfill-since` | ninguno | Acota el relleno: una fecha (`2024-01-01`), una duración (`720h`), días (`90d`) o años (`2y`) | | `-card` | ninguno | Hace una pasada y escribe un SVG de resumen en esta ruta; esa pasada recoge todas las familias, digan lo que digan las cadencias | | `-card-only` | apagada | Con `-card`, escribe el SVG y nada más: no hace falta ningún destino, no se escribe en ninguno y el fichero de estado se queda como estaba | | `-card-theme` | `auto` | `dark`, `light`, `auto` o `both`: la tarjeta clara en `-card` y la oscura a su lado con `_dark` antes de la extensión, en una sola pasada | | `-card-motion` | `once` | `once`, `loop` u `off`; `loop` solo cambia `terminal` y `ticker` | | `-card-layout` | `summary` | Cuál de los trece [diseños](/ghchronicle/es/card/layouts/) dibujar | | `-card-fields` | ninguno | Campos que muestra la tarjeta, separados por comas, de entre [los campos](/ghchronicle/es/card/#los-campos); vacío significa el valor por omisión del diseño | | `-card-width` | el del diseño | Ancho de la tarjeta en píxeles. Cada diseño dibuja entre dos extremos propios, que dicen tanto su [sección](/ghchronicle/es/card/layouts/) como `-card-layouts`; `badge-row` la ignora, porque su ancho lo deciden sus píldoras | | `-card-speed` | `0.5` | A qué velocidad se reproduce un diseño animado, como decimal de 0 a 1. `0` es la animación más lenta y `1` la más rápida; `0.5` es el ritmo con el que siempre se han dibujado las tarjetas. `0` no es una tarjeta quieta, eso es `-card-motion off` | | `-card-layouts` | apagada | Imprime los diseños con los campos y los anchos que dibuja cada uno, y sale | ## Las cuatro que imprimen y salen `-version`, `-groups`, `-card-layouts` y `-list` responden y paran. Las tres primeras no necesitan token ni configuración; `-list` lee la configuración y le pregunta a GitHub en qué repositorios se resuelven los objetivos, que es la forma barata de comprobar un cambio antes de gastar cuota en una pasada. ```sh ghchronicle -version ghchronicle -groups ghchronicle -card-layouts ghchronicle -config config.yaml -list ``` ## Las tres formas de hacer una pasada ```sh ghchronicle -config config.yaml # el bucle: cada familia en su cadencia ghchronicle -config config.yaml -once # una pasada, en primer plano, y salir ghchronicle -config config.yaml -backfill # el recorrido de la historia, una vez ``` El bucle es lo que ejecuta un servicio. `-once` es lo que ejecuta un trabajo programado, y es también la forma más rápida de ver qué hace un cambio de configuración. Un [relleno histórico](/ghchronicle/es/how/backfill/) es otra intención y lo dice: recorre cada superficie hasta el final y espera a que se reponga un presupuesto agotado en vez de rendirse. ```sh ghchronicle -config config.yaml -backfill -backfill-since 2y ``` ## La tarjeta, en una línea ```sh ghchronicle -config config.yaml -card profile.svg -card-only \ -card-layout github-stats -card-theme dark ``` `-card-only` es la combinación que conviene recordar: hace una ejecución que no escribe puntos, así que no necesita ningún destino configurado y se acepta una configuración que de otro modo se rechazaría al arrancar. [La tarjeta](/ghchronicle/es/card/) tiene los diseños y los campos. `-card-width` es la única opción que cambia lo que dice una tarjeta y no solo cómo se ve, y en un solo diseño. [`activity-heatmap`](/ghchronicle/es/card/layouts/#activity-heatmap) se gasta el sitio en datos: dieciséis semanas del calendario de contribuciones en su extremo cercano, veintitrés con el ancho que declara y el año entero que guarda el recolector en su extremo lejano, que cae justo donde cae el año para que a la tarjeta nunca se le pida llenar un sitio para el que no tiene nada. Cualquier otro diseño reparte el mismo contenido por el ancho que le den, así que ensanchar uno de esos compra proporciones y no información, y su extremo lejano es solo una defensa contra una errata. Un ancho fuera de los dos extremos de un diseño se rechaza antes de la pasada, nombrándolos, y `-card-layouts` los imprime para cada diseño. ```sh ghchronicle -config config.yaml -card calendar.svg -card-only \ -card-layout activity-heatmap -card-width 700 ``` ## La velocidad, y lo que no es su extremo lento `-card-speed` es un solo número para toda la tarjeta. Todos los diseños animados se escalan juntos, los movimientos continuos con el resto: con el mismo ajuste, la banda del ticker tarda más en dar la vuelta y el cursor del terminal parpadea más despacio. Es un mando y no uno por diseño porque el movimiento que tiene la tarjeta se calibró contra sí mismo, el ciclo de un diseño elegido al lado del de otro, y a quien le parezca lenta la banda le parecerá lento también el tecleo. `0.5` es el centro del rango y es exactamente la tarjeta que este renderizador ha dibujado siempre, byte a byte, así que omitir la opción y pedir `0.5` son la misma orden, y cada extremo llega a la misma distancia de ella: `0` dibuja la animación el doble de larga que la de por omisión, `1` la mitad de larga que la de por omisión. > **0 es la animación más lenta, no la ausencia de animación** > > Un rango que empieza en cero parece un interruptor, y este no lo es. En `0` la > tarjeta sigue animándose, tan despacio como este renderizador la dibuje. Lo > que dibuja una tarjeta sin animación ninguna es `-card-motion off`. ```sh ghchronicle -config config.yaml -card slow.svg -card-only \ -card-layout ticker -card-motion loop -card-speed 0.25 ``` ## Dónde está lo demás Todo lo que no está en esa tabla es una clave de configuración y no una opción: [el fichero](/ghchronicle/es/configuration/) es el mapa de todas ellas. --- # Llamarlo desde un programa No hay biblioteca de Go. Lo que hay es una pasada, NDJSON por la salida estándar, y buenas razones para no llamarlo en bucle. Source: https://jmrplens.github.io/ghchronicle/es/reference/subprocess/ > **No hay biblioteca de Go** > > Todos los paquetes de esta herramienta viven bajo `internal/`, y Go prohíbe > esa importación desde fuera del módulo. La vía del subproceso de esta página > no es un apaño para algo que se te haya escapado: es la interfaz. ```text main.go:6:2: use of internal package github.com/jmrplens/ghchronicle/internal/collect not allowed ``` Lo que hay en su lugar es un binario que hace una pasada, escribe un objeto JSON por línea en la salida estándar y termina. Cualquier cosa capaz de lanzar un proceso y leer una tubería puede usarlo, en cualquier lenguaje, y obtiene los mismos puntos que recibiría una base de datos en vez de una segunda pasada por la API. ## Una configuración para una sola pregunta ```yaml github: token: ${GITHUB_TOKEN} reserve_rate: 500 targets: repos: [acme/telemetry] sinks: stdout: true stdout_format: json state_file: /tmp/ghchronicle-adhoc.json log: level: warn ``` Hay dos cosas de `targets` que conviene acertar a la primera. **Nombra repositorios, no la cuenta.** `repos` es una inclusión, no un filtro: añadir `user: acme` al lado no acota nada, descubre la cuenta entera y la recoge también. `-list` es la forma barata de comprobarlo antes de gastar cuota en ello. ```sh ghchronicle -config adhoc.yaml -list ``` ```text acme/telemetry ``` **Omitir `user` apaga once familias sin coste.** Los colectores de cuenta (`account`, `totals`, `ratelimit`, `events`, `notifs`, `billing`, `profile`, `outbound`, `history`, `achievements` y `keys`) no tienen ninguna cuenta por la que preguntar y se saltan, que es casi todo lo que una aplicación que pregunta por un solo repositorio no quiere pagar. El log va siempre a la salida de error, sea cual sea el nivel, así que la salida estándar lleva datos y nada más. Eso es lo que hace que la tubería se pueda analizar sin filtrarla antes. ## La orden ```sh ghchronicle -config adhoc.yaml -once > points.ndjson 2> sweep.log ``` `-once` hace una sola pasada y termina. No arranca el exportador ni ocupa ningún puerto, así que no choca con una instancia permanente en la misma máquina. ## Qué sale Tres líneas de salida, de la cuenta de demostración que usan todos los ejemplos de aquí: ```json {"time":"2026-09-08T16:08:17.651862791Z","measurement":"gh_repo","tags":{"archived":"false","default_branch":"main","fork":"false","full_name":"acme/telemetry","language":"Go","license":"apache-2.0","owner":"acme","repo":"telemetry","visibility":"public"},"fields":{"age_days":1290,"days_since_push":0,"forks":21,"network":21,"open_issues":9,"repo_id":1043778215,"size_kb":18422,"stars":148,"url":"https://github.com/acme/telemetry","watchers":11}} {"time":"2026-09-07T00:00:00Z","measurement":"gh_traffic","tags":{"full_name":"acme/telemetry","kind":"clones","owner":"acme","repo":"telemetry"},"fields":{"count":94,"uniques":71,"url":"https://github.com/acme/telemetry/graphs/traffic"}} {"time":"2026-09-08T16:08:17.651862791Z","measurement":"gh_release","tags":{"draft":"false","full_name":"acme/telemetry","owner":"acme","prerelease":"false","repo":"telemetry","tag":"v2.4.0"},"fields":{"age_days":22,"assets":4,"downloads":1840,"url":"https://github.com/acme/telemetry/releases/tag/v2.4.0"}} ``` Cuatro claves, y son las mismas cuatro en todos los puntos: | Clave | Qué contiene | | ------------- | ------------------------------------------------------------------ | | `time` | La fecha en que ocurrió la cosa, RFC 3339. No la hora de la pasada | | `measurement` | Qué clase de cosa es, siempre con el prefijo `gh_` | | `tags` | Cadenas, y solo cadenas. Juntas identifican la serie | | `fields` | Los valores: números, booleanos y alguna cadena suelta como `url` | La distinción de `time` es el diseño entero, y se ve en esas tres líneas. El punto de tráfico lleva la fecha `2026-09-07T00:00:00Z` porque ese es el día en que ocurrieron esos 94 clones, y todas las pasadas de la próxima quincena lo volverán a ofrecer con la misma fecha. El punto del repositorio lleva el momento de la pasada, porque "148 estrellas" es cierto ahora y no tiene otra fecha que llevar. La release lleva su antigüedad en un campo por la misma razón. Todas las medidas y todos los campos están en [Medidas](/ghchronicle/es/collectors/measurements/). `stdout_format: influx` imprime line protocol de InfluxDB en su lugar, que es la mejor opción cuando el otro extremo ya lo habla. ## Acotarlo a lo que te interesa Incluso contra un solo repositorio, una pasada ejecuta veintiuna familias: las veintitrés por repositorio menos `deps` y `joblogs`, que vienen apagadas. `every` las apaga, y `default` es la capa que lo hace en una línea: pon todo a `0` y luego nombra lo que quieras de vuelta. ```yaml every: default: 0 families: traffic: 6h ``` Esa ejecución cuesta cinco llamadas a la API, una para resolver el repositorio nombrado en `targets` y cuatro para la familia de tráfico, e imprime 48 objetos: 28 `gh_traffic`, 10 `gh_traffic_path` y 10 `gh_traffic_referrer`. > **Un default de 0 nunca enciende las apagadas** > > `deps`, `history` y `joblogs` vienen con una cadencia interna de `0`, y ni > `default` ni `every.groups` alcanzan a una familia que viene apagada. > Nombrarla bajo `families:` es la única forma de encender una, así que la > configuración de arriba recoge tráfico y nada más, el grafo de dependencias > incluido. Una familia no es una medida, que es la otra mitad de esto: `repo` por sí sola emite `gh_repo`, `gh_repo_language`, `gh_repo_topic`, `gh_release` y varias más. Apagar la familia es lo que ahorra llamadas a la API; filtrar el flujo es lo que te ahorra leerlas. ```sh ghchronicle -config adhoc.yaml -once | grep '"gh_traffic"' ghchronicle -config adhoc.yaml -once | jq -c 'select(.measurement == "gh_repo") | {repo: .tags.full_name, stars: .fields.stars}' ``` Un nombre de familia mal escrito es un error de arranque, y el mensaje enumera todos los nombres que existen, así que no hace falta guardar una copia de la lista en ninguna parte. ## Leerlo desde otro programa En cualquier lenguaje, porque el contrato es una tubería y una línea de JSON. Aquí Python, porque un programa en Go no puede importar esto y acabaría lanzando el mismo subproceso. ```python import json import subprocess proc = subprocess.Popen( ["ghchronicle", "-config", "adhoc.yaml", "-once"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, ) stars = {} for line in proc.stdout: point = json.loads(line) if point["measurement"] == "gh_repo": stars[point["tags"]["full_name"]] = point["fields"]["stars"] if proc.wait() != 0: raise SystemExit("la pasada falló; repítela sin silenciar la salida de error") print(stars) ``` ```text {'acme/telemetry': 148} ``` Lee la tubería según se va llenando, no después de que el proceso termine. El destino vacía su búfer una vez por familia, así que quien consume ve los puntos de tráfico mientras el colector de commits sigue trabajando, y una pasada sobre una cuenta entera tarda minutos. Esperar al final significa además tenerlo todo en memoria: sobre un puñado de repositorios con actividad, solo la familia `actions` puede producir más de diez mil objetos en una sola pasada. Mandar el log a `DEVNULL` está bien para un programa al que solo le importa si la pasada salió bien. Es el valor por defecto equivocado mientras estás escribiendo ese programa: consérvalo y léelo. ## Dos preguntas más pequeñas - **-list** Imprime los repositorios en alcance, un nombre completo por línea, y cuesta las llamadas de descubrimiento y nada más. ```sh ghchronicle -config adhoc.yaml -list ``` - **-card** Hace igualmente una pasada completa, pero escribe un SVG autocontenido sin ninguna base de datos configurada. La tarjeta va de una cuenta y no de un repositorio, así que necesita una configuración con `targets.user`: la de arriba no lo tiene, y recibe `render: card has no login` y un 1. Ver [la tarjeta](/ghchronicle/es/card/). ```sh ghchronicle -config account.yaml -card summary.svg -card-only ``` ## Códigos de salida | Código | Significa | | ------ | ------------------------------------------------------------------------------------------------------------------------------- | | 0 | La pasada se ejecutó | | 1 | No pudo arrancar, o no pudo listar los repositorios: no hay fichero de configuración, o no es válido, o GitHub rechazó el token | | 2 | Una opción desconocida. Es el paquete flag de Go, y ya ha impreso el uso | El cero responde a "¿se ejecutó la pasada?", no a "¿funcionó todo?". Un colector que falla se registra y la pasada continúa, porque un repositorio con una función apagada no debe detener la pasada de los otros cuarenta. ```text level=ERROR msg="collector failed" family=issueevents repo=acme/parser err="/repos/acme/parser/issues/events?per_page=100&page=1: 504 Gateway Timeout" ``` Quien llame y necesite enterarse de eso tiene que leer la salida de error. No hay código de salida para ello, a propósito: en una cuenta grande alguna familia falla en algún sitio casi todas las pasadas, y un estado que lo dijera estaría permanentemente en rojo. Matar el proceso también suele dar un 1. `SIGTERM` y `SIGINT` cancelan la pasada, lo que ya está en la tubería se queda ahí, y la ejecución termina con `ghchronicle: context canceled`. Suele, porque el código depende de dónde caiga la señal: durante el descubrimiento es un 1 que lleva el error de la propia petición cancelada, y en el último repositorio de una familia se registra como un fallo de colector y la pasada termina igualmente con un 0. Quien llame con su propio plazo debería tratar una muerte que ha ordenado él como "incompleta", en vez de leer el código de salida para saberlo. ## Llamarlo en bucle es la forma equivocada Una pasada cuesta llamadas a la API: cuatro por repositorio para el tráfico, tres por repositorio más dos puntos de GraphQL por cada diez repositorios para la familia del repositorio, un punto de GraphQL por repositorio para commits y uno o dos para issues. [Coste de una pasada](/ghchronicle/es/api/cost/) tiene la tabla medida. El presupuesto son 5000 llamadas REST por hora para todo el token, compartidas con lo demás que lo use, y el colector frena antes de gastar las últimas `reserve_rate`: detiene una familia en vez de cruzar esa línea, y lo dice en el log. Así que un programa que llame a esto en cada petición, o con un temporizador apretado, recibe una pasada vacía y un aviso, no números más frescos. De ahí se siguen dos cosas, y la segunda sorprende. **Cachea la respuesta.** Estos números se mueven en escala de horas. La propia ventana de tráfico de GitHub se actualiza una vez al día. **Espera que la segunda ejecución no imprima nada.** Una familia solo se recoge cuando su cadencia dice que toca, y `-once` la marca como ejecutada en el fichero de estado. Dos pasadas con un minuto de diferencia dan por tanto un flujo completo y luego uno vacío, con código de salida 0 las dos veces, lo que parece un fallo y es el freno funcionando. En nivel `info` el log lo dice por ausencia: ```text level=INFO msg="repositories discovered" count=1 level=INFO msg="rate budget" bucket=core remaining=3949 limit=5000 level=INFO msg="sweep finished" ``` Ninguna línea `written`, porque no tocaba nada. Borrar el fichero de estado recoge todo otra vez a precio completo, incluido el recorrido único de todas las estrellas, así que apunta `state_file` a un sitio que controle la aplicación y déjalo ahí. > **Cuando no es puntual** > > Si lo que el programa quiere es un flujo continuo y no una respuesta ahora, > deja de lanzarlo: ejecútalo como servicio y dale un > [destino](/ghchronicle/es/sinks/). El destino de fichero escribe este mismo > JSON en un fichero rotatorio para cualquier cosa que lo siga, y los destinos > de push llegan a un almacén que el programa puede consultar sin tocar > GitHub. --- # Resolución de problemas Los mensajes que parecen errores y no lo son, los que sí lo son, y los datos que parecen mal y no lo están. Source: https://jmrplens.github.io/ghchronicle/es/reference/troubleshooting/ ## Cosas que parecen errores y no lo son **`not available (403)` o `(404)`.** La función está apagada en ese repositorio, o el token no la ve. Dependabot, code scanning, las discusiones y el grafo de dependencias responden así cuando están desactivados. El colector anota el hecho y sigue: un repositorio con una función apagada no debe detener la pasada de los otros cuarenta. Si son _todos_ los repositorios y no uno, es el token. El tráfico necesita acceso de escritura; las alertas necesitan `security_events`. Ver [el token](/ghchronicle/es/start/token/). **Una función encendida y nada recogido de ella.** Code scanning activado esta mañana, Dependabot encendido, un foro abierto: el colector preguntó antes de que lo hicieras, le dijeron que no, y recuerda ese rechazo un día en vez de pagarlo en cada pasada. Se nota como muy tarde al día siguiente, y reiniciar el proceso vuelve a preguntar en el acto. Ver [un rechazo también se recuerda](/ghchronicle/es/api/#un-rechazo-también-se-recuerda). **`still being computed by GitHub (202)`.** GitHub calcula los endpoints `stats/*` de forma asíncrona y responde 202 con cuerpo vacío mientras trabaja. La siguiente pasada suele conseguir los números. Dos de ellos no lo hacen nunca. En una cuenta personal `stats/code_frequency` y `stats/contributors` devuelven 202 con cuerpo vacío indefinidamente, que es por lo que este proyecto no los llama: las líneas añadidas y quitadas salen del colector de commits. **`pagination is limited for this resource` (422).** El final de un feed de actividad, no un fallo. GitHub sirve tres páginas del feed de eventos y rechaza la cuarta. **Un repositorio sin tráfico mostrando una ventana que terminó hace semanas.** GitHub sigue devolviendo los últimos catorce días que _tuvieron_ datos, no los últimos catorce días. El colector anota lo que le dicen. **Una familia que no aparece nunca en el log.** Aún no le toca. Con una cadencia de doce horas, medio día de logs puede legítimamente no mencionar nunca `account`. ## Cosas que sí son errores **`github.token is empty and GITHUB_TOKEN is unset`.** Exactamente lo que dice. **`every.families.: unknown collector`.** El nombre no es una familia. El mensaje lista las treinta y cuatro que existen, en un paréntesis tras los dos puntos. **`groups: is empty`.** `groups: []` no recogería absolutamente nada. Omite la clave para recogerlo todo, que es lo que significa cuando no está. **`groups[N]: "" is not a group`.** El nombre no es un grupo. El mensaje lista los que existen, y `ghchronicle -groups` imprime cada uno con sus familias. **`groups[N]: "" is a family, not a group`.** Familias y grupos son sustantivos en minúscula de la misma tabla, así que este es fácil de encontrar. El mensaje nombra el grupo en el que está la familia, que probablemente es lo que querías, y apunta a `every.families.`, que es donde vive la cadencia de una sola familia. **`sinks: enable at least one of ...`.** Una ejecución que recoge y tira es casi nunca lo que alguien quiso. `-card-only` es la excepción y no necesita ningún destino. **`prometheus exporter: listen tcp :9605: bind: address already in use`.** Se informa al arrancar en vez de quedar sepultado en una goroutine, para que un choque de puerto no te deje con un colector corriendo y un exportador ausente en silencio. **`influx write: 400`.** Casi siempre una colisión de tipo de columna. InfluxDB fija una columna como etiqueta o como campo la primera vez que la ve y rechaza las escrituras posteriores que no coincidan. Si un colector cambió de qué tipo es un nombre, hay que borrar la tabla: `DELETE /api/v3/configure/table`. **`family failed everywhere, not marking it as run`.** Fallaron todos los repositorios en una familia, así que se reintentará en vez de darse por hecha. Que falle un repositorio es normal; que fallen todos es el token, la red o una caída. **`rate limit reserve reached, family skipped`.** Una vez está bien. En cada pasada significa que las cadencias son demasiado rápidas para el número de repositorios. Alarga `artifacts` y después `actions`; ver [coste de una pasada](/ghchronicle/es/api/cost/). ## Los datos parecen mal **Un número es múltiplo del número de pasadas.** Algo que es una instantánea se está sumando en el tiempo. Los referrers, las rutas, las etiquetas y los hitos son instantáneas de una ventana sin fecha propia; se sellan al inicio del día UTC para que las pasadas de un día reescriban una fila, y el dashboard toma la más reciente y no la suma. **La mediana de tiempo hasta la primera revisión dice No data.** El panel lee `seconds_to_first_human_review`, que deja fuera a los bots de revisión y las respuestas del propio autor; en una cuenta donde nadie más revisa, ninguna pull request lo lleva y la tarjeta está honestamente vacía. La velocidad de los bots está en la tabla Reviewers, donde cada uno va marcado como bot: medido, nueve de cada diez pull requests tuvieron una revisión de un bot en menos de un minuto. **Los clones son enormes comparados con las visitas.** La integración continua clona un repositorio miles de veces por cada visita humana. Un repositorio medido aquí tuvo más de cien clones por cada visita. `clones` no cuenta personas. **`open_issues` no cuadra con el número de issues.** Ese campo es de GitHub, y GitHub cuenta las pull requests como issues dentro. La medida `gh_issue` es la que cuenta issues. **El almacenamiento de artefactos parece pequeño.** Compara el campo `walked` con `count` en `gh_artifact_total`. Cuando no coinciden, el tamaño vivo es un suelo: el repositorio tiene más artefactos de los que recorrió el tope de páginas. **La gráfica de tráfico solo llega catorce días atrás.** Eso es una primera pasada. La ventana se reescribe día a día en cada pasada, así que la serie se extiende conforme el colector sigue corriendo. No se puede rellenar: GitHub nunca guardó nada más viejo. **Un panel dice "Query would scan 10000 Parquet files".** InfluxDB 3 Core escribe un fichero por partición y por petición de escritura, y no los compacta nunca, así que un almacén alimentado por una versión de esta herramienta anterior al registro de escrituras guarda sus filas en muchos más ficheros de los que necesita. Ensanchar el intervalo del panel no ayuda: el límite cuenta los ficheros que abre el planificador, antes de cualquier agregación. Lo que ayuda es el registro, que viene encendido y detiene el crecimiento, y después una de tres cosas para lo ya acumulado: subir `--query-file-limit` en el servidor, reescribir las tablas afectadas, o pasar a InfluxDB 3 Enterprise, que compacta por su cuenta y es gratis para uso doméstico. Ver [solo se escribe lo que ha cambiado](/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). **Un panel de Prometheus muestra una línea plana.** Eso es el almacén, no los datos. El exportador sirve valores actuales, así que la ventana de tráfico de catorce días se colapsa a su día más reciente y la historia de estrellas al total actual. Ver [la fecha del punto](/ghchronicle/es/how/dating/). ## No se escribe nada Ejecuta una pasada en primer plano y lee lo que dice. Después comprueba, en orden: 1. que `-list` imprime los repositorios que esperas, 2. que el log de la pasada dice `written`, 3. que el destino es alcanzable. ```sh ghchronicle -config config.yaml -list # los repositorios, y cuáles quedan aparte ghchronicle -config config.yaml -once # una pasada en primer plano, y salir journalctl -u ghchronicle -f # bajo systemd ``` `debug` añade tres líneas a eso y nada más: el tamaño del libro de escrituras al arrancar, las familias de cuenta saltadas por falta de `targets.user` y las entradas que un destino dejó fuera por viejas. No hay registro por petición en ningún nivel. Ver [registro](/ghchronicle/es/configuration/logging/). ```yaml log: level: debug ``` Una familia a la que aún no le toca sencillamente no aparece. > **Borrar el fichero de estado cuesta cuota, y una cosa más** > > Recuerda seis cosas, y cinco de ellas solo cuestan cuota cuando se van: lo que > se vuelve a recoger se indexa por medida, etiquetas y marca de tiempo y > sobrescribe. La sexta, `last_head`, es el commit desde el que arrancaba cada > diff de dependencias, y sin ella la pasada siguiente tiene la fotografía y no > el diff. Ver > [el fichero de estado](/ghchronicle/es/configuration/#state_file). ## Loki descarta entradas Busca la línea de depuración que las cuenta. Loki rechaza una entrada que esté más atrasada que su ventana de desorden respecto a la entrada más nueva que ya hay en ese stream, unas dos horas por omisión, así que el destino deja fuera las más viejas en vez de perder el envío entero. Sube `max_age` solo junto al propio `out_of_order_time_window` de Loki. Ver [Loki](/ghchronicle/es/sinks/loki/). --- # Las capas de prueba Tres capas, y solo la primera es gratis: el contrato de los bytes, los almacenes reales en contenedores y las consultas propias de los dashboards. Source: https://jmrplens.github.io/ghchronicle/es/reference/testing/ Las pruebas están en tres capas, y conviene distinguirlas porque solo la primera es gratis. Dos de ellas levantan nueve contenedores, y quien decide si espera merece saber qué compra con esa espera. | Capa | Orden | Docker | Tiempo | Qué demuestra | | ------------------ | ---------------------- | ------ | ---------------- | ----------------------------------------------------------------------------------------------- | | L1, el contrato | `make test` | no | unos 5 s | los bytes exactos que cada destino pone en el cable | | L2, los almacenes | `make test-e2e-docker` | sí | 53 s en caliente | que un almacén real acepta esos bytes y conserva la fecha del hecho | | L3, los dashboards | el mismo objetivo | sí | incluido arriba | que las consultas propias de los cinco dashboards responden contra lo que escribieron los destinos | L1 corre en cada push. L2 y L3 son una sola suite tras la etiqueta de compilación `dockere2e`, así que `go test ./...` no levanta ningún contenedor; en CI corren semanalmente, a demanda y como puerta de publicación. ## L1: los bytes en el cable `test/e2e` compila el binario real, lo ejecuta contra un GitHub falso cuyas fixtures están en `test/e2e/testdata` y cuya tabla de rutas es `test/e2e/fakegh`, y apunta cada destino a un servidor de captura `httptest`. Después comprueba los bytes: el line protocol, el sobre `_bulk`, las sentencias SQL, la ruta de Graphite, la carga OTLP. El falso además cobra sus respuestas como lo hace api.github.com: un ETag por fixture REST y ninguno en las respuestas GraphQL, un 304 sin coste para la petición que lo presenta, uno por cada otra respuesta de la API, nada por el almacenamiento de objetos al que redirige un log de job, y el bloque de presupuesto en cada respuesta GraphQL. Eso es lo que permite que `TestTheSecondSweepIsPricedByTheCache` ejecute dos pasadas en un mismo proceso y sostenga que cada URL respondida 200 la primera vez volvió 304 la segunda, que la segunda pasada cobró menos de la mitad de las peticiones core de la primera, y que `own_cost` en la fila `gh_rate_limit` es el número de consultas que hizo el proceso y no cero. Esa es la prueba correcta para un formato, y es lo bastante rápida para correr mientras se toca un destino. Lo que no puede detectar es nada sobre lo que el receptor tenga una opinión. Un servidor de captura responde 204 a todo. No tiene tipos de columna, ni mapping, ni planificador de consultas, ni esquema. ## L2: los almacenes de verdad `test/e2e/docker` levanta los almacenes reales en contenedores, ejecuta una pasada desde el mismo GitHub falso hacia todos ellos y luego relee cada almacén y comprueba el valor, las etiquetas y sobre todo la marca de tiempo. La regla de fechado es el producto de esta herramienta: una estrella se fecha cuando se dio, una ejecución de workflow cuando terminó, un día de tráfico en la fecha de ese día. Ninguna prueba de captura puede demostrar que el almacén guardó la fecha del hecho y no la de la pasada, porque guardarla es trabajo del almacén. Estos son los defectos por los que existe esta capa, todos reales: **InfluxDB fija el tipo de una columna la primera vez que la ve.** InfluxDB 3 decide si una columna es etiqueta o campo en cuanto la ve y rechaza toda escritura posterior que no coincida: `400 invalid column type for column 'owner', expected iox::column_type::tag`. Un servidor de captura responde 204 y no se entera. Esto ya costó borrar una base de datos, y reproducirlo fue lo primero que se hizo con el stack en contenedores. **El mapping dinámico de Elasticsearch decide si los dashboards pueden agregar.** Un panel saca una url con un `top_metrics`, que normalmente necesita un campo keyword y no uno de texto. Dos auditorías distintas lo anotaron como no verificable por falta de un Elasticsearch real. Esta capa lo responde: indexando por `_bulk` y releyendo el mapping que el clúster se construyó solo. **PostgreSQL tiene que aceptar el DDL.** El destino SQL emite sentencias en lugar de hablar el protocolo, así que hasta ahora nadie las había parseado nunca. La suite las pasa por `psql`, inserta y planifica las consultas de los paneles contra el esquema que creó el destino, y no contra uno transcrito de InfluxDB. **Las rutas de Graphite deben tener la profundidad que indexan los dashboards.** Los dashboards direccionan los nodos de la ruta por posición, y el acuerdo entre esas posiciones y lo que escribe el destino lo mantenía una tabla a mano que no comprobaba nadie. Aquí el destino escribe a carbon y se le pide la ruta de vuelta a la API de render. ## L3: las consultas propias de los dashboards Con los almacenes ya cargados, los cinco dashboards generados se ejecutan por `/api/ds/query` de Grafana, que es el camino que toma `cmd/check_dashboards` contra un Grafana vivo. Cada datasource se provisiona al arrancar con un uid fijo y el arnés emite un token de cuenta de servicio, así que la consulta de un panel pasa por Grafana igual que para una persona que mira el dashboard. Eso es lo que convierte tres comprobadores manuales en algo que la CI ejecuta, y lo que zanja la duda de Elasticsearch de arriba: un panel que no puede agregar no devuelve ningún data frame. ## Lo que no detecta ninguna Todas las capas corren contra el GitHub falso, así que aquí nadie se entera de que GitHub cambie una carga, retire un endpoint o limite de otra manera. Para eso está `ghchronicle -once` contra un token real. Tampoco demuestran nada sobre un almacén que la suite no levanta. La respuesta cubre InfluxDB 3 Core, PostgreSQL 18, Elasticsearch 9, Graphite 1.1, Prometheus 3, Loki 3, el colector de OpenTelemetry y Telegraf, en las versiones fijadas. OpenSearch, TimescaleDB y todo lo que quede detrás del salto de Telegraf o de OTLP siguen siendo una inferencia a partir del formato. Hay cinco cosas más, y ninguna es una capa. Cada una se enciende con una variable de entorno y se salta cuando no está, así que un `go test ./...` corriente sigue sin red. **El almacén que tú ejecutas.** `test/live` envía por push un puñado de puntos a un Loki o a un colector de OpenTelemetry nombrados en `GHC_LIVE_LOKI` o `GHC_LIVE_OTLP`. Responde a la única pregunta que los contenedores no pueden: si tu instancia los acepta. ```sh GHC_LIVE_LOKI=http://localhost:3100 go test ./test/live/ ``` **La API real, de extremo a extremo.** `GHC_E2E_LIVE=1` corre `TestLiveAPI` contra GitHub en vez de contra el falso, con un `GITHUB_TOKEN` de verdad, y hace la pasada sobre la cuenta que nombra `GHC_E2E_USER`. ```sh GHC_E2E_LIVE=1 GHC_E2E_USER=octocat GITHUB_TOKEN=ghp_... go test ./test/e2e/ -run TestLiveAPI ``` **Lo que cuesta una pasada en caché.** `GHC_LIVE_CONFIG` apunta `TestLiveSweepCacheFootprint` a un fichero de configuración y hace la pasada sobre la cuenta que nombra, informando de las entradas y los bytes que guarda la caché de peticiones condicionales tras cada pasada. Esas son las cifras sobre las que descansan la cota de 256 MB y el [coste de una pasada](/ghchronicle/es/api/cost/), y así es como se reproducen para tu propia cuenta. `GHC_LIVE_DUMP=1` añade la lista por url a la salida estándar. ```sh GHC_LIVE_CONFIG=config.yaml go test ./internal/ghapi/ -run TestLiveSweepCacheFootprint -v ``` **Un repositorio, una familia.** `cmd/probe` corre los colectores contra un solo repositorio e imprime la línea que escribirían, sin escribir nada en ningún sitio. `GHC_DUMP=` imprime entero cada punto de esa familia, que es la forma más rápida de ver lo que produce de verdad un colector. ```sh go run ./cmd/probe owner/name GHC_DUMP=actions go run ./cmd/probe owner/name ``` **Las imágenes de la tarjeta.** `GHC_CARD_GALLERY` nombra un directorio que ya existe y `TestCardGallery` dibuja en él una tarjeta por diseño, desde el GitHub falso y no desde la cuenta de nadie. De ahí salen las imágenes de [la página de diseños](/ghchronicle/es/card/layouts/), y un diseño que cambia de forma queda a un comando de tener unas imágenes que concuerden con él. La cuenta son los fixtures base con `test/e2e/testdata/gallery/` superpuesto: un año de contribuciones, los catorce días completos de tráfico de GitHub, cinco repositorios que ordenar y uno de ellos en seis lenguajes, que la cuenta más pequeña sobre la que afirma el resto de suites no puede dar a una imagen. Un fixture llamado `~` responde solo por ese repositorio, y cualquier otro toma prestado el de hello-world. Cada diseño sale de una sola pasada con `-card-theme both` como dos ficheros, `card-.svg` en la paleta clara y `card-_dark.svg` en la oscura, que es lo que leen el `ThemeImage` del sitio y el `` del README. Los dos diseños que hacen bucle salen una segunda vez con `-card-motion loop`, como `card--loop.svg` y su gemela `_dark`. Solo esos dos: en cualquier otro diseño `loop` dibuja la misma tarjeta que `once`, así que una imagen en bucle suya sería una segunda copia de la primera con un nombre que promete otra cosa. Cuáles son lo dice `Loops` en el registro, y la galería lo lee en vez de mantener su propia lista. ```sh mkdir -p /tmp/cards GHC_CARD_GALLERY=/tmp/cards go test ./test/e2e/ -run TestCardGallery ``` `make check-gallery` dibuja la galería en un directorio temporal y falla, nombrando cada diferencia, si el conjunto guardado en el repositorio ya no coincide byte a byte; `make gallery` la regenera en su sitio. El job «Generated artifacts» de CI ejecuta la comprobación en cada pull request. ## Levantar el stack Docker con el plugin de compose, y sitio para las imágenes. Luego: ```sh make test-e2e-docker ``` Arriba, ejecutar, abajo por todos los caminos incluido el de una comprobación fallida, y después una verificación de que `docker ps` no deja nada del proyecto. Una suite que deja nueve contenedores tras un fallo es una suite que nadie ejecuta dos veces. > **Nada de esto escucha fuera de loopback** > > Cada puerto se publica en `127.0.0.1`, en un puerto libre que Docker elige > entre 49200 y 49299, nunca el puerto por defecto del almacén: 9200, 8086, > 5432, 2003, 9090, 3100, 3000 y 4318 son de lo que ya haya en la máquina. El > arnés relee los puertos elegidos con `docker compose port`, y el proyecto se > llama `ghchronicle-e2e` tanto en el fichero de compose como en cada orden, > así que nada de aquí puede tocar un contenedor que no arrancó. Arranque, medido en frío con las imágenes ya descargadas: Elasticsearch 29 s, Loki 21 s, Grafana 13 s, la API de render de Graphite 10 s, InfluxDB 8 s, PostgreSQL 6 s, el resto 6 s. El stack está listo en 30 s; el objetivo de principio a fin, con el desmontaje incluido, son 53 s. ## Depurar con el stack en pie El motivo para fallar una comprobación es ir a mirar el almacén, y un almacén que ya se ha desmontado no se puede mirar. Por eso las dos mitades son objetivos separados, y el arnés reutiliza un stack que encuentra ya levantado y lo deja levantado. 1. Levanta los almacenes y déjalos en pie. La orden imprime el puerto en el que acabó cada servicio. ```sh make e2e-docker-up ``` 2. Ejecuta la suite, o una sola prueba de ella, tantas veces como haga falta. ```sh go test -count=1 -tags dockere2e -timeout 30m -v ./test/e2e/docker/ ``` `GHCHRONICLE_E2E_KEEP=1` además impide que el binario de pruebas desmonte un stack que levantó él mismo, que es lo que quieres cuando falla un único `-run`. 3. Pregúntale al almacén qué piensa y luego desmóntalo. ```sh make e2e-docker-logs SERVICE=influxdb make e2e-docker-down ``` Con los puertos del paso 1: ```sh # Qué cree InfluxDB que es cada columna. Esta es la respuesta a un 400 al escribir. curl -s "http://127.0.0.1:/api/v3/query_sql?db=ghchronicle" \ --data-urlencode "q=SELECT * FROM information_schema.columns WHERE table_name = 'gh_repo'" # El mapping que Elasticsearch se construyó solo. curl -s "http://127.0.0.1:/ghchronicle-*/_mapping?pretty" # Lo que el destino SQL creó de verdad. psql "postgres://ghchronicle:ghchronicle@127.0.0.1:/ghchronicle" -c '\d+ gh_repo' # La ruta de Graphite, nodo a nodo. curl -s "http://127.0.0.1:/metrics/find?query=github.repo.*" ``` Grafana está en el puerto que publicó, con `admin` y `admin`, y todos los datasources vienen provisionados, así que la consulta de un panel se puede pegar en Explore y ejecutar a mano. ## Tres cosas que hubo que decirle al stack Cada una de estas produjo en silencio una respuesta equivocada antes de que se encontrara, y cada una está en el fichero de compose o en su configuración con la medición al lado: - **Carbon descarta sin avisar un punto más antiguo que su archivo más largo.** Una estrella fechada en 2020 desapareció bajo una retención de seis años y la escritura se dio por aceptada. Por eso la retención es `1d:12y`. - **El `MAX_CREATES_PER_MINUTE` por defecto de carbon es 50**, menos rutas de las que crea una sola pasada, así que se perdería la mayor parte de la primera. - **Loki responde 204 a un push y no lo sirve hasta que se vuelca el chunk.** La prueba sondea en vez de preguntar una vez, y `chunk_idle_period` es 5 s. > **Una máquina con cortafuegos necesita una regla para hacer scrape del exportador** > > El exportador de Prometheus es el único destino del que se hace scrape en > lugar de recibir envíos, así que el scrape tiene que salir del contenedor de > vuelta al anfitrión. Donde la política INPUT por defecto es denegar, ningún > contenedor de ningún puente alcanza ningún puerto del anfitrión. Basta con una > regla estrecha: permitir TCP 49300-49399, el rango que acotan `scrapePortLow` > y `scrapePortHigh` en `sweep_push_stores_test.go`, desde el espacio de > direcciones de los contenedores y desde nada más. Con ufw es > > ```sh > ufw allow proto tcp from 172.16.0.0/12 to any port 49300:49399 > ``` > > Sin ella el arnés lo informa como `ErrExporterUnreachable` y las > comprobaciones de Prometheus se saltan con un motivo en lugar de fallar, lo > que significa que nunca se han ejecutado en esa máquina. Un runner de CI no > necesita regla, y una máquina de trabajo normal tampoco. Una máquina que añade la regla ejecuta esas comprobaciones por primera vez, que es cuando `promNeedsHistory` en `dashboards_test.go` empieza a importar: el exportador vive solo lo que dura el test, así que cada panel de series temporales y cada panel construido sobre `increase()` se comprueba contra nada, y solo se afirman los paneles instantáneos. ## En CI `.github/workflows/e2e.yml` ejecuta `make test-e2e-docker` y tiene tres formas de entrar: lanzamiento manual con una ref opcional, una programación semanal sobre main y `workflow_call`, para que un pipeline de publicación condicione una etiqueta a él con una línea en vez de con una copia del trabajo que se desvía del original. No es una comprobación obligatoria en una pull request: nueve contenedores y unos 10 GB de imágenes son demasiado para cada push, y para eso está L1. La ejecución semanal es el sentido de la programación. Nada más en el repositorio levanta un contenedor, así que sin ella la suite solo correría cuando alguien se acordara, que es como una suite acaba rota durante semanas sin que nadie lo sepa. La misma suite corre también bajo el detector de carreras, en `.github/workflows/race.yml`: cada semana, en cada publicación junto a la puerta de E2E, y a mano. El arnés compila el colector con `-race` y lo arranca con `GORACE=halt_on_error=1`, así que una carrera dentro del colector hace fallar la prueba que lo lanzó, con el informe. En local es `make test-e2e-docker-race`.