# ghchronicle ghchronicle es una herramienta de código abierto que recoge todas las métricas que GitHub expone sobre una cuenta y guarda cada una con la fecha en que ocurrió. Source: https://docs.jmrp.io/ghchronicle/es/ Cada punto que escribe ghchronicle 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 - [95 medidas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/) - [34 colectores](https://docs.jmrp.io/ghchronicle/es/collectors/) - [11 destinos](https://docs.jmrp.io/ghchronicle/es/sinks/) - [154 paneles de dashboard](https://docs.jmrp.io/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, y ninguno de hace más de treinta días. Las notificaciones de la bandeja se conservan tres meses salvo que se guarden. Los logs de los jobs se borran por omisión 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 único binario estático de Go, sin dependencias en tiempo de ejecución. ## 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: un relleno recorre estrellas, pull requests y ejecuciones hasta donde la API responda, pero el tráfico de más de catorce días, los eventos más allá de los últimos trescientos o de hace más de treinta días y los logs más antiguos que el periodo de retención del repositorio, noventa días por omisión, no los tiene nadie - 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/v2/cmd/ghchronicle@latest ``` ### Configurar La configuración mínima que hace algo. Un ${VAR} en una credencial, una dirección o una ruta 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 hora y las claves de la cuenta una vez al día. 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**: Todo salvo el exportador de Prometheus se envía por push: a InfluxDB, PostgreSQL, Graphite, Elasticsearch, Prometheus por su receptor OTLP, 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](https://docs.jmrp.io/ghchronicle/es/start/quickstart/): De cero a la primera pasada. - [El token](https://docs.jmrp.io/ghchronicle/es/start/token/): Qué permisos, y por qué el automático no basta. - [La fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/): La idea de la que se deriva el resto del diseño. - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/): Qué puede y qué no puede responder cada uno de los 11. --- # Qué es Qué recoge ghchronicle de GitHub y el problema que viene a resolver: un tráfico que dura catorce días y un feed de eventos de trescientos eventos y treinta días. Source: https://docs.jmrp.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](https://docs.github.com/es/rest/metrics/traffic) sirve catorce días y olvida. El [feed de actividad](https://docs.github.com/es/rest/activity/events) guarda los últimos trescientos eventos, y ninguno de hace más de treinta días. La [bandeja de notificaciones](https://docs.github.com/es/subscriptions-and-notifications/concepts/about-notifications#notification-retention-policy) descarta toda notificación a los tres meses, leída o no, salvo que se haya guardado. Los [logs de los jobs](https://docs.github.com/es/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository) se borran por omisión a los noventa días. La lista de estrellas dice cuándo se dio cada una, pero desde julio de 2026 solo a los [administradores y colaboradores](https://docs.github.com/es/rest/activity/starring#new-access-restrictions) del repositorio, y 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 estático de Go sin dependencias en tiempo de ejecución, 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 95 | | **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](https://docs.jmrp.io/ghchronicle/es/start/quickstart/): de cero a una primera pasada. - [El token](https://docs.jmrp.io/ghchronicle/es/start/token/): qué ámbito compra qué familia. - [La fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/): la idea de diseño de la que se sigue todo lo demás. --- # Inicio rápido Instalar ghchronicle, apuntarlo a una base de datos y lanzar la primera pasada sobre una cuenta de GitHub, y qué hace esa primera pasada que las siguientes no hacen. Source: https://docs.jmrp.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. ## El camino corto Si tienes una terminal, deja que pregunte: ```sh ghchronicle -setup ``` Pide un token, le pregunta a GitHub de quién es, pregunta qué cuenta recoger y dónde poner los números, comprueba que ese sitio responde, ofrece el dashboard y ofrece dejarlo funcionando solo. Lo que escribe es un `config.yaml` que carga y, si pediste servicio, una unidad, un agente o una tarea programada según el sistema. Las credenciales nunca van en la configuración. Van a un fichero al lado que solo puedes leer tú, y la configuración las nombra. El instalador de arriba lo ofrece como último paso, así que en una máquina nueva los dos juntos son todo lo que hay que hacer. El resto de esta página es lo que escribe, para quien prefiera escribirlo a mano o quiera saber a qué acaba de decir que sí. ## De cero a la primera pasada 1. **Instala el binario.** - **Script** ```sh curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | bash ``` En Windows, desde PowerShell: ```powershell irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 | iex ``` - **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 ``` - **Go** ```sh go install github.com/jmrplens/ghchronicle/v2/cmd/ghchronicle@latest ``` 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](https://docs.jmrp.io/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 ``` Un `${VAR}` en una credencial, una dirección o una ruta de fichero 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 hora, las claves SSH y GPG de la cuenta una vez al día. ## Qué hace la primera pasada que las siguientes no hacen Cuatro cosas ocurren una sola vez, y son la razón de que la primera pasada sea la cara. - Se lee el historial diario de estrellas de cada repositorio, vea lo que vea el token, hasta la primera semana del repositorio, y se recorre entera, página a página, la lista de estrellas de cada repositorio cuya lista el token puede leer (desde julio de 2026, solo los [administradores y colaboradores](https://docs.github.com/es/rest/activity/starring#new-access-restrictions) del repositorio), para que cada una de esas estrellas lleve el momento en que se dio y quién la dio. Después, el historial es una petición por repositorio por sus treinta semanas más nuevas, casi siempre un 304 gratis, y las cien estrellas más nuevas de cada lista legible viajan en una consulta GraphQL por cada diez repositorios. - 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. - Las pull requests en coautoría de toda la vida de la cuenta, que `achievements` recorre para el recuento de Pair Extraordinaire: 35 consultas y 23,7 MB sobre 2.315 pull requests fusionadas, medido el 2026-09-27. Después, una pasada recorre los días desde entonces, una página, y el historial entero otra vez una vez por semana. - El calendario de contribuciones de cada año, si `every.families.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. Tampoco hay nada en caché todavía, así que cada respuesta de la primera pasada se paga entera, donde una posterior pregunta con el ETag con que llegó la última respuesta y casi siempre recibe un 304 gratis; ver [la caché que hay junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). 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](https://docs.jmrp.io/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 nueve cosas](https://docs.jmrp.io/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 siete de las nueve. Las otras dos son el commit desde el que arranca cada > diff de dependencias, cuyos cambios del hueco no se vuelven a recoger, y lo > que el fichero registra de cada almacén, entre ello una relectura que una > migración aún debe, que entonces nada vuelve a leer. ## Por dónde seguir - [Formas de instalar](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/how/dating/) es la idea de diseño de la que se deriva todo lo demás. - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) decide qué preguntas podrás hacer después. - [Coste de una pasada](https://docs.jmrp.io/ghchronicle/es/api/cost/) es el precio medido en llamadas a la API. --- # El token Qué familias de ghchronicle compra cada permiso del token de GitHub, y por qué el GITHUB_TOKEN automático de un workflow no basta. Source: https://docs.jmrp.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, incluido el permiso de repositorio Administration, que es el que pide el tráfico, 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, y Administration (lectura) en un token fine-grained | El tráfico es un 403. GitHub solo muestra visitas y clones a quien puede hacer push al repositorio | | `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, porque poder leer un repositorio no basta. GitHub solo muestra visitas y clones a quien puede hacer push al repositorio, y un token fine-grained necesita además el [permiso de repositorio Administration (lectura)](https://docs.github.com/es/rest/metrics/traffic#get-repository-clones--fine-grained-access-tokens); un token al que le falte cualquiera de los dos recibe un 403 en ese 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. - **No puede leer el tráfico, ni siquiera el suyo.** El tráfico necesita acceso de escritura a _cada_ repositorio que se recoja, y el token automático solo lo tiene para el repositorio en el que corre el workflow. Incluso ahí recibe un 403: GitHub pide además el permiso de repositorio Administration, que un workflow no puede conceder a su token automático. - **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](https://docs.jmrp.io/ghchronicle/es/install/actions/). ## Funcionar sin token Solo para las tres órdenes que no preguntan nada a GitHub: `-backfill-status`, `-publish-dashboard` y `-uninstall`. Todo lo que recoge, una tarjeta de números públicos incluida, necesita uno, y sin él se para al arrancar con `github.token is empty and GITHUB_TOKEN is unset`. Lo que cuesta un token al que le falta un permiso es la tabla de arriba: la parte que no puede ver queda vacía, y el log dice `not available (403)` para cada una, que es el colector informando de un permiso, no de un fallo. --- # Comparado con las alternativas ghchronicle junto a GitHub Insights, repohistory, github-repo-stats, star-history y los exportadores de Prometheus: qué guarda cada uno, cuánto y dónde. Source: https://docs.jmrp.io/ghchronicle/es/start/compared/ Varias herramientas responden a una parte de lo que responde ghchronicle: guardar el tráfico de GitHub más allá de catorce días, dibujar la curva de estrellas o llevar recuentos de repositorios a Prometheus. Se diferencian en lo que cubren, en si una fila lleva la fecha en que ocurrió la cosa o el momento en que alguien miró, y en dónde acaban los datos. Esta página pone ocho de ellas junto a ghchronicle, dice de dónde sale cada celda y dice cuándo una de ellas es la mejor elección. ## Qué guarda cada una | Herramienta | Qué cubre | Cuánto tiempo se guarda | Fechado, o un valor actual | Relleno | | ----------- | --------- | ----------------------- | -------------------------- | ------- | | [ghchronicle](https://docs.jmrp.io/ghchronicle/es/collectors/) | 34 familias: tráfico, estrellas, forks, ejecuciones de workflows con sus jobs y sus pasos, pull requests, issues, commits, el feed de eventos, notificaciones, alertas, facturación y más | mientras lo guarde el almacén al que escribe | fechado: cada punto en el momento en que ocurrió la cosa; una lista de diez sin fecha al inicio de su día UTC; un estado actual en la pasada | sí, `-backfill` recorre cada familia activada hasta donde responde la API, pero lee solo las cien entregas de webhook más recientes de cada hook y como mucho 500 logs de jobs fallidos por repositorio; no el tráfico de hace más de 14 días, el feed de eventos más allá del techo de GitHub, los logs de jobs de hace más de 90 días, los nombres y los instantes exactos de las estrellas de un repositorio cuya lista de stargazers el token no puede leer (sus estrellas se guardan por día) ni, desde el 2026-10-01, las ejecuciones de workflows más antiguas que el ajuste de retención del repositorio | | [El propio GitHub](https://docs.github.com/es/rest/metrics/traffic) | tráfico: visitas y clones por día, los diez principales referentes y rutas; el feed de eventos; la bandeja de notificaciones; ejecuciones, logs y artefactos de Actions | el tráfico, 14 días; el feed de eventos, hasta 300 eventos y 30 días; la bandeja, 3 meses salvo las marcadas como guardadas; logs y artefactos de Actions, 90 días por omisión, y desde el 2026-10-01 también las ejecuciones | visitas y clones por día; referentes y rutas como una sola lista de diez para los 14 días enteros | no aplica: no se sirve nada anterior a esas ventanas | | [KipHub Traffic](https://github.com/farique1/KipHub-Traffic/blob/711ccf7558bae756eed14accdf1b418cbe17c7f3/README.md) | solo tráfico: visitas y clones por día, y la lista de referentes de 14 días de GitHub | mientras se conserve su fichero JSON local | visitas y clones por día, fusionados en un solo fichero en cada descarga | no: su README dice que se descargue más a menudo que cada 14 días | | [repohistory](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/README.md) | tráfico: clones, visitas, referentes y páginas populares; historia de estrellas | desde el primer inicio de sesión en adelante | tráfico guardado por repositorio y fecha; estrellas en la fecha de cada una para las 400 páginas de 100 que lee como mucho, las 40.000 más antiguas, y el resto repartido por igual entre los días posteriores a la última leída y marcado como estimado | el tráfico no, empieza en el primer inicio de sesión; las estrellas, de la lista de stargazers de GitHub | | [github-repo-stats](https://github.com/jgehrcke/github-repo-stats/blob/ff48771cbb4324c3b77490bbfc26d6a5f3831cdb/README.md) | tráfico: visitas y clones por día, principales referentes y rutas; stargazers y forks a lo largo del tiempo | "un periodo arbitrariamente largo", como instantáneas diarias guardadas en git | una instantánea diaria del tráfico; estrellas y forks en la fecha de cada uno, y una instantánea diaria del número de estrellas que lleva la curva más allá de 40.000 | estrellas y forks sí, las estrellas hasta las 40.000 más antiguas, un tope que su código llama un límite de la API de GitHub; el tráfico no | | [grafana/github-traffic](https://github.com/grafana/github-traffic/blob/f3ab4e1cb5fbeb52983fd67e93a3118e1630e96e/README.md) | visitas y clones del día más reciente de la ventana, el número de estrellas, principales rutas y referentes, el límite de la API | no se indica: lo que guarde el Prometheus que lo lee | valores actuales, fijados en cada ejecución | ninguno en su código | | [promhippie/github_exporter](https://github.com/promhippie/github_exporter/blob/a9208c3a257a6605a01cf0b18637dd4fa28b4153/README.md) | repositorios, organizaciones y sus puestos, facturación, runners propios, estadísticas de administración; ejecuciones y jobs de workflows desde un webhook | ejecuciones y jobs guardados en su base de datos durante una ventana de purga, 24 horas por omisión; el resto no se indica | valores actuales; las horas de ejecuciones y jobs se exponen como valores de marca de tiempo | las ejecuciones y los jobs llegan solo por webhook, según ocurren; por lo demás no se indica | | [githubexporter/github-exporter](https://github.com/githubexporter/github-exporter/blob/eedff7e7d54326c9607faa8b2ffc28a0140685de/README.md) | por repositorio: estrellas, forks, watchers, issues abiertas, pull requests, tamaño, descargas de releases; el límite de la API | no se indica: lo que guarde el Prometheus que lo lee | valores actuales | no se indica | | [star-history.com](https://github.com/star-history/star-history/blob/608d0e39be441665c01cd6ac1fd01ca5ec1eaf39/README.md) | solo estrellas | no se guarda nada para ti: cada gráfico se dibuja desde la lista de stargazers de GitHub | fechado por el `starred_at` de una muestra de estrellas, no de cada una: por debajo de 15 páginas de 100 lee todas y se queda con unas quince estrellas espaciadas por igual (todas si son menos de 30); a partir de 15 páginas (16 en su API de SVG) lee ese número de páginas repartidas por la lista y se queda con la primera estrella de cada una; un último punto da el total de hoy | la curva entera, en cada dibujo | ## Dónde se ejecuta y dónde escribe cada una | Herramienta | Se ejecuta como | Escribe en | Autoalojada | | ----------- | --------------- | ---------- | ----------- | | [ghchronicle](https://docs.jmrp.io/ghchronicle/es/install/) | un único binario de Go para Linux, macOS y Windows; Docker; una unidad de systemd; una GitHub Action | InfluxDB, PostgreSQL, Graphite, Elasticsearch, Loki, OpenTelemetry, Telegraf, un fichero o stdout, y Prometheus solo como valores actuales; un dashboard de Grafana generado para cinco de ellos | sí | | El propio GitHub | github.com: los gráficos de Insights y la API REST; el gráfico de tráfico de un repositorio privado necesita GitHub Pro, Team o Enterprise Cloud | nada que sea tuyo | no aplica | | KipHub Traffic | un script de Python, con una ventana opcional de Dear PyGui, que se lanza a mano; último commit el 2024-02-16 | un fichero JSON en una carpeta local `JSONs` | sí, en tu propia máquina | | repohistory | un servicio alojado en repohistory.com, instalado como GitHub App, que recoge una vez al día; gratis para un repositorio, de pago para más | su propia base de datos de Supabase; exportación a CSV | el código es AGPL-3.0; ejecutar una copia propia no se indica | | github-repo-stats | una GitHub Action, una vez al día, un repositorio por job | ficheros CSV y un informe en HTML y PDF, con commit en una rama | sí, en las Actions de tu propio repositorio | | grafana/github-traffic | un contenedor Docker, en Python, cada hora por omisión; para un usuario o una organización | un endpoint de Prometheus en el puerto 8001; el dashboard 15000 de Grafana | sí | | promhippie/github_exporter | un binario de Go, paquetes DEB y RPM, contenedores; los colectores de webhook necesitan que sea accesible desde internet | un endpoint de Prometheus; Chai (el predeterminado en la mayoría de sus binarios publicados), SQLite, MySQL o PostgreSQL para los datos del webhook | sí | | githubexporter/github-exporter | un contenedor Docker, en Go, en el puerto 9171 | un endpoint de Prometheus | sí | | star-history.com | una web alojada, una extensión de Chrome y una API que dibuja el gráfico como SVG | una imagen para incrustar | alojada; la sección de desarrollo de su README la ejecuta en local | Las filas de las demás herramientas son lo que dice el README, la documentación o el código de cada una en el commit enlazado, leídos el 2026-09-24, con "no se indica" donde no dicen nada. Además de los README enlazados en la primera tabla, las celdas salen de estos ficheros: - repohistory en `9dd4ac2` (2026-03-04): el [trabajo diario](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/.github/workflows/cron.yaml), el [escritor del tráfico](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/web/src/utils/update-traffic.ts), el [lector de estrellas](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/web/src/utils/repo/stars.ts), la [página de precios](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/landing/app/pricing/page.tsx), la [ruta de exportación](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/web/src/app/api/export/%5BrepoId%5D/route.ts) y la [licencia](https://github.com/repohistory/repohistory/blob/9dd4ac28b42d372cdb91668e379569b0cc69d694/LICENSE). - github-repo-stats en `ff48771` (2026-07-10): el [recolector](https://github.com/jgehrcke/github-repo-stats/blob/ff48771cbb4324c3b77490bbfc26d6a5f3831cdb/fetch.py), cuyo recorrido de estrellas dice que devuelve "at most the oldest 40.000 stargazers (a GitHub HTTP API limitation)", y que lee el número de estrellas en cada ejecución y lo guarda cuando ha cambiado, "for building up a stargazer timeseries beyond said limit". - grafana/github-traffic en `f3ab4e1` (2026-05-25): su único [fichero de código](https://github.com/grafana/github-traffic/blob/f3ab4e1cb5fbeb52983fd67e93a3118e1630e96e/github-traffic.py), que fija cada gauge de tráfico con el último día de la ventana. - promhippie/github_exporter en `a9208c3` (2026-09-21): las [métricas](https://github.com/promhippie/github_exporter/blob/a9208c3a257a6605a01cf0b18637dd4fa28b4153/docs/partials/metrics.md), los [ajustes](https://github.com/promhippie/github_exporter/blob/a9208c3a257a6605a01cf0b18637dd4fa28b4153/docs/partials/envvars.md), la [guía de uso](https://github.com/promhippie/github_exporter/blob/a9208c3a257a6605a01cf0b18637dd4fa28b4153/docs/content/usage.md), los [almacenes](https://github.com/promhippie/github_exporter/tree/a9208c3a257a6605a01cf0b18637dd4fa28b4153/pkg/store), la [base de datos predeterminada](https://github.com/promhippie/github_exporter/blob/a9208c3a257a6605a01cf0b18637dd4fa28b4153/pkg/command/command.go) y los [binarios publicados](https://github.com/promhippie/github_exporter/blob/a9208c3a257a6605a01cf0b18637dd4fa28b4153/.goreleaser.yaml). - githubexporter/github-exporter en `eedff7e` (2026-06-09): la [lista de métricas](https://github.com/githubexporter/github-exporter/blob/eedff7e7d54326c9607faa8b2ffc28a0140685de/METRICS.md) y la [definición de métricas](https://github.com/githubexporter/github-exporter/blob/eedff7e7d54326c9607faa8b2ffc28a0140685de/exporter/metrics.go). - star-history en `608d0e3` (2026-09-12): el [cargador del gráfico](https://github.com/star-history/star-history/blob/608d0e39be441665c01cd6ac1fd01ca5ec1eaf39/shared/common/chart.tsx), el [cliente de la API](https://github.com/star-history/star-history/blob/608d0e39be441665c01cd6ac1fd01ca5ec1eaf39/shared/common/api.tsx) y las [constantes del servidor de SVG](https://github.com/star-history/star-history/blob/608d0e39be441665c01cd6ac1fd01ca5ec1eaf39/backend/const.ts). - KipHub Traffic en `711ccf7` (2024-02-16): solo su README. La fila de GitHub sale de la documentación de GitHub, leída el mismo día: [el tráfico en la API REST](https://docs.github.com/es/rest/metrics/traffic), [el gráfico de tráfico](https://docs.github.com/es/repositories/viewing-activity-and-data-for-your-repository/viewing-traffic-to-a-repository), [el feed de eventos](https://docs.github.com/es/rest/activity/events), [la bandeja de notificaciones](https://docs.github.com/es/subscriptions-and-notifications/concepts/about-notifications#notification-retention-policy) y [la retención de Actions](https://docs.github.com/es/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#configuring-the-retention-period-for-checks-workflow-runs-commit-statuses-artifacts-and-logs-in-your-repository). La fila de ghchronicle sale de este sitio: [qué se recoge](https://docs.jmrp.io/ghchronicle/es/collectors/), [la fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/), [relleno histórico](https://docs.jmrp.io/ghchronicle/es/how/backfill/), [elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) y [formas de instalar](https://docs.jmrp.io/ghchronicle/es/install/). Las herramientas de terceros cambian: comprueba sus páginas antes de fiarte de una celda. ## Fechado, o un valor actual La columna que más separa a estas herramientas es la cuarta. Un exportador de Prometheus guarda un gauge, y la historia de un gauge son las muestras que Prometheus tomó de él, cada una sellada cuando la leyó. grafana/github-traffic fija su gauge de visitas con el día más reciente de los catorce, así que lo que Prometheus guarda es ese número tal como estaba en cada lectura, sellado con la hora de la lectura y no con el día que cuenta. Las herramientas que escriben ficheros o filas pueden guardar el día mismo. KipHub Traffic, repohistory y github-repo-stats guardan visitas y clones por día, que es la parte del tráfico que tiene fecha, y github-repo-stats y repohistory sacan las estrellas de la fecha en que se dio cada una. ghchronicle aplica la misma regla a cada superficie que lee, y esa regla es la razón de que su lado de Prometheus solo tenga valores actuales: Prometheus rechaza una muestra fechada días atrás, como mide [la fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/#lo-que-prometheus-no-puede-sostener-por-construcción). ## Las ventanas del propio GitHub Casi todas las herramientas de esta página existen por la segunda fila. El tráfico son catorce días. El feed de eventos sirve hasta trescientos eventos, ninguno de hace más de treinta días. La bandeja conserva las notificaciones tres meses, salvo las marcadas como guardadas. Los logs y los artefactos se borran a los noventa días por omisión, y la documentación de GitHub dice que desde el 1 de octubre de 2026 el mismo ajuste de retención borra también las ejecuciones de workflows, las comprobaciones y los estados de commit, que hasta entonces se conservaban 400 días o más. Un repositorio público puede fijarlo entre uno y noventa días; uno privado, hasta 400. Lo que GitHub sirve en esas ventanas está completo mientras dura. Para una pregunta sobre las dos últimas semanas, los gráficos de Insights y la API REST son la herramienta adecuada. ## Cuándo es mejor otra herramienta - **Un gráfico de estrellas para un README, y nada más.** star-history.com lo dibuja a demanda desde la lista de stargazers de GitHub, sin nada que ejecutar y nada que guardar. Desde julio de 2026 GitHub sirve esa lista solo a los administradores y colaboradores del repositorio ([su página de estrellas](https://docs.github.com/es/rest/activity/starring#new-access-restrictions)), y cómo dibuja star-history.com el gráfico de cualquier otro no consta en la fuente fijada arriba. La tarjeta de ghchronicle es una función secundaria y necesita un token y una ejecución. - **Tráfico de unos pocos repositorios, guardado en git, sin base de datos.** github-repo-stats es un workflow diario con un job por repositorio, hace commit de sus datos en una rama y publica un informe en HTML y PDF. ghchronicle necesita un almacén en algún sitio al que llegue. - **Historia del tráfico sin ejecutar nada.** repohistory es un dashboard alojado detrás de una GitHub App, gratis para un repositorio. - **Un recuento actual en un Prometheus que ya tienes.** githubexporter/github-exporter y grafana/github-traffic son un contenedor cada uno y se leen como cualquier otro objetivo. Si un recuento actual es todo lo que quieres, son lo más pequeño que ejecutar. - **Runners propios, puestos de una organización o estadísticas de administración.** promhippie/github_exporter tiene colectores para las tres cosas, y ghchronicle no recoge ninguna. - **Tráfico en una sola máquina, sin ningún servidor.** KipHub Traffic lo guarda en un fichero JSON y lo dibuja en una ventana de escritorio, siempre que alguien lo ejecute cada quince días. > **Leído, no ejecutado** > > Ninguna de las otras ocho herramientas se ejecutó para esta página. Cada > celda de sus filas es lo que dice su propio README, su documentación o su > código, leídos el 2026-09-24, y nada de ello es una medida. No se midió lo > que cuesta cada una en llamadas a la API, cómo se comporta en una cuenta de > cincuenta repositorios ni cómo se comparan sus cifras con las de ghchronicle > para el mismo día. ## Por dónde seguir - [Respuestas cortas](https://docs.jmrp.io/ghchronicle/es/start/questions/): el tráfico más allá de catorce días, las estrellas a lo largo del tiempo, Prometheus y las organizaciones, en un párrafo cada una. - [La fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/): la regla detrás de la cuarta columna. - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/): qué guarda cada destino de ghchronicle. --- # Respuestas cortas Doce preguntas sobre guardar la historia de GitHub con ghchronicle: tráfico más allá de 14 días, estrellas, relleno, Prometheus, organizaciones y coste. Source: https://docs.jmrp.io/ghchronicle/es/start/questions/ Cada respuesta de abajo es la forma corta de una página que tiene el detalle, y enlaza a ella. Donde una respuesta cita a GitHub y no a este proyecto, enlaza a la documentación del propio GitHub. ## ¿Cómo guardo el tráfico de GitHub más de 14 días? Recogiéndolo más a menudo que cada catorce días, cada día con su propia fecha. La [API de tráfico](https://docs.github.com/es/rest/metrics/traffic) de GitHub solo sirve visitas y clones por día de los últimos catorce días. La familia `traffic` de ghchronicle relee la ventana entera cada seis horas por omisión y escribe cada día en su propia fecha, así que las filas convergen en vez de acumularse y una semana de colector parado no pierde nada. Lo anterior GitHub nunca lo guardó. El detalle está en [qué se recoge](https://docs.jmrp.io/ghchronicle/es/collectors/#audiencia) y en [la fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/). ## ¿Cómo sigo las estrellas de GitHub a lo largo del tiempo? Leyendo el historial diario de estrellas de GitHub, que da las estrellas que ganó un repositorio cada día, hasta la semana en que se creó, a cualquiera que pueda ver el repositorio. La familia `stars` de ghchronicle lo lee entero en su primera pasada, para cada repositorio que recoge, y escribe cada día como un punto `gh_star_day`, así que la curva empieza en la primera estrella del repositorio y no el día en que se instaló, y un repositorio cuya lista de stargazers GitHub oculta al token tiene igualmente su curva diaria. Donde el token puede leer esa lista, la familia también la recorre con el tipo de medio de estrellas y escribe cada estrella como un punto `gh_star`, fechado al segundo y con el nombre de quien la dio. Desde julio de 2026 [GitHub solo sirve la lista a los administradores y colaboradores del repositorio](https://docs.github.com/es/rest/activity/starring#new-access-restrictions). El token siempre tiene ese acceso en los repositorios de la propia cuenta y en los de una organización que su usuario administra. Tras la primera pasada, ghchronicle pide las treinta semanas más nuevas de cada historial, una petición por repositorio y casi siempre un 304 gratis, y las cien estrellas más nuevas de cada lista que puede leer, diez repositorios por consulta GraphQL. El historial es el endpoint [`stargazers/history`](https://docs.github.com/es/rest/activity/starring#get-repository-star-history) de GitHub: estrellas por día agrupadas por semana, treinta semanas por página, paginando hacia atrás hasta la primera semana del repositorio, trece páginas en `cli/cli`, hasta 2019. Sus días son días naturales del Pacífico, y cuenta los stargazers de hoy, así que quitar una estrella la saca del día en que se dio. Ver [qué se recoge](https://docs.jmrp.io/ghchronicle/es/collectors/#audiencia) y [lo que GitHub no da](https://docs.jmrp.io/ghchronicle/es/api/limits/). ## ¿Puedo recoger la historia de GitHub de antes de instalarlo? Casi toda, con una ejecución de `ghchronicle -config config.yaml -backfill`. Recorre cada familia activada hasta que la API se acaba, o hasta el límite de `-backfill-since`, espera a que se renueve el límite de la API en vez de saltarse nada y, parado a medias, sigue desde su punto de control al volver a lanzarlo. Ningún relleno alcanza el tráfico de más de catorce días, el feed de eventos pasados sus trescientos eventos o sus treinta días, los logs de jobs de más de noventa días ni, desde el 1 de octubre de 2026, las ejecuciones de workflows pasado el periodo de retención del repositorio. El recorrido, su punto de control y su límite están en [relleno histórico](https://docs.jmrp.io/ghchronicle/es/how/backfill/). ## ¿Puedo archivar las notificaciones de GitHub y el feed de eventos? Sí, siempre que el colector se ejecute mientras siguen ahí. La familia `events` guarda el feed de actividad de la cuenta, que GitHub sirve hasta trescientos eventos y ninguno de hace más de treinta días, y `notifs` guarda la bandeja, que según GitHub conserva las notificaciones tres meses salvo las marcadas como guardadas. Las dos se ejecutan cada quince minutos por omisión, cada elemento es un punto fechado cuando ocurrió, y un destino Loki las convierte en líneas de log. GitHub da las dos ventanas en su documentación sobre [el feed de eventos](https://docs.github.com/es/rest/activity/events) y [la bandeja](https://docs.github.com/es/subscriptions-and-notifications/concepts/about-notifications#notification-retention-policy). Las familias están en [qué se recoge](https://docs.jmrp.io/ghchronicle/es/collectors/#actividad-y-coste), y las líneas de log en [Loki](https://docs.jmrp.io/ghchronicle/es/sinks/loki/). ## ¿Cuánto tiempo guarda GitHub la historia de las ejecuciones de workflows? Desde el 1 de octubre de 2026, noventa días por omisión, según la documentación de GitHub. Los logs y los artefactos ya tenían ese valor; desde esa fecha el ajuste de retención borra también ejecuciones de workflows, comprobaciones y estados de commit, que hasta entonces duraban 400 días o más. Un repositorio público admite de uno a noventa días; uno privado, hasta 400. La familia `actions` de ghchronicle escribe cada ejecución con sus jobs y sus pasos, fechada cuando terminó. Lo que dice GitHub está en [retención de comprobaciones, ejecuciones y artefactos](https://docs.github.com/es/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#configuring-the-retention-period-for-checks-workflow-runs-commit-statuses-artifacts-and-logs-in-your-repository). Un [relleno](https://docs.jmrp.io/ghchronicle/es/how/backfill/) recorre cada ejecución que GitHub todavía guarde, hasta `-backfill-since` si se fija, y expande cada una en sus jobs, y en sus pasos mientras GitHub los siga sirviendo; desde el 1 de octubre de 2026 no llega más atrás que el periodo de retención del repositorio, noventa días por omisión. ## ¿Puede Prometheus guardar la historia de ghchronicle? No. Prometheus sella una muestra cuando la lee y rechaza una mucho más antigua: medido contra Prometheus 3.14 con una ventana de fuera de orden de treinta minutos, una muestra fechada dos días atrás volvió como HTTP 400. Por eso el exportador de Prometheus, y el destino OpenTelemetry con `raw: false`, sirven valores actuales reducidos de las filas fechadas, útiles para alertar. La historia va en InfluxDB, PostgreSQL, Graphite o Elasticsearch, junto a Prometheus si usas ambos. La medida y la reducción están en [la fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/#lo-que-prometheus-no-puede-sostener-por-construcción). ## ¿En qué base de datos guardo las métricas de GitHub? En una que identifique la fila por su marca de tiempo, porque eso es lo que permite reescribir los mismos catorce días en cada pasada sin duplicados. InfluxDB, PostgreSQL, Graphite y Elasticsearch guardan todos la historia fechada, y ghchronicle genera un dashboard de Grafana para cada uno de ellos y para Prometheus, que solo guarda valores actuales. Usar más de un destino es lo normal: un almacén de historia para los gráficos, Prometheus para las alertas. Cada almacén se sopesa en [elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/), y los dashboards están en [importar](https://docs.jmrp.io/ghchronicle/es/dashboards/). ## ¿Por qué stats/code_frequency devuelve siempre 202? En una cuenta personal, GitHub responde a `stats/code_frequency` y a `stats/contributors` con 202 y un cuerpo vacío indefinidamente. Un 202 suele significar que los números aún se están calculando y que una petición posterior los obtendrá; para estos dos, la siguiente respuesta es otro 202. ghchronicle no los llama: las líneas añadidas y quitadas salen del colector de commits, por commit, atribuidas a un autor y fechadas en el commit. La comprobación de diez segundos y los demás endpoints que no responden están en [lo que GitHub no da](https://docs.jmrp.io/ghchronicle/es/api/limits/). ## ¿Por qué la API de tráfico de GitHub devuelve 403 con mi token? Porque el tráfico depende del acceso de push al repositorio, no de poder leerlo. GitHub [solo sirve visitas y clones](https://docs.github.com/es/rest/metrics/traffic) a quien puede hacer push al repositorio, y un token fine-grained necesita además el permiso de repositorio [Administration (lectura)](https://docs.github.com/es/rest/metrics/traffic#get-repository-clones--fine-grained-access-tokens); sin las dos cosas, la respuesta es un 403. El `GITHUB_TOKEN` automático de un workflow no puede recibir Administration, así que recibe un 403 incluso en el repositorio en el que se ejecuta. ghchronicle anota el 403 como no disponible y sigue: el síntoma es un panel de tráfico vacío, no una pasada fallida. Cada permiso y cómo se nota su falta están en [el token](https://docs.jmrp.io/ghchronicle/es/start/token/). ## ¿Funciona con una organización de GitHub? Para sus repositorios, sí: con la organización en `targets.orgs`, sus repositorios se descubren y se recorren como los de la cuenta, con el permiso `read:org`. Las familias de toda la cuenta (calendario de contribuciones, feed de eventos, notificaciones, facturación, paquetes y las de salida) necesitan `targets.user` y describen a un usuario. Lo exclusivo de una organización, como el registro de auditoría, no se recoge: el único endpoint de organización que llama ghchronicle es la lista de repositorios. Las claves están en [objetivos](https://docs.jmrp.io/ghchronicle/es/configuration/targets/), y lo que tiene una organización y una cuenta personal no puede ver está en [lo que GitHub no da](https://docs.jmrp.io/ghchronicle/es/api/limits/). ## ¿Puede ejecutarse en GitHub Actions sin un servidor? Sí, salvo el almacén en el que escribe. El repositorio incluye una Action compuesta, `jmrplens/ghchronicle@v2`, que descarga un binario publicado y hace una pasada, un relleno o una tarjeta. Necesita un token de acceso personal, porque el `GITHUB_TOKEN` automático no tiene acceso de push a otros repositorios, [no puede leer el tráfico ni siquiera del suyo](https://docs.github.com/es/rest/metrics/traffic#get-repository-clones--fine-grained-access-tokens) y no es un usuario. Un runner alojado no conserva el fichero de estado entre ejecuciones, así que salvo que el estado se guarde en caché, lo que necesita un fichero de configuración, cada ejecución es una primera ejecución: recoge todas las familias diga lo que diga su cadencia, vuelve a recorrer los stargazers, el historial de estrellas entero y las pull requests en coautoría de la cuenta, y paga cada respuesta entera, sin ETag con el que recibir un 304 gratis. Los modos, las entradas y el paso de caché están en [GitHub Actions](https://docs.jmrp.io/ghchronicle/es/install/actions/). ## ¿Va a agotar mi límite de la API de GitHub? No por omisión. El colector nunca gasta las últimas 500 llamadas de un presupuesto, o una quinta parte del límite del cubo cuando eso es menos, y una pasada se salta una familia con un aviso antes que cruzar esa línea, así que lo demás que comparte el token sigue funcionando. Cada respuesta se guarda en caché por su ETag, y un 304 Not Modified no gasta cuota, que es lo que hace asequibles las cadencias cortas. El freno está en [límites de la API](https://docs.jmrp.io/ghchronicle/es/api/), y cada familia tiene su precio en [coste de una pasada](https://docs.jmrp.io/ghchronicle/es/api/cost/). ## Por dónde seguir - [Comparado con las alternativas](https://docs.jmrp.io/ghchronicle/es/start/compared/): KipHub Traffic, repohistory, github-repo-stats, star-history y los exportadores de Prometheus, celda a celda. - [Resolución de problemas](https://docs.jmrp.io/ghchronicle/es/reference/troubleshooting/): los mensajes que parecen errores y no lo son, y los que sí. --- # Formas de instalar Formas de instalar ghchronicle: una página por sistema operativo, cinco maneras de poner el binario en marcha, y para qué sirve cada una. Source: https://docs.jmrp.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](https://docs.jmrp.io/ghchronicle/es/install/linux/): Un tar.gz, /usr/local/bin y una unidad de systemd blindada. - [macOS](https://docs.jmrp.io/ghchronicle/es/install/macos/): El archivo darwin, el atributo de cuarentena y un agente o demonio de launchd. - [Windows](https://docs.jmrp.io/ghchronicle/es/install/windows/): Un zip, PowerShell y cmd, una tarea programada y lo que de verdad cambia allí. ## Conseguir el binario - **Script** ```sh curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | bash ``` En Windows, desde PowerShell: ```powershell irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 | iex ``` Cualquiera de las dos deduce la plataforma, coge la release más nueva y se niega a instalar nada cuya suma de verificación no sea la que publicó la release. - **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_2.6.5_linux_amd64.tar.gz sudo install -m 755 ghchronicle /usr/local/bin/ ``` - **Go** ```sh go install github.com/jmrplens/ghchronicle/v2/cmd/ghchronicle@latest ``` Necesita una cadena de herramientas de Go, y compila desde fuente la etiqueta más reciente. El `/v2` es parte de la ruta: sin él, Go se queda con la 1.0.0, la última versión cuya ruta de módulo acepta, y no avisa. - **Contenedor** ```sh docker run -v $PWD/config.yaml:/config.yaml:ro \ -v ghchronicle-state:/var/lib/ghchronicle -e GITHUB_TOKEN \ ghcr.io/jmrplens/ghchronicle -config /config.yaml ``` Distroless, estático y corriendo como uid 65532. Con `state_file` dentro de `/var/lib/ghchronicle` el volumen guarda el estado, y es de ese usuario desde el principio, porque lo es el directorio propio de la imagen: [qué tiene que ser escribible](https://docs.jmrp.io/ghchronicle/es/install/docker/#qué-tiene-que-ser-escribible). - **Action** ```yaml - uses: jmrplens/ghchronicle@v2 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](https://docs.jmrp.io/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](https://docs.jmrp.io/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](https://docs.jmrp.io/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](https://docs.jmrp.io/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, > en un directorio en el que el colector pueda escribir, porque el fichero de > caché y el registro de escrituras viven a su lado. Sin él cada ejecución > recoge todas las familias diga lo que diga su cadencia, y vuelve a recorrer > la lista de estrellas, el historial entero de estrellas y las pull requests > en coautoría. ## Actualizar Una versión nueva se instala encima de la anterior como se instaló esa, y la configuración y el estado pasan tal cual. Cómo son las primeras horas tras ella, de la 2.5.x a la 2.6.x y a la 2.6.1, está en [actualizar](https://docs.jmrp.io/ghchronicle/es/install/upgrading/). --- # Linux El camino completo en Linux: el archivo correcto, la firma, el PATH, un servicio y compilarlo tú mismo. Source: https://docs.jmrp.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. ## La instalación en una línea ```sh curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | bash ``` Deduce la arquitectura, coge la release más nueva, comprueba el archivo contra la suma de verificación que esa release publicó, y deja el binario en `/usr/local/bin` si tiene permiso de escritura. Si no lo tiene, coge el primero de `~/.local/bin` y `~/bin` que exista y ya esté en tu `PATH`, y recurre a `~/.local/bin` cuando no lo está ninguno. Una suma que no cuadra lo detiene sin instalar nada, y no hay opción para saltarse ese paso. Si `cosign` 2.4.2 o posterior ya está en la máquina, verifica además que el propio fichero de sumas viene del workflow de release, y una firma que cosign no confirma lo detiene, con lo que dijo cosign. Un cosign más antiguo no sabe leer el bundle de la firma, así que entonces, como sin cosign, dice que solo se verificó la suma. Termina ejecutando lo que ha instalado, así que la versión que ves es la del fichero recién escrito, y avisa cuando otro `ghchronicle` anterior en tu `PATH` sigue ganando el nombre. Fijar una versión, o elegir dónde va: ```sh curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | VERSION=2.6.5 BIN_DIR=~/bin bash ``` > **Leerlo antes es justo para lo que sirve que sea corto** > > Meter cualquier cosa por una tubería a un shell es ejecutar lo que esa URL > responda hoy. `curl -fsSL -o install.sh`, léelo, y luego > `bash install.sh`: es el mismo script, y es lo bastante corto para leerlo. El resto de esta página es esa misma instalación hecha a mano, que es lo que seguir cuando quieres saber exactamente qué ha aterrizado dónde, o cuando prefieres no ejecutar un script que no has escrito. ## 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=2.6.5 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 mayor móvil junto a las releases > numeradas, porque la Action está listada en el Marketplace y ese listado > necesita una. La release de esa etiqueta **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_2.6.5_linux_amd64.tar.gz: OK ``` 3. Comprueba el propio fichero de sumas, si tienes [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) 2.4.2 o posterior. Uno más antiguo no sabe leer el bundle y falla diga lo que diga el fichero. ```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. Después deja que escriba la configuración: ```sh ghchronicle -setup ``` Pide un token y dónde van los números, comprueba cada respuesta contra aquello que nombra, y escribe un `config.yaml` y, si lo quieres, una unidad de systemd. El instalador de arriba lo ofrece como último paso. ## Ponerlo en el PATH El archivo lleva tres ficheros y ningún directorio, así que descomprímelo donde quieras de verdad. - ghchronicle_VERSION_linux_ARCH.tar.gz - ghchronicle el binario - LICENSE - README.md - **Para todos** ```sh tar -xzf "ghchronicle_${VERSION}_linux_${arch}.tar.gz" ghchronicle sudo install -m 755 ghchronicle /usr/local/bin/ ghchronicle -version ``` - **Para una cuenta** ```sh mkdir -p ~/.local/bin tar -xzf "ghchronicle_${VERSION}_linux_${arch}.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á. `-version` responde con una línea: el número de la versión, y después el commit y la fecha de compilación de esa versión. ```text ghchronicle 2.6.5 (commit , built ) ``` ## Ejecutarlo una vez El binario necesita un fichero de configuración y un token, y [el inicio rápido](https://docs.jmrp.io/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](https://docs.jmrp.io/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 servicio que arranca sobre un fichero de estado que nadie ha escrito aún trae sus once familias de seis horas o más al almacén a lo largo de sus dos primeras horas y media, una por pasada, y solo esa vez; la página de systemd explica por qué. Un planificador también vale: [cron con `-once`](https://docs.jmrp.io/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.2 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/v2/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í no tiene commit ni fecha de compilación de los que informar: esas 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. `-version` nombra en su lugar lo que la compilación sí registra, la versión del módulo que descargó la orden go y la versión de Go que lo compiló: ```text ghchronicle 2.6.5 (module v2.6.5, built with ) ``` La primera versión sale del fichero `VERSION` que el módulo incrusta. - **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, registro, caché | `/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` y la caché como `ghchronicle-state-cache.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://docs.jmrp.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. ## La instalación en una línea ```sh curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | bash ``` Deduce la arquitectura, coge la release más nueva, comprueba el archivo contra la suma de verificación que esa release publicó, y deja el binario en `/usr/local/bin` si tiene permiso de escritura. Si no lo tiene, coge el primero de `~/.local/bin` y `~/bin` que exista y ya esté en tu `PATH`, y recurre a `~/.local/bin` cuando no lo está ninguno. Una suma que no cuadra lo detiene sin instalar nada, y no hay opción para saltarse ese paso. Si `cosign` 2.4.2 o posterior ya está en la máquina, verifica además que el propio fichero de sumas viene del workflow de release, y una firma que cosign no confirma lo detiene, con lo que dijo cosign. Un cosign más antiguo no sabe leer el bundle de la firma, así que entonces, como sin cosign, dice que solo se verificó la suma. Termina ejecutando lo que ha instalado, así que la versión que ves es la del fichero recién escrito, y avisa cuando otro `ghchronicle` anterior en tu `PATH` sigue ganando el nombre. Fijar una versión, o elegir dónde va: ```sh curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | VERSION=2.6.5 BIN_DIR=~/bin bash ``` > **Leerlo antes es justo para lo que sirve que sea corto** > > Meter cualquier cosa por una tubería a un shell es ejecutar lo que esa URL > responda hoy. `curl -fsSL -o install.sh`, léelo, y luego > `bash install.sh`: es el mismo script, y es lo bastante corto para leerlo. El resto de esta página es esa misma instalación hecha a mano, que es lo que seguir cuando quieres saber exactamente qué ha aterrizado dónde, o cuando prefieres no ejecutar un script que no has escrito. ## 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=2.6.5 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 mayor móvil junto a las releases > numeradas, porque la Action está listada en el Marketplace y ese listado > necesita una. La release de esa etiqueta **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_2.6.5_darwin_arm64.tar.gz: OK ``` 3. Comprueba el propio fichero de sumas, si tienes [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) 2.4.2 o posterior. Uno más antiguo no sabe leer el bundle y falla diga lo que diga el fichero. ```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. Después deja que escriba la configuración: ```sh ghchronicle -setup ``` Pide un token y dónde van los números, comprueba cada respuesta contra aquello que nombra, y escribe un `config.yaml` y, si lo quieres, un agente de launchd en `~/Library/LaunchAgents`. El instalador de arriba lo ofrece como último paso. ## Ponerlo en el PATH El archivo lleva tres ficheros y ningún directorio. - ghchronicle_VERSION_darwin_ARCH.tar.gz - ghchronicle el binario - LICENSE - README.md - **Para todos** ```sh tar -xzf "ghchronicle_${VERSION}_darwin_${arch}.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_${VERSION}_darwin_${arch}.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_${VERSION}_darwin_${arch}.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](https://docs.jmrp.io/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. Sin él cada ejecución recoge todas las familias, diga lo que diga su cadencia, y vuelve a recorrer la lista de estrellas, el historial entero de estrellas y las pull requests en coautoría; el fichero de caché que hay a su lado es lo que deja a una ejecución preguntar a GitHub solo por lo que cambió. ## Compilarlo desde fuente Go 1.27.2 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/v2/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í no tiene commit ni fecha de compilación de los que informar: 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. `-version` nombra en su lugar lo que la compilación sí registra, la versión del módulo que descargó la orden go y la versión de Go que lo compiló: ```text ghchronicle 2.6.5 (module v2.6.5, built with ) ``` - **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, registro, caché | `~/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` y la caché como `ghchronicle-state-cache.bin`. El directorio de trabajo de un trabajo de launchd no es algo en lo que apoyarse. Ponlo. Desde la 2.6.1 un ajuste de ruta toma una `~` inicial como el directorio personal, así que la configuración de un agente puede decir `state_file: ~/Library/Application Support/ghchronicle/state.json`; una versión anterior lee la `~` como el nombre de un directorio, y quiere `/Users/tu/...` escrito entero. --- # Windows El camino completo en Windows: el zip, PowerShell y cmd, una tarea programada y lo que de verdad cambia allí. Source: https://docs.jmrp.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. ## La instalación en una línea ```powershell irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 | iex ``` Deduce la arquitectura, coge la release más nueva, comprueba el archivo contra la suma de verificación que esa release publicó, y deja `ghchronicle.exe` en `%LOCALAPPDATA%\Programs\ghchronicle`. Una suma que no cuadra lo detiene sin instalar nada, y no hay parámetro para saltarse ese paso. Si `cosign` 2.4.2 o posterior ya está en tu PATH, verifica además que el propio fichero de sumas viene del workflow de release, la misma comprobación que hace `install.sh`, y una firma que cosign no confirma lo detiene, con lo que dijo cosign, para que una máquina que no llega a Sigstore no se tome por un fichero falsificado. Un cosign más antiguo no sabe leer el bundle de la firma, así que entonces, como sin `cosign`, dice que solo se verificó la suma. Después añade ese directorio a **tu** PATH, el de usuario, para que `ghchronicle` funcione por nombre. Los programas ya abiertos conservan el PATH con el que arrancaron, así que abre una terminal nueva. Windows lee el PATH de máquina antes que el de usuario, así que otro `ghchronicle` instalado para todo el mundo, o una compilación vieja de `go install`, puede seguir respondiendo al nombre cuando esto termina; el script avisa cuando encuentra uno. No escribe nada fuera de tu perfil ni pide elevación: una instalación para toda la máquina es cosa de un instalador con su aviso de UAC, no de un script bajado de la red. Para fijar una versión, elegir el directorio o no tocar el PATH, el script admite parámetros, y eso pide la forma algo más larga porque `iex` no tiene dónde ponerlos: ```powershell & ([scriptblock]::Create((irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1))) -Version 2.6.5 -BinDir C:\tools -NoPathUpdate ``` > **Leerlo antes es justo para lo que sirve que sea corto** > > `irm ... | iex` ejecuta lo que esa URL responda hoy. > `irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 -OutFile install.ps1`, léelo, y luego `.\install.ps1`: es el mismo > script, y es lo bastante corto para leerlo. El resto de esta página es esa misma instalación hecha a mano, que es lo que seguir cuando quieres saber exactamente qué ha aterrizado dónde, o cuando prefieres no ejecutar un script que no has escrito. Después deja que escriba la configuración: ```powershell ghchronicle -setup ``` Pide un token y dónde van los números, comprueba cada respuesta contra aquello que nombra, y escribe un `config.yaml` en `%APPDATA%\ghchronicle` y, si lo quieres, una tarea programada que registras con `schtasks`. ## 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 = "2.6.5" $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 mayor móvil junto a las releases > numeradas, porque la Action está listada en el Marketplace y ese listado > necesita una. La release de esa etiqueta **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/) 2.4.2 o posterior; uno más antiguo no sabe leer el bundle y falla diga lo que diga el fichero. 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_VERSION_windows_ARCH.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 ``` Responde con una línea: el número de la versión, y después el commit y la fecha de compilación de esa versión. ```text ghchronicle 2.6.5 (commit , built ) ``` > **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 Un `${VAR}` en una credencial, una dirección o una ruta de fichero de la 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](https://docs.jmrp.io/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 las cinco que corren cada quince minutos, `actions`, `events`, `notifs`, `activity` y `ratelimit`, corren cuatro veces menos, que es el mismo cambio que [hace cron en Linux](https://docs.jmrp.io/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.2 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/v2/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í no tiene commit ni fecha de compilación de los que informar: 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. `-version` nombra en su lugar lo que la compilación sí registra, la versión del módulo que descargó la orden go y la versión de Go que lo compiló: ```text ghchronicle 2.6.5 (module v2.6.5, built with ) ``` - **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, registro, caché | `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, y lo hace en `state_file`, `sinks.dedupe_file` y `log.file` desde la 2.6.1. Una versión anterior no expande ninguna ruta, y lee `${LOCALAPPDATA}/ghchronicle/state.json` como un directorio llamado literalmente `${LOCALAPPDATA}`; con una de esas, escribe la ruta entera. `%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. La ruta de la propia configuración es la excepción de la tabla: se da en la línea de órdenes, donde el colector no expande nada, así que el `-config` de una tarea programada la nombra entera, `C:\Users\tu\AppData\Local\ghchronicle\config.yaml`. 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. Deja escribibles el fichero de estado y el fichero de caché que hay a su lado. El colector reemplaza los dos escribiendo un fichero nuevo y renombrándolo sobre el viejo, y NTFS se niega a reemplazar un fichero de solo lectura incluso cuando se le pide reemplazarlo, así que un fichero de estado de solo lectura deja de guardarse y cada pasada avisa `state not saved`. El registro de escrituras y los ficheros rotados del destino de fichero son los dos a los que quita ese atributo él mismo, y una pasada no falla por ellos. --- # 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://docs.jmrp.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 cuatro 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, y su caché, `state-cache.bin`, que es lo que permite a un reinicio preguntar a GitHub solo por lo que cambió; y el servicio mantiene `state-lock` mientras corre, que es como `-migrate -yes` sabe que no debe cambiar los almacenes por debajo de él. Un relleno, o la relectura de una [migración](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones), que se detiene a medias deja también ahí su punto de control. `ReadWritePaths` cubre el directorio, así que todos están ya permitidos. Si pones el fichero de estado o el registro en otro sitio, esa ruta hay que añadirla aquí, y la caché sigue al fichero de estado vaya donde vaya. Perder el registro cuesta una pasada de reescritura: [solo se escribe lo que ha cambiado](https://docs.jmrp.io/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado); perder la caché, una pasada a precio completo: [la caché que hay a su lado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). ## 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 - state-cache.bin la caché, también al lado - state-lock lo mantiene el servicio mientras corre 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 ``` La primera pasada corre todas las familias, porque aún no ha corrido ninguna, salvo que el servicio arranca como mucho una familia de seis horas o más por pasada: `traffic`, `stats`, `forks` y el resto de las once llegan al almacén a lo largo de las dos primeras horas y media, y solo esta vez. Ver [las familias lentas se turnan](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan). ## Qué vigilar El log dice qué se escribió y dónde. ```text level=INFO msg=written sink=influxdb family=repo points=934 unchanged=1955 level=INFO msg="rate budget" bucket=core remaining=4477 limit=5000 ``` [Qué dice el log un día bueno](https://docs.jmrp.io/ghchronicle/es/configuration/logging/#qué-dice-el-log-un-día-bueno) tiene todas las líneas de rutina. Tres 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. - **`migration pending`**, en cada arranque tras una actualización, significa que un almacén aún guarda filas con una forma que esta versión ya no escribe, y que el arranque las dejó: mira [tras una actualización](https://docs.jmrp.io/ghchronicle/es/install/systemd/#tras-una-actualización). ```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. ## Tras una actualización Sustituye el binario y reinicia el servicio. Con el valor por omisión, [`migrate: auto`](https://docs.jmrp.io/ghchronicle/es/configuration/#migrate), el arranque comprueba cada almacén frente a los cambios que trae la nueva versión y, antes de su primera pasada, aplica por su cuenta cada uno que no pierde nada, y lo dice en `WARN`. Un cambio que deja es un `WARN` en cada arranque, con el almacén, el motivo y las dos órdenes: [lo que hace un arranque](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#lo-que-hace-un-arranque). Ejecútalas con el propio usuario del servicio y con su fichero de entorno, con el servicio parado, porque `-migrate -yes` se niega a correr a su lado: ```sh sudo systemctl stop ghchronicle sudo systemd-run --uid=ghchronicle --gid=ghchronicle --pipe --wait --collect \ --property=EnvironmentFile=/etc/ghchronicle/ghchronicle.env \ /usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -migrate sudo systemd-run --uid=ghchronicle --gid=ghchronicle --pipe --wait --collect \ --property=EnvironmentFile=/etc/ghchronicle/ghchronicle.env \ /usr/local/bin/ghchronicle -config /etc/ghchronicle/config.yaml -migrate -yes sudo systemctl start ghchronicle ``` La primera imprime el plan y no cambia nada; la segunda lo aplica y vuelve a leer lo que despejó. `systemd-run` lee el fichero de entorno como root, igual que la unidad, así que puede seguir en modo 600, y ejecuta el binario como `ghchronicle`: medido en systemd 257, una orden lanzada así vio el token de un fichero que solo root podía leer y corrió como el usuario indicado. Si la lanza root, `-migrate -yes` guarda como de root, en modo 600, los ficheros que reescribe junto al de estado, `state.json` entre ellos, y el servicio, que corre como `ghchronicle`, se detiene entonces al arrancar y nombra el fichero en vez de empezar con uno nuevo, que olvidaría una relectura aún debida: devuélveselo con `chown ghchronicle:ghchronicle`. Con `-once` lanzado desde cron en vez de un servicio, comenta la línea mientras duran las dos órdenes: un `-once` no tiene ningún bloqueo mientras hace su pasada, así que nada le impide correr a la vez que `-migrate -yes`. ## 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. Sin él cada ejecución recoge todas las familias, diga lo que diga su cadencia, y vuelve a recorrer la lista de estrellas, el historial entero de estrellas y las pull requests en coautoría; el fichero de caché que hay a su lado es lo que deja a una ejecución preguntar a GitHub solo por lo que cambió. Ten en cuenta que un cron horario da a cada familia una cadencia horaria como mucho, así que las cinco que corren cada quince minutos, `actions`, `events`, `notifs`, `activity` y `ratelimit`, corren cuatro veces menos: el tiempo en cola de una ejecución de workflow se lee cuando ya ha terminado, y un hilo de notificaciones que se movió dos veces dentro de la hora muestra solo su segundo movimiento. --- # Docker ghchronicle en Docker: la imagen distroless, qué hay que montar con escritura y un compose junto a InfluxDB. Source: https://docs.jmrp.io/ghchronicle/es/install/docker/ Elige una pila, pon dos líneas en un `.env` al lado, y arráncala. ## InfluxDB and Grafana ```yaml # ghchronicle, with InfluxDB and Grafana. # # Put two lines in a .env file beside this one: # # GITHUB_TOKEN=github_pat_... # GITHUB_USER=your-login # # then `docker compose up -d`. Grafana is on http://localhost:3000, # admin and the password below, with the dashboard already in it: the # collector publishes it on start and points it at the store beside it. name: ghchronicle services: influxdb: image: influxdb:3-core # Without a credential, because a store that exists for the first time # when the collector first writes to it has nobody to have made one. command: - influxdb3 - serve - --node-id=node0 - --object-store=file - --data-dir=/var/lib/influxdb3 - --without-auth volumes: - influx:/var/lib/influxdb3 healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8181/health"] interval: 5s timeout: 3s retries: 30 grafana: image: grafana/grafana:12.3.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} ports: - "3000:3000" volumes: - grafana:/var/lib/grafana healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 30 ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: influxdb: condition: service_healthy grafana: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} GRAFANA_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: influxdb: url: http://influxdb:8181 bucket: github grafana: url: http://grafana:3000 user: admin password: $${GRAFANA_PASSWORD} publish_on_start: true state_file: /var/lib/ghchronicle/state.json volumes: influx: state: grafana: ``` ## InfluxDB ```yaml # ghchronicle, with InfluxDB. # # Put two lines in a .env file beside this one: # # GITHUB_TOKEN=github_pat_... # GITHUB_USER=your-login # # then `docker compose up -d`. Nothing is published to Grafana; # the store is yours to point one at. name: ghchronicle services: influxdb: image: influxdb:3-core # Without a credential, because a store that exists for the first time # when the collector first writes to it has nobody to have made one. command: - influxdb3 - serve - --node-id=node0 - --object-store=file - --data-dir=/var/lib/influxdb3 - --without-auth volumes: - influx:/var/lib/influxdb3 healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8181/health"] interval: 5s timeout: 3s retries: 30 ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: influxdb: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: influxdb: url: http://influxdb:8181 bucket: github state_file: /var/lib/ghchronicle/state.json volumes: influx: state: ``` ## PostgreSQL and Grafana ```yaml # ghchronicle, with PostgreSQL and Grafana. # # Put two lines in a .env file beside this one: # # GITHUB_TOKEN=github_pat_... # GITHUB_USER=your-login # # then `docker compose up -d`. Grafana is on http://localhost:3000, # admin and the password below, with the dashboard already in it: the # collector publishes it on start and points it at the store beside it. name: ghchronicle services: postgres: image: postgres:18.6-alpine environment: POSTGRES_USER: ghchronicle POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} POSTGRES_DB: ghchronicle volumes: # /var/lib/postgresql, not /var/lib/postgresql/data: the 18+ images # moved where they keep the cluster, and a volume on the old path is # refused outright with "in 18+, these Docker images are configured to # store database data in a subdirectory". - postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ghchronicle"] interval: 5s timeout: 3s retries: 30 grafana: image: grafana/grafana:12.3.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} ports: - "3000:3000" volumes: - grafana:/var/lib/grafana healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 30 ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: postgres: condition: service_healthy grafana: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} GRAFANA_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: postgres: dsn: postgres://ghchronicle:$${POSTGRES_PASSWORD}@postgres:5432/ghchronicle?sslmode=disable grafana: url: http://grafana:3000 user: admin password: $${GRAFANA_PASSWORD} publish_on_start: true datasource: sslmode: disable state_file: /var/lib/ghchronicle/state.json volumes: postgres: state: grafana: ``` ## PostgreSQL ```yaml # ghchronicle, with PostgreSQL. # # Put two lines in a .env file beside this one: # # GITHUB_TOKEN=github_pat_... # GITHUB_USER=your-login # # then `docker compose up -d`. Nothing is published to Grafana; # the store is yours to point one at. name: ghchronicle services: postgres: image: postgres:18.6-alpine environment: POSTGRES_USER: ghchronicle POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} POSTGRES_DB: ghchronicle volumes: # /var/lib/postgresql, not /var/lib/postgresql/data: the 18+ images # moved where they keep the cluster, and a volume on the old path is # refused outright with "in 18+, these Docker images are configured to # store database data in a subdirectory". - postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ghchronicle"] interval: 5s timeout: 3s retries: 30 ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: postgres: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: postgres: dsn: postgres://ghchronicle:$${POSTGRES_PASSWORD}@postgres:5432/ghchronicle?sslmode=disable state_file: /var/lib/ghchronicle/state.json volumes: postgres: state: ``` ## The collector on its own ```yaml # ghchronicle, on its own. # # Put two lines in a .env file beside this one: # # GITHUB_TOKEN=github_pat_... # GITHUB_USER=your-login # # then `docker compose up -d`. It writes what it collects to its own # log, which is enough to watch it work. Point sinks at a store of your # own when you have one. name: ghchronicle services: ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: stdout: true state_file: /var/lib/ghchronicle/state.json volumes: state: ``` ```sh docker compose up -d ``` El volumen `state` de la pila conserva el estado y la caché de un arranque al siguiente, y el colector puede escribir en él desde el primero: la imagen trae el directorio sobre el que se monta, propiedad del uid con el que corre el colector, y Docker le da ese dueño a un volumen nuevo. Ver [qué tiene que ser escribible](https://docs.jmrp.io/ghchronicle/es/install/docker/#qué-tiene-que-ser-escribible). La primera pasada corre todas las familias, porque aún no ha corrido ninguna, salvo que el colector arranca una sola familia de seis horas o más por pasada: `traffic`, `stats`, `forks` y el resto de las once llegan al almacén a lo largo de las dos primeras horas y media, y solo la primera vez. Ver [las familias lentas se turnan](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan). Con Grafana está en `http://localhost:3000`, usuario `admin` y contraseña `ghchronicle` salvo que pongas `GRAFANA_PASSWORD`. El dashboard ya está ahí: el colector lo publica al arrancar y lo apunta al almacén de al lado, así que no hay nada que importar ni ningún datasource que rellenar. El resto de esta página es la imagen en sí, para quien la ejecute de otra manera. ## Un contenedor, a mano ```sh docker run -d --name ghchronicle \ -v /etc/ghchronicle/config.yaml:/config.yaml:ro \ -v ghchronicle-state:/var/lib/ghchronicle \ -e GITHUB_TOKEN -e INFLUX_TOKEN \ -p 9605:9605 \ ghcr.io/jmrplens/ghchronicle -config /config.yaml ``` Con `state_file: /var/lib/ghchronicle/state.json` en la configuración, que es donde lo pone la configuración de ejemplo. El volumen es lo que conserva el estado y la caché de un contenedor al siguiente, y Docker lo crea al primer uso, propiedad del uid 65532 como el directorio sobre el que se monta; [qué tiene que ser escribible](https://docs.jmrp.io/ghchronicle/es/install/docker/#qué-tiene-que-ser-escribible) explica por qué hace falta cada cosa. ## 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. ## Verificar la imagen Toda imagen publicada desde la 2.5.1, que es toda imagen que nombra una página de release, va firmada con cosign, sin claves y con la misma identidad que el fichero de sumas que comprueban las demás instalaciones: el workflow de release de este repositorio, ejecutado para una etiqueta de release, anotado en un registro público de transparencia. Con [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) 3: ```sh cosign verify \ --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/jmrplens/ghchronicle:v2.6.5 > /dev/null ``` ```text Verification for ghcr.io/jmrplens/ghchronicle:v2.6.5 -- The following checks were performed on each of these signatures: - The cosign claims were validated - Existence of the claims in the transparency log was verified offline - The code-signing certificate was verified using trusted certificate authority certificates ``` La copia de Docker Hub, `docker.io/jmrplens/ghchronicle`, se verifica con la misma orden. Lo que cosign comprueba es un digest, no la etiqueta: sin `> /dev/null` imprime además, en JSON, el digest que verificó (`docker-manifest-digest`), y una imagen descargada como `ghcr.io/jmrplens/ghchronicle@sha256:...` es la verificada aunque una etiqueta se mueva después. `latest` se mueve al digest de una release lo último, cuando el workflow de release ya ha verificado la firma de ese digest y ha ejecutado la imagen en las dos arquitecturas, así que nunca nombra una imagen sin comprobar; una release que falla antes lo deja en la anterior. Usa cosign 3. Las firmas van en el formato de bundle que escribe, y un cosign más antiguo responde `no signatures found` para estas imágenes: medido, cosign 2.6.1 encuentra la firma solo si se le pasa `--new-bundle-format`, la 2.5.0 no la encuentra ni así, y la 2.4.2 no conoce la opción. Las imágenes hasta la 2.5.0 no llevan firma ninguna, así que para ellas esa respuesta es la verdadera. ## Qué tiene que ser escribible El fichero de configuración se monta de solo lectura. Seis cosas no: | Ruta | Necesaria para | | -------------------- | -------------------------------------------------------------------------------------- | | `state_file` | Siempre. Sin una ruta persistente el recorrido de estrellas se repite en cada reinicio | | `-cache.bin` | Siempre. La caché, que vive junto al fichero de estado y no tiene ajuste propio | | `sinks.dedupe_file` | El servicio permanente, con un destino que lo lleve. Una ejecución `-once` no abre ninguno | | `-lock` | El servicio permanente, que lo mantiene mientras corre, y `-migrate -yes` | | `sinks.file.path` | Solo con el destino de fichero | | `log.file` | Solo con un fichero de log configurado | Los tres primeros viven por omisión en el mismo directorio, y el candado también, así que un solo directorio montado los cubre, y tiene que ser un directorio. Cada uno de los tres se escribe a su lado y se renombra en su sitio, para que nadie lea nunca medio fichero, y sobre un fichero montado solo no se puede renombrar: medido con la imagen 2.6.1 construida desde su Dockerfile, un fichero de estado montado solo no se guardó nunca, y cada pasada lo dijo. ```text level=WARN msg="state not saved" err="rename /var/lib/ghchronicle/state.json.tmp /var/lib/ghchronicle/state.json: device or resource busy" ``` Un directorio en el que el colector no puede escribir da el mismo aviso, con el error que toque, y otro por la caché de al lado: ```text level=WARN msg="state not saved" err="open /var/lib/ghchronicle/state.json.tmp: permission denied" level=WARN msg="cache file not saved" file=/var/lib/ghchronicle/state-cache.bin err="open /var/lib/ghchronicle/state-cache.bin.tmp: permission denied" ``` Desde la 2.6.1 la imagen trae `/var/lib/ghchronicle` como propiedad del uid 65532, y Docker llena un volumen con nombre nuevo desde el directorio sobre el que se monta, dueño incluido, así que el directorio que nombra la configuración de ejemplo no necesita que se le haga nada: medido con un volumen recién creado, y con uno vacío que la imagen 2.6.0 había dejado a root, el colector escribió su estado y su caché en los dos sin ningún aviso. Sin nada montado ahí también escribe, y el estado se va con el contenedor. Hasta la 2.6.0 la imagen no tenía ese directorio: sin nada montado ahí quedaba uno que el uid 65532 no podía crear, y un volumen con nombre nuevo ahí era de root, que es de donde salieron las dos líneas de arriba. Quedan dos maneras de acabar con un directorio en el que el colector no puede escribir. Un volumen con nombre montado donde la imagen no tiene un directorio propio se crea como propiedad de root: entrégaselo una vez al uid 65532, con cualquier imagen que traiga un `chown`, como en `docker run --rm -v :/v alpine chown 65532:65532 /v`. Un directorio del anfitrión se monta tal cual, sea de quien sea: hazle `sudo chown 65532:65532` en el anfitrión. Una configuración que no nombra ningún `state_file` escribe en el directorio de trabajo del contenedor, que es escribible y se va con el contenedor. Sin el estado, cada contenedor nuevo empieza de cero: el recorrido de estrellas, el historial entero de estrellas y el recorrido de coautorías otra vez, y todas las familias a la vez. Sin la caché, una pasada entera volviendo a preguntar a GitHub lo que ya sabía: [la caché que hay a su lado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). Sin el registro, una pasada entera de reescritura, que es justo lo que existe para evitar: [solo se escribe lo que ha cambiado](https://docs.jmrp.io/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). ## 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`. ## Tras una actualización Descarga la nueva imagen y vuelve a crear el contenedor. Con el valor por omisión, [`migrate: auto`](https://docs.jmrp.io/ghchronicle/es/configuration/#migrate), su arranque aplica por su cuenta, antes de su primera pasada, cada cambio de la nueva versión que no pierde nada, y avisa del resto en cada arranque con las dos órdenes que lo aplican: mira [Migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones). El contenedor acepta las mismas opciones que el binario, así que esas órdenes corren en un contenedor de un solo uso del mismo servicio, con su volumen, su entorno y su usuario, mientras el servicio está parado: ```sh docker compose pull ghchronicle docker compose up -d ghchronicle docker compose logs ghchronicle | grep 'migration pending' docker compose stop ghchronicle docker compose run --rm ghchronicle -config /config.yaml -migrate docker compose run --rm ghchronicle -config /config.yaml -migrate -yes docker compose start ghchronicle ``` El primer `run` imprime el plan y no cambia nada; el segundo lo aplica y vuelve a leer lo que despejó. Los dos montan el volumen que monta el servicio, así que encuentran el candado que este mantiene junto al fichero de estado: con el servicio aún en marcha, `-migrate -yes` se niega, nombra el proceso con el número que le da el contenedor del propio servicio, y no cambia nada. Medido con dos contenedores sobre un mismo volumen con nombre: un `flock` que tenía uno se le negó al otro, y se le concedió en cuanto el primero se fue. Un contenedor de una sola pasada que lanza un planificador, más abajo, no tiene ningún bloqueo mientras hace su pasada, así que pausa el planificador mientras duran las dos ejecuciones. ## 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, y lo que mantiene cada familia en su cadencia: sin él cada ejecución recoge todas las familias. --- # GitHub Actions ghchronicle como GitHub Action: la Action compuesta, sus cuatro modos y las dos cosas que un runner alojado no conserva. Source: https://docs.jmrp.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@v2 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`, cuyo estado dura una ejecución y no se puede guardar en caché | | `user` | el dueño del repositorio | La cuenta que recoger cuando no se da fichero de configuración | | `mode` | `once` | `once`, `backfill`, `card` o `migrate` | | `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](https://docs.jmrp.io/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. Ver el aviso más abajo | | `version` | `latest` | La release que instalar: `latest` para la más nueva, o una release con o sin su `v`, así que `2.6.5` y `v2.6.5` son la misma. La etiqueta mayor `v2` es lo que toma `uses:`, no una release, y se rechaza | ## Los cuatro 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@v2 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@v2 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@v2 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. - **migrate** Pone al día los almacenes en los que escribe la configuración después de una actualización: ejecuta `-migrate -yes`, que aplica cada cambio que [el plan](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) encuentra pendiente, también los que necesitan la palabra de alguien, y vuelve a leer lo que despejó. Ejecútalo a mano, una vez, después de leer lo que dice `-migrate` sobre la misma configuración. ```yaml name: Migrate on: workflow_dispatch: jobs: migrate: runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@v7 - uses: jmrplens/ghchronicle@v2 with: token: ${{ secrets.GHCHRONICLE_TOKEN }} mode: migrate config: .github/ghchronicle.yaml ``` No admite tarjeta. Un almacén que guarda filas de cuentas que esta configuración no recoge, o cuyas filas no se pudieron comparar con ella, se deja sin aplicar y el paso falla, porque solo `-migrate-others` lo despeja, y esa es una decisión para tomar en una terminal, no en un workflow. Sin un fichero de estado [restaurado entre ejecuciones](https://docs.jmrp.io/ghchronicle/es/install/actions/#dos-cosas-que-un-runner-alojado-no-conserva), cada ejecución de la Action empieza con uno nuevo, así que un almacén al que no se le puede preguntar, un fichero SQL, un Graphite o lo que haya detrás de un Telegraf, no tiene historia aquí y nunca se da por necesitado de un cambio; con uno restaurado, esos siguen su registro como en una máquina. Un job que agotó su tiempo mientras volvía a leer la historia no deja nada que el siguiente pueda retomar, porque `actions/cache` no guarda nada de un job que no terminó bien: ejecuta después `mode: backfill` con la misma configuración. En `once` y `backfill`, un arranque comprueba los almacenes igual y avisa de lo pendiente. Sobre un fichero de estado nuevo, que es cada ejecución sin uno restaurado, no aplica nada por su cuenta ni siquiera con `migrate: auto`: el fichero de estado se va con el runner, y con él el registro de que aún se debe una relectura, así que una relectura que falló o un job cancelado a medias dejaría un almacén despejado y nada que lo dijera. Con un fichero de estado restaurado aplica lo que es seguro aplicar sin supervisión, como una máquina. La Action convierte cada línea que dice que un cambio está pendiente, que se aplicó uno al arrancar, que uno falló o que se debe una relectura en una anotación de la ejecución en cuanto se escribe la línea, así que se ve en la página de resumen del workflow y no solo en su registro, también en un job cancelado a medias. ## 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](https://docs.jmrp.io/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@v2 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@v2` 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](https://docs.jmrp.io/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. 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, > en un token fine-grained, el permiso de repositorio Administration (lectura); > el `GITHUB_TOKEN` automático no puede recibir Administration, así que recibe > un 403 incluso en 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](https://docs.jmrp.io/ghchronicle/es/start/token/). **El fichero de estado no sobrevive entre ejecuciones.** Sin él cada ejecución es una primera ejecución. Corren todas las familias, diga lo que diga su cadencia, porque nada recuerda cuándo corrieron por última vez. Se vuelve a recorrer entera la lista de estrellas, y se lee entero el historial diario de estrellas de cada repositorio, una página por cada treinta semanas de su vida. `achievements` recorre enteras las pull requests fusionadas de la cuenta para su recuento de coautorías, que fueron 35 consultas y 23,7 MB en una cuenta con 2.315. Y sin [fichero de caché](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado) junto al estado, nada se pregunta con un ETag, así que ninguna respuesta es un 304 gratis. En una cuenta pequeña son un puñado de llamadas; en una cuenta con muchas estrellas, repositorios antiguos o una historia larga, guárdalo en caché. Guardarlo en caché necesita un fichero `config:`. Sin él la Action escribe una configuración propia y pone el estado en un directorio temporal nuevo dentro de `RUNNER_TEMP` en cada ejecución, que ningún paso de caché puede nombrar. Con él, apunta `state_file` al directorio personal: ```yaml state_file: ~/.ghchronicle/state.json ``` y restaura y guarda ese directorio con un paso anterior al de ghchronicle: ```yaml - uses: actions/cache@v6 with: path: ~/.ghchronicle key: ghchronicle-state-${{ github.run_id }} restore-keys: ghchronicle-state- ``` Una `~` en `state_file` es el directorio personal a partir de la 2.6.1. Una versión anterior la lee tal como está escrita, un directorio llamado `~` dentro del checkout que el paso de caché nunca ve, así que con un `version` anterior a la 2.6.1 escribe la ruta entera: `/home/runner/.ghchronicle/state.json` en un runner Ubuntu alojado por GitHub. El directorio guarda entonces también el fichero de caché, que un paso `once` escribe y un paso `backfill` solo lee, así que una ejecución restaurada de él pregunta además a GitHub con los validadores que guardó la anterior, y solo paga lo que cambió. Una pasada en modo `card` lee el estado restaurado, que es lo que le permite saltarse los dos recorridos, y nunca lo vuelve a escribir, ni tampoco el fichero de caché que hay a su lado: 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 entrada de `actions/cache` es un paso `once`, con los dos ficheros, o un paso `backfill`, solo con el fichero de estado. Ningún paso deja ahí un registro de escrituras: una ejecución que termina con su pasada no abre ninguno. ## 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/v2/cmd/ghchronicle@latest - run: ghchronicle -config .github/ghchronicle.yaml -card profile.svg -card-only env: GITHUB_TOKEN: ${{ secrets.GHCHRONICLE_TOKEN }} ``` --- # Actualizar Cómo son las primeras horas tras actualizar de la 2.5.x a la 2.6.x, cada cosa una sola vez y a propósito, y qué cambia la 2.6.1 en un almacén escrito antes que ella. Source: https://docs.jmrp.io/ghchronicle/es/install/upgrading/ Instala la versión nueva como se instaló la anterior, encima de ella, y vuelve a arrancar el colector. La configuración, el fichero de estado y el registro de escrituras pasan tal cual, y no hay nada que migrar a mano. Lo que sigue es cómo son las primeras horas tras la actualización, para que nada de ello parezca una avería, y lo poco que merece la pena hacer al respecto. Los dashboards se generan desde el binario, así que vuelve a publicarlos tras una actualización, con `-publish-dashboard` o `grafana.publish_on_start`: ver [dejárselo al binario](https://docs.jmrp.io/ghchronicle/es/dashboards/#dejárselo-al-binario). ## Migraciones A veces una versión cambia la clave de una fila guardada. Lo habitual es una etiqueta que pasa a ser campo: cada fila escrita antes del cambio es una serie distinta de las escritas después, así que el almacén guarda las dos para siempre, y un recuento sobre ellas crece en uno por cada elemento leído a los dos lados del cambio. El binario lleva la lista de todos los cambios así que todavía puede guardar un almacén escrito por una versión 2.x, cada uno con un ID que nombra la versión y la medida, como `2.6.1/gh_discussion_comment/is_answer`, y `-migrate` contrasta con ella cada almacén configurado: ```sh ghchronicle -config /etc/ghchronicle/config.yaml -migrate ``` Imprime un bloque por almacén y una línea por cambio, y no cambia nada: a los almacenes se les hacen preguntas, a GitHub solo se le pide la lista de repositorios, y el fichero de estado se lee y nunca se escribe. Un almacén al que se le puede preguntar decide por sí mismo; uno al que no, lo decide lo que el fichero de estado recuerda de él. | Almacén | Qué decide | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | InfluxDB 3 | Si la etiqueta antigua es una columna de etiqueta de la tabla viva, según el catálogo. Una tabla que InfluxDB ha apartado aparece con otro nombre y no se pregunta | | InfluxDB 2 | Si alguna fila lleva la etiqueta antigua. Las claves de etiqueta siguen listadas tras un borrado, así que no se preguntan | | PostgreSQL | Si alguna fila tiene un valor en la columna de la etiqueta antigua, en el esquema donde escribe el destino | | Elasticsearch | Un recuento de los documentos que llevan la etiqueta antigua. El mapping conserva un campo después de irse sus documentos | | Fichero SQL, Graphite, Telegraf | La versión que el fichero de estado registra como la primera que escribió en el almacén | | Loki, Prometheus, OTLP, file, stdout | Nada: ninguno guarda una fila cuya identidad pueda cambiar una versión | Cada línea empieza por lo que encontró la comprobación: - `not needed`: el almacén no guarda nada de la forma antigua. - `pending`: guarda la forma antigua o, en un almacén al que no se le puede preguntar, puede guardarla. Las líneas de debajo dicen por qué, qué haría allí ponerlo al día, qué familias volverían a leer la medida y desde cuándo, y lo que no volvería: las filas de repositorios que la configuración ya no cubre y las de familias que tiene apagadas. En los comentarios, esas son los comentarios de otros en un repositorio así: los de la propia cuenta vuelven a través de `outbound` estén donde estén. La última línea dice si aplicarlo no necesita la palabra de nadie: GitHub todavía sirve toda la historia, así que volvería cada fila que guarda el almacén, las filas antiguas se apartarían al menos 24 horas en vez de borrarse, y todas las filas del almacén son de esta configuración. Un almacén compartido con otro colector, uno que guarda filas que la relectura no traería de vuelta, uno cuyas filas no se pudieron comparar con la configuración, un InfluxDB 2, cuya única vía es un borrado, y todo almacén al que no se le puede preguntar necesitan la palabra de alguien, y la línea dice qué razón aplica. Una fila que no nombra ninguna cuenta es de una configuración solo de organizaciones, que no escribe usuario: es de esta configuración solo cuando esta tampoco tiene `targets.user`. - `note`: las filas están ahí y nada puede arreglarlas, así que el plan dice qué significan y no cambia nada. - `frozen`: nada de lo que ejecuta esta configuración escribe ya la medida, así que sus filas son historia y se dejan como están. - `applied`: el fichero de estado registra el cambio como aplicado a este almacén, y el almacén, si se le puede preguntar, está de acuerdo. - `unreachable`: el almacén no respondió, así que no se sabe nada de él. ### Lo que hace un arranque Toda ejecución que escribe en los almacenes, el servicio, `-once`, `-backfill` y una tarjeta dibujada junto a los almacenes, los comprueba igual antes de su primera pasada, y lo que hace después lo decide el ajuste [`migrate`](https://docs.jmrp.io/ghchronicle/es/configuration/#migrate): - `auto`, el valor por omisión, aplica por su cuenta cada cambio pendiente marcado como seguro de aplicar sin supervisión, y nada más. Primero lo dice, en `WARN`, con lo que encontró, por qué existe el cambio y qué hace en ese almacén, incluido dónde se apartan las filas antiguas; después vuelve a leer la medida de GitHub, y solo entonces hace la pasada. - `warn` no aplica nada. Una ejecución de una sola vez, `-once` o `-backfill`, sobre un fichero de estado nuevo tampoco aplica nada por su cuenta bajo `auto`, y avisa en su lugar. Esa es cada ejecución de la Action que no restaura su fichero de estado con `actions/cache`, cuyo fichero de estado se va con su runner, y con él el registro de que aún se debe una relectura: una relectura que falló, o un job cancelado a medias, dejaría el almacén despejado y nada en ninguna parte que lo dijera. La siguiente ejecución en una máquina, que para entonces tiene fichero de estado, o `-migrate -yes`, lo aplica. Un cambio pendiente que un arranque no aplica es un `WARN` en cada arranque, con el almacén, la razón por la que se dejó y las dos órdenes, copiadas de la configuración que recibió la ejecución. Una línea en el registro, partida aquí: ```text level=WARN msg="migration pending" sink=graphite measurement=gh_discussion_comment migration=2.6.1/gh_discussion_comment/is_answer why="is_answer was a tag, ..." not_applied="only whoever runs the Graphite host can remove its files" plan="ghchronicle -config /etc/ghchronicle/config.yaml -migrate" apply="ghchronicle -config /etc/ghchronicle/config.yaml -migrate -yes" first="stop this service: -migrate -yes refuses to run beside it" ``` No hay ajuste que aplique el resto por su cuenta: un cambio que podría perder filas espera a que alguien lea el plan y dé su palabra. Una nota se dice una vez en `INFO` y después en `DEBUG`, porque nada la cambiará nunca, y lo mismo un cambio cuya medida ya no escribe nada de lo que ejecuta la configuración. Un almacén que no responde en 30 segundos también es un `WARN`, y la pasada sigue sin esperar más. Lo que un arranque encuentra innecesario, y lo que ha aplicado, lo registra, y el siguiente arranque se fía del registro: una vez resuelto cada cambio de un almacén, un arranque no le pregunta nada a ese almacén. `-migrate` no se fía de ese registro y vuelve a preguntar cada vez. ### Aplicarlo a mano ```sh systemctl stop ghchronicle ghchronicle -config /etc/ghchronicle/config.yaml -migrate -yes systemctl start ghchronicle ``` Ejecútalo con el usuario con que corre el servicio y con el entorno que tiene el servicio: guarda el fichero de estado y los que hay a su lado, y un fichero que guarda root es uno que el servicio ya no puede leer. Una ejecución que encuentra el fichero de estado y no puede leerlo, o no puede analizarlo, se detiene y lo nombra en vez de empezar con uno nuevo, que olvidaría una relectura aún debida: devuélvele el fichero al usuario del servicio. [systemd](https://docs.jmrp.io/ghchronicle/es/install/systemd/#tras-una-actualización) y [Docker](https://docs.jmrp.io/ghchronicle/es/install/docker/#tras-una-actualización) muestran cómo. Pausa también un cron o un temporizador que ejecute `-once`, y deja terminar antes un `-backfill` que esté en marcha. Ninguno tiene el bloqueo mientras hace su pasada, y un fichero SQL que escriben dos procesos a la vez no es uno que psql pueda reproducir. Su fichero de estado está a salvo en cualquier caso: una ejecución que lo guarda conserva lo que otro proceso registró de los almacenes desde que ella lo leyó, así que un `-once` que corrió a la vez que `-migrate -yes` no devuelve el registro que había leído antes. `-migrate -yes` imprime el mismo plan y después aplica cada cambio pendiente, también los que no son seguros, porque `-yes` es la palabra que pedía el plan. Cada uno se registra en el fichero de estado al aplicarse, así que parar a medias no cuesta nada que la siguiente ejecución no pueda retomar: lo aplicado no se hace dos veces. Una línea bajo el plan dice qué pasó con cada uno: - `applied`: hecho. Donde se guardan las filas antiguas, las nombra; donde tiene que actuar otro, en un servidor de Graphite o detrás de un Telegraf, imprime las órdenes que hay que ejecutar allí. - `failed`: el almacén se negó, con su razón. Los demás siguen adelante. - `held back`: el almacén guarda filas de cuentas que esta configuración no recoge, o de quién son sus filas no se pudo comparar con esta configuración, y la línea dice cuál y por qué. Apartadas, las filas de otro solo vuelven cuando quien las recoge las lee de nuevo, así que esto lleva `-migrate-others` además de `-yes`. - `unreachable`: el almacén no respondió, así que allí no se hizo nada. - `refill`: qué se volvió a leer de GitHub, desde cuándo y en qué almacenes, o por qué la relectura no terminó: ver [volver a leer el historial](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#volver-a-leer-el-historial). - `reconciled`: cada copia de las filas antiguas comparada con lo que volvió, y los elementos que GitHub ya no sirve. Sale con 0 cuando todo lo pendiente se aplicó y se volvió a leer, y con 1 cuando quedó algo, con la frase que dice cómo seguir. Se niega antes de cambiar nada, y sale con 1, sin token de GitHub o sin lista de repositorios, porque nada de lo que despeja podría volver a leerse, y mientras otro proceso tiene el fichero de estado: el servicio lo tiene mientras corre, junto al fichero de estado como `-lock`, y `-migrate -yes` nombra ese proceso en vez de cambiar los almacenes en los que escribe. Un servicio arrancado mientras corre `-migrate -yes` espera a que termine, y un segundo servicio sobre el mismo fichero de estado se rechaza. En la GitHub Action, `mode: migrate` ejecuta `-migrate -yes`: ver [las entradas](https://docs.jmrp.io/ghchronicle/es/install/actions/#entradas). ### Qué hace aplicarlo en cada almacén | Almacén | Qué hace aplicarlo | | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | [InfluxDB 3](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#qué-hace-aquí-una-migración) | Borra esa tabla, que InfluxDB guarda como `-`, consultable, y purga él mismo 72 horas después, o nunca antes de la 3.2 | | InfluxDB 2 | Borra todas las filas de la medida en el bucket. No se guarda nada, así que un arranque nunca lo hace por su cuenta | | [PostgreSQL](https://docs.jmrp.io/ghchronicle/es/sinks/postgres/#qué-hace-aquí-una-migración) | Renombra la tabla `-` en el esquema del destino; ghchronicle la borra 24 horas después | | [Elasticsearch](https://docs.jmrp.io/ghchronicle/es/sinks/elasticsearch/#qué-hace-aquí-una-migración) | Bloquea las escrituras del índice, lo clona a `<índice>-` y lo borra; ghchronicle borra el clon 24 horas después | | Fichero SQL | Escribe `DROP TABLE IF EXISTS` de la medida en el fichero, antes de las filas escritas después | | Graphite | Imprime las órdenes que quitan las rutas antiguas en la máquina de Graphite | | Telegraf | Dice qué hacer en el almacén que tiene detrás | Cada almacén toca esa medida y nada más: la tabla, el índice o las rutas nombrados exactamente, en la base de datos, el bucket, el esquema o el prefijo en el que escribe el destino. Una medida que se le parece, las tablas de otro colector y los índices de otro prefijo no se alcanzan nunca. Un almacén al que se le puede preguntar solo se cambia después de preguntarle y encontrar que guarda la forma antigua, y una prueba en seco no le envía más que preguntas. Donde un almacén guarda aparte las filas antiguas, el plan nombra cada copia bajo el almacén, con una línea `kept aside` que dice cuándo se apartó y cuándo se va, y el fichero de estado la guarda hasta entonces. ghchronicle purga sus propias copias cuando llevan 24 horas guardadas: el servicio tras una pasada, cualquier ejecución en su siguiente arranque, y `-migrate -yes` cada vez que se ejecuta. Una ejecución sobre un fichero de estado nuevo, entre ellas cada ejecución de la Action que no restaura uno, pregunta a PostgreSQL y a Elasticsearch por las copias con el nombre que les da una migración, porque su fichero de estado no puede nombrarlas. InfluxDB 3 purga las suyas con un calendario propio, que se lee de la tabla de sistema de su base de datos `_internal`: 72 horas después del borrado por omisión (medido de la 3.2.1 a la 3.11.5), y guarda el nombre en su catálogo durante su periodo de gracia de borrado después de eso, 24 horas por omisión. Un servidor anterior a la 3.2 no purga ninguna: el plan lo dice antes de aplicar, y `-migrate` pregunta al servidor por cada copia que guarda así, con la petición que la quita en cuanto el servidor ejecute una versión de la 3.2 a la 3.9 (ver [antes de la 3.2 la copia se queda](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#antes-de-la-32-la-copia-se-queda)). Hasta que una copia se va, cómo deshacer el cambio está en la página de cada almacén. Un almacén despejado se vuelve a escribir entero. El [registro de escrituras](https://docs.jmrp.io/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado) olvida la medida solo en ese almacén, y cada otra medida y cada otro almacén conservan lo que recuerdan, y el [fichero de caché](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado) olvida lo que afirma de las familias que escriben la medida, sus rechazos y, cuando `actions` está entre ellas, las ejecuciones de workflows cuyos jobs había escrito, así que las pasadas siguientes preguntan todo lo que pregunta una primera pasada. ### Volver a leer el historial Un almacén despejado no guarda nada del historial de la medida hasta que se vuelve a leer de GitHub. Justo después de aplicar, la misma ejecución lo lee de nuevo con un relleno histórico propio, la relectura: las familias que escriben la medida, `discussions` y `outbound` para los comentarios, `security` para los elementos de alerta, y ninguna otra; escribiendo solo esa medida; en los almacenes que se despejaron y en ningún otro. ```text refill read discussions and outbound again, since 2023-11-14, writing gh_discussion_comment to elasticsearch, influxdb and postgres ``` - **Solo lo que se despejó.** Una familia pregunta a GitHub todo lo que pregunta siempre, y solo se escribe la medida despejada; el resto de lo que recoge la familia ya está en los almacenes. Cada almacén recibe lo que se despejó en ese almacén, así que a un almacén que aún guarda una medida con su forma antigua nunca se le entrega el historial de esa medida al lado. - **Solo donde se despejó.** InfluxDB, PostgreSQL, Elasticsearch, el fichero SQL, después de su `DROP`, y Graphite, a la profundidad nueva. Nunca Loki, el exportador de Prometheus, OTLP, el destino de fichero ni stdout, que no se despejaron y guardarían cada fila de la relectura dos veces, ni un Telegraf, cuyo almacén es de otro: el plan nombra el `-backfill -families` que envía el historial a través de él cuando ese almacén se haya despejado. - **Tan atrás como llegaba el almacén.** El día de la fila más antigua que guardaba el almacén, leída antes de despejarlo: un límite posterior perdería la diferencia para siempre cuando se purgue la copia. Un almacén que no puede decir hasta dónde llegan sus filas, el fichero SQL, Graphite o un InfluxDB 3 Core que no quiso contarlas (abajo), se relee sin límite: lo que se lleva aplicarlo allí es cada fila sea cual sea su fecha, y `backfill.since` acota lo que alcanza un relleno histórico, no lo que guarda un almacén. Una relectura para varios almacenes llega tan atrás como el que más. - **Esperando, como espera un relleno histórico.** Tiene un cliente de GitHub propio que espera a que se renueve un límite de peticiones gastado, donde una pasada lo salta, y `-backfill-retry` vuelve a por lo que deja igual que en un relleno histórico. El `-migrate -yes` entero de la batería con contenedores, que despeja InfluxDB 3.11.2, PostgreSQL 18.6 y Elasticsearch 9.5.3 y vuelve a leer los comentarios del GitHub falso, tarda unos 2 segundos; en la cuenta del autor, el recorrido de `outbound` que necesita tardó 33 segundos cuando el de la 2.6.1 se hizo a mano. La relectura se debe antes de tocar un almacén, y el fichero de estado lo registra entonces, bajo la clave `refill` del almacén: un almacén despejado y no releído parece, a quien le pregunte, exactamente uno que nunca guardó la forma antigua, y solo ese registro dice que el historial aún está por llegar. Un despeje que falló y dejó el almacén como estaba, lo que dice el almacén, retira la deuda; uno cuya respuesta no llegó, un 502 de un proxy o un tiempo agotado, se vuelve a mirar, y cuando el almacén no puede decir si se llevó a cabo la relectura sigue debiéndose, lo que como mucho lee el historial una vez para nada. Se va cuando la relectura llega al final de todas las familias. Una relectura cortada, por una parada, un almacén que rechazó una escritura o un GitHub que no respondió, guarda un punto de control propio junto al fichero de estado, con `-refill.json` en el nombre, aparte del de un relleno histórico para que ninguno rechace ni pise al otro, y se retoma desde ahí: con `-migrate -yes` otra vez, aunque no quede nada por aplicar, y con cualquier arranque bajo `migrate: auto`. Bajo `migrate: warn` un arranque dice que se debe, en cada arranque, con la orden. `-backfill-status` imprime hasta dónde ha llegado, y `-migrate` la lista bajo su almacén como `refill owed`. #### Qué hace el servicio mientras tanto Bajo `migrate: auto` el servicio aplica lo seguro y lo vuelve a leer después de construir sus destinos y antes de su primera pasada, así que sus pasadas empiezan cuando termina la relectura. El exportador de Prometheus está levantado durante ese tiempo y no guarda nada hasta la primera pasada, como tras cualquier reinicio. Un servicio parado durante la relectura sale en el acto, conservando lo que escribió, y su siguiente arranque continúa. El registro de escrituras recuerda lo que escribió la relectura, así que la primera pasada tras ella no vuelve a enviar esas filas. Bajo `migrate: warn` no se relee nada y el servicio hace sus pasadas como siempre, escribiendo la forma de esta versión junto a la antigua, y `-migrate -yes` se niega a correr a su lado. #### Lo que GitHub ya no sirve Una relectura trae de vuelta lo que GitHub aún sirve para los objetivos configurados ahora. Cuando termina, cada almacén que guardó una copia de las filas antiguas y al que se le puede preguntar, InfluxDB 3, PostgreSQL y Elasticsearch, compara la copia con la tabla, elemento a elemento: el `comment` de cada comentario, el `full_name` y el `number` de cada alerta. Lo que guarda la copia y no la tabla es lo que GitHub ya no sirvió, un repositorio borrado o que ya no se cubre, un comentario borrado, una alerta cuya función se apagó; el informe y el registro nombran unos pocos, y esas filas quedan solo en la copia hasta que se purga. De la batería con contenedores, cuyo comentario sembrado no sirve el GitHub falso: ```text reconciled gh_discussion_comment in postgres: 1 item in gh_discussion_comment-20260928T234418, 6 now; 1 GitHub no longer serves, whose rows are only in the copy until it is purged: 1 ``` Traspasarlas queda en manos del lector, desde la copia, mientras está: para un elemento cuya forma antigua eran dos filas en un mismo instante, cuál de las dos era la buena no es algo que un programa pueda saber sin conexión. Medido en solo lectura contra el InfluxDB 3.11.5 de producción, la copia que guardó InfluxDB de la tabla borrada a mano para la 2.6.1 tenía 114 comentarios, y la tabla releída 121, sin que faltara ninguno de los 114. ### El límite de ficheros por consulta de InfluxDB 3 Core InfluxDB 3 Core rechaza una consulta que abriría más ficheros Parquet que su `--query-file-limit`, 432 por omisión, y una tabla que una pasada escribe cada diez minutos lo supera en días. La comprobación lee el catálogo, que no abre ningún fichero, así que sigue encontrando la forma antigua en una tabla así, y una tabla sin etiqueta antigua no se cuenta en absoluto. Lo que no puede hacer allí es contar las filas, lo que deja la relectura sin límite, ni leer de quién son las filas de la tabla, lo que deja el cambio necesitando tu palabra y a `-migrate -yes` reteniéndolo a la espera de `-migrate-others`; el plan cita el rechazo del servidor. Medido en 3.11.2 con el límite bajado a 3: antes de esto, la misma tabla era `unreachable` en cada arranque y `-migrate -yes` no podía aplicarlo en absoluto. Subir el límite en el servidor, mientras dura la migración, le deja responder a todo. ### Lo que el fichero de estado recuerda de cada almacén La clave `stores` del [fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#state_file) guarda, para cada almacén en el que escribe una ejecución, adónde apunta, la versión que escribió en él primero y la que lo hizo por última vez. Un primer arranque, sobre un fichero de estado sin historia, registra la versión en marcha como la primera que escribió en cada almacén, así que una instalación nueva no tiene nada que migrar. El primer arranque tras actualizar desde la 2.6.1 o anterior registra la primera versión como desconocida, lo que para un fichero SQL, un Graphite o un Telegraf se lee como anterior a cualquier cambio, así que esos muestran cada cambio de una versión 2.x como pendiente, o como nota donde nada puede arreglarlo, hasta que se aplique. Un destino apuntado a otro almacén empieza un registro nuevo, y el que deja se conserva mientras aún deba allí una relectura o nombre una copia, se dice en cada arranque y en `-migrate` como `owed there`, y se recupera si el destino vuelve a apuntar allí. Una URL escrita de otra forma, con una barra al final, una mayúscula o el puerto por omisión, es el mismo almacén. Un fichero de estado borrado tras una actualización se lleva el registro consigo, y los almacenes a los que no se puede preguntar se toman entonces por de la versión en marcha; también se lleva una relectura aún debida, y nada vuelve a leer ese historial hasta que lo haga un `-backfill -families` de las familias que nombra. Un cambio hecho antes de la 1.0.0, las etiquetas `state` y `reason` de los elementos de alerta, no lo escribió nunca una versión publicada, así que solo puede mostrarlo un almacén al que se le puede preguntar. ## De la 2.5.x a la 2.6.x ### El primer arranque paga todo, una vez La 2.6.0 guarda lo que una pasada aprendió de GitHub en [un fichero de caché junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), y la 2.5.x no escribía ninguno. Así que el primer arranque no encuentra nada que leer, lo dice en `debug` como `no cache file yet, the first pass of each family pays in full`, y lo pregunta todo una vez sin ETag. Es el precio que pagaba cada reinicio de la 2.5.x: medido en el servicio del autor el 2026-09-26, los primeros 38 minutos tras un reinicio gastaron 1.092 peticiones `core` cobradas en pasadas que costaban unas 66 con la caché caliente. Desde el segundo arranque el fichero está ahí, y el log dice `cache file read`. ### `achievements` recorre entero el historial de fusiones, una vez El recuento de pull requests en coautoría que hay detrás de Pair Extraordinaire se guarda en el fichero de estado desde la 2.6.0, y un fichero de estado de la 2.5.x no tiene ninguno. La primera pasada de `achievements` recorre por tanto todas las pull requests que la cuenta ha fusionado: 35 consultas y 23,7 MB sobre 2.315, medido el 2026-09-27. A partir de ahí una pasada recorre los días desde el recuento que guarda, y el historial entero otra vez una vez por semana. ### `totals` corre primero La consulta de pull requests se dimensiona por repositorio a partir de los recuentos que lee `totals`, y la 2.6.0 guarda esos tamaños en el fichero de caché. La primera pasada no tiene ninguno, así que corre `totals` antes que las pull requests diga lo que diga su cadencia. Con su cadencia de una hora, `totals` suele tocar de todos modos después de actualizar, y entonces simplemente corre primero; solo cuando no toca, y la pasada no es la de cebado, que corre todas las familias, el log dice por qué corre: ```text level=INFO msg="no page sizes remembered, running totals before the pull requests it sizes" ``` ### Las familias lentas se reparten Un fichero de estado de la 2.5.x tiene las familias diarias de la cuenta marcadas en un mismo instante y las de doce horas en otro, porque es cuando corrieron, juntas. La 2.6.0 arranca como mucho una familia de seis horas o más por pasada, así que el primer día tras la actualización las trae una por tic: a lo largo de hasta dos horas y media con las cadencias internas, y más con `deps`, `history` o `joblogs` encendidas. El log nombra quién arranca y quién espera, bajo `slow families due together take turns`, y desde entonces cada una lleva su propio horario. Ver [las familias lentas se turnan](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan). ### `commented_elsewhere` baja La búsqueda que hay detrás de `gh_account_total.commented_elsewhere` contaba también los hilos que la cuenta comentó en sus propios repositorios. Desde la 2.5.2, cuyos cambios salieron en la 2.6.0, los deja fuera, como ya hacían sus dos hermanos, y el campo conserva su nombre. Así que la serie baja en todos los almacenes por la diferencia en la primera pasada de `totals` tras la actualización, de 125 a 55 en la cuenta en la que se midió. Es una corrección, no una pérdida. ### Las tablas de PostgreSQL ganan columnas La 2.6.0 añade campos a medidas cuyas tablas creó una versión anterior, y una tabla creada por una versión anterior no tiene columna para ellos. El destino PostgreSQL que conecta lee las columnas de cada tabla la primera vez que su proceso se encuentra con ella y añade las que faltan con `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. El destino de fichero SQL no puede preguntar, así que su fichero lleva esa sentencia para cada campo, y reproducirlo en una base de datos que llenó una versión anterior hace que psql imprima un aviso por cada tabla y cada columna que ya existen. Son de esperar: ```text NOTICE: relation "gh_repo" already exists, skipping NOTICE: column "stars" of relation "gh_repo" already exists, skipping ``` Ver [cómo llegan las declaraciones](https://docs.jmrp.io/ghchronicle/es/sinks/postgres/#cómo-llegan-las-declaraciones). ### La columna Stars de Work elsewhere espera a `outbound` La tabla "Work elsewhere" une `gh_upstream_repo`, una medida que añade la 2.6.0, para las estrellas de cada repositorio. Hasta que la primera pasada de `outbound` de la versión nueva la escribe, que es dentro de la hora, InfluxDB y PostgreSQL rechazan la tabla entera en vez de dejar vacía la columna, y un rango que termina antes de la actualización no tiene estrellas que mostrar en ningún almacén. Espera a esa pasada, o publica los dashboards después de ella; ver [los datos parecen mal](https://docs.jmrp.io/ghchronicle/es/reference/troubleshooting/#los-datos-parecen-mal). ### Una configuración copiada de un ejemplo antiguo conserva las cadencias viejas Hasta la 2.6.0 la configuración de ejemplo fijaba cada familia bajo `every.families` al valor interno de su versión, así que un `config.yaml` copiado de ella las fija todas. Uno copiado de la 2.5.x mantiene doce más lentas de lo que las corre la 2.6.0: `account`, `outbound` y `totals` a `12h`, `achievements` a `24h`, `stars`, `billing` y `analyses` a `6h`, `discussions` a `2h`, `deployments` a `1h`, y `activity`, `events` y `notifs` a `30m`. Nada avisa, porque el aviso es para una cadencia cuatro veces más rápida que la interna. Borrar esas líneas es lo que recoge los valores actuales; desde la 2.6.1 el ejemplo las muestra comentadas, así que una copia suya no fija ninguna. Ver [cada familia, su grupo y su cadencia interna](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#cada-familia-su-grupo-y-su-cadencia-interna). ## A la 2.6.1 ### Si un comentario es la respuesta aceptada es un campo `gh_discussion_comment` llevaba `is_answer` como etiqueta, y quien mantiene un repositorio acepta una respuesta días después de escribirse el comentario, así que un comentario leído antes y después de eso eran dos filas en el mismo instante. La 2.6.1 no escribe ningún `is_answer`: el campo `answers` que ya llevaba cada fila, 1 para la respuesta aceptada y 0 para cualquier otro comentario, dice lo mismo. Un almacén escrito antes de la 2.6.1 y después tiene la medida con dos formas hasta que se borra la medida y se vuelve a llenar con un [relleno histórico](https://docs.jmrp.io/ghchronicle/es/how/backfill/). Los dashboards leen las dos formas, una fila por comentario, y [la página de medidas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cómo-leer-las-tablas) dice qué guarda cada almacén y cómo borrarla. `-migrate` dice cuáles de los almacenes configurados guardan todavía la forma antigua, bajo `2.6.1/gh_discussion_comment/is_answer`: ver [Migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones). Lo que la forma antigua todavía le cuesta a quien lee es una respuesta aceptada y retirada después: su fila antigua dice aceptada, así que los dos paneles de comentarios la leen aceptada hasta que el almacén se pone al día. Medido en la batería en contenedores con una respuesta retirada en el GitHub falso, en InfluxDB 3.11.2, PostgreSQL 18.6, Elasticsearch 9.5.3, Graphite 1.1.10-5 y el fichero SQL reproducido en PostgreSQL: Answers elsewhere y Discussion answers la leen aceptada, una fila para el comentario, antes de actualizar en todos los almacenes; no aceptada, todavía una fila, en los tres primeros tras un arranque con `migrate: auto`, que deja Graphite y el fichero SQL a quien los lleva; y no aceptada en los cinco tras `-migrate -yes`, una vez ejecutadas las órdenes de Graphite y reproducido el fichero desde donde estaba. ### Un ajuste de ruta se expande `state_file`, `sinks.dedupe_file`, `log.file`, `sinks.file.path` y `sinks.sql.path` toman una `~` inicial como el directorio personal y un `${VAR}` del entorno, como siempre hicieron las credenciales y las direcciones. Una configuración que escribía cualquiera de los dos obtenía un directorio llamado con esos caracteres, dentro del directorio de trabajo, y ahora obtiene la ruta que quería decir. Donde eso era el fichero de estado, el estado que guardaba la versión anterior está en el directorio del nombre raro, y la nueva arranca sin él: mueve los ficheros antes del primer arranque si los recorridos que ahorra merecen conservarse. Un `${VAR}` en una ruta que no está definido o está vacío detiene el arranque y nombra la clave, en vez de dejar la ruta sin esa parte. Ver [expansión de `${VAR}`](https://docs.jmrp.io/ghchronicle/es/configuration/#expansión-de-var). ### Un volumen de estado de Docker no necesita `chown` Hasta la 2.6.0 la imagen no tenía `/var/lib/ghchronicle`, así que el volumen que una pila de compose o un `docker run` montaba ahí se creaba como propiedad de root, y el colector, uid 65532, no guardaba en él ni su estado ni su caché hasta que se le entregaba el volumen con un `chown`. Desde la 2.6.1 la imagen trae el directorio, propiedad del uid 65532, y Docker le da ese dueño a un volumen nuevo montado ahí. Un volumen vacío que una imagen anterior dejó a root se entrega del mismo modo la primera vez que se crea sobre él un contenedor de la 2.6.1, que es lo que hacen `docker compose pull` y después `docker compose up -d`; uno ya entregado se queda como está. Un directorio del anfitrión montado ahí sigue necesitando su `chown`. Ver [qué tiene que ser escribible](https://docs.jmrp.io/ghchronicle/es/install/docker/#qué-tiene-que-ser-escribible). ### `achievements` recorre entero el historial de fusiones otra vez El recuento de coautorías que hay detrás de Pair Extraordinaire lee ahora todos los commits de una pull request que tiene más de cien, donde la 2.6.0 leía los cien primeros y contaba el resto como un mínimo. Eso cambia la regla con la que se guarda el recuento, así que el recuento que tiene un fichero de estado de la 2.6.0 se deja a un lado y la primera pasada de `achievements` tras la actualización recorre todas las pull requests que la cuenta ha fusionado: 41 consultas con la de cuentas, 41 puntos, en 1 minuto y 53 segundos sobre la cuenta medida, el 2026-09-28. Un aviso `co-authored pull request count is a floor` que volvía en cada pasada deja de salir con ello, salvo que de verdad no se pudieran leer los commits que le quedaban a una pull request. Ver [`gh_achievement_progress`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/). > **Qué más cambió** > > Las notas de cada versión, con lo que se midió y lo que no, están en [el > historial de versiones](https://docs.jmrp.io/ghchronicle/es/reference/changelog/). --- # El fichero La configuración de ghchronicle: un fichero YAML, sus credenciales, direcciones y rutas expandibles desde el entorno, y para qué sirve cada bloque de primer nivel. Source: https://docs.jmrp.io/ghchronicle/es/configuration/ ghchronicle lee un único fichero YAML, y el ejemplo documentado del repositorio es el punto de partida. ```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 migrate: # lo que hace un arranque con lo que una actualización dejó en los almacenes grafana: # dónde se publica el dashboard, cuando se pide ``` 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}` Una credencial, una dirección o una ruta de fichero 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, salvo en una ruta de fichero, más abajo. ```yaml github: token: ${GITHUB_TOKEN} sinks: influxdb: url: http://localhost:8181 token: ${INFLUX_TOKEN} ``` La expansión va por clave, no sobre el fichero entero, y estas son las claves a las que llega: - credenciales: `github.token`, `sinks.influxdb.token`, cada valor de `sinks.otlp.headers`, `sinks.telegraf.username` y `password`, `sinks.postgres.dsn`, `sinks.elasticsearch.api_key`, `username` y `password`, y `grafana.token`, `user` y `password`; - direcciones: `github.base_url` y `web_url`, la `url` de `influxdb`, `loki`, `telegraf` y `elasticsearch`, `sinks.otlp.endpoint`, `sinks.graphite.addr`, `grafana.url` y `grafana.datasource.url`; - los identificadores de lo que Grafana ya tiene: `grafana.dashboard_uid`, `grafana.datasource.uid` y `grafana.datasource.loki_uid`; - rutas de fichero: `state_file`, `sinks.dedupe_file`, `log.file`, `sinks.file.path` y `sinks.sql.path`, donde una `~` inicial es además el directorio personal, así que `state_file: ~/.ghchronicle/state.json` es un fichero dentro de él. Solo se lee así una `~` sola o seguida de un separador, `~nombre` se deja como está escrito, y una `~` cuando el proceso no tiene directorio personal se rechaza al arrancar nombrando la clave. También un `${VAR}` en una ruta que no está definido o está vacío, porque la ruta que queda sin él no es la escrita: `state_file: ${STATE_DIRECTORY}/state.json` sería `/state.json`, con el registro de escrituras y el fichero de caché a su lado en la raíz del sistema de ficheros. Antes de 2.6.1 no se expandía ninguna ruta, y esa `~` en un `state_file` era un directorio llamado `~` dentro del directorio de trabajo. Cualquier otro valor se lee tal como está escrito, así que un `${VAR}` en `sinks.loki.tenant_id`, `sinks.influxdb.bucket`, `sinks.prometheus.listen`, una etiqueta, un prefijo o una cadencia le llega al colector como esos caracteres. 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`. GitHub Enterprise Server no sirve el historial diario de estrellas, así que allí [`gh_star_day`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#estrellas-y-forks) y los paneles que cuentan estrellas a partir de ella quedan vacíos | | `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](https://docs.jmrp.io/ghchronicle/es/api/). ## `state_file` ```yaml state_file: /var/lib/ghchronicle/state.json ``` Nueve 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, salvo que el servicio arranca las familias de seis horas o más [una por pasada](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan). - `first_saw`, cuándo se recorrió entera por primera vez la lista de stargazers de cada repositorio. Solo se escribe tras un recorrido que volvió sin error. Sin él el recorrido completo de la lista de stargazers se vuelve a hacer. - `history_read`, cuándo se leyó entero por última vez el historial diario de estrellas de cada repositorio. Solo se escribe tras un recorrido que llegó al final del historial, no tras uno que un error o un 403 o 404 de una página posterior cortó. Sin él ese historial se vuelve a leer entero, una página por cada treinta semanas de vida del repositorio. - `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 hizo la lectura del día cada familia que normalmente lee solo lo que ha cambiado: para `issues`, cada elemento abierto y lo que se movió desde la lectura del día anterior, anotada por repositorio, así que uno que falló la repite y ningún otro. Sin él un repositorio se lee como vencido, así que la pasada siguiente la hace, llegando un mes atrás. - `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. - `coauthored`, el recuento de Pair Extraordinaire que la familia `achievements` ha dado por cerrado, el último día UTC que cubre, la versión de la regla con la que se contó y el día en que se recorrió entero el historial por última vez. Cada pasada recorre solo las pull requests fusionadas desde ese día, y el historial entero otra vez una vez por semana o cuando la regla ha cambiado. Sin él la pasada siguiente recorre enteras las pull requests fusionadas de la cuenta, que en una cuenta con 2.315 fueron 35 consultas y 23,7 MB, donde una pasada que lo tiene recorre solo el día en curso: la consulta de recuentos y una página del recorrido, dos puntos, y de 468 a 513 KB en cada pasada horaria que registró el proxy de producción contra la misma cuenta el 2026-09-27, un tamaño que crece a lo largo del día con lo que se fusiona. - `stores`, para cada almacén en el que escribe una ejecución, adónde apunta, la versión que escribió en él primero, la que lo hizo por última vez, las [migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) que se le han aplicado, las copias de filas antiguas que una migración apartó allí y el historial que una migración despejó allí y aún no ha vuelto a leer. Sin él un fichero SQL, un Graphite o un Telegraf se toman por de la versión en marcha, así que lo que dejó en ellos una versión anterior ya no se muestra, y un almacén despejado y no releído parece uno que nunca guardó la forma antigua, así que nada vuelve a leer su historial hasta que lo haga un `-backfill -families` de las familias que nombraba la relectura. Siete de las nueve 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` 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. También `stores`: qué versión escribió primero en un almacén al que no se le puede preguntar cuál es la clave de sus filas, y una relectura que una migración aún debe, son preguntas que nada puede responder después. Un fichero que está y no se puede leer ni analizar no se toma por uno nuevo: una ejecución que escribe en los almacenes se detiene y lo nombra, porque leído como nuevo olvidaría una relectura aún debida, y su primer guardado pondría un fichero nuevo en su lugar. Una ejecución con otro usuario, root casi siempre, es lo que lo deja ilegible; devuélveselo al usuario con el que corre el colector. Una ejecución que guarda el fichero conserva lo que otro proceso registró de los almacenes desde que ella lo leyó, así que dos ejecuciones que se solapan, un `-once` de cron a la vez que un `-migrate -yes`, no deshacen cada una los registros de los almacenes de la otra. Una pasada con `-card-only` no escribe ninguna de las nueve. Sus puntos llegan a [la tarjeta](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado). ### La caché que hay a su lado ```text /var/lib/ghchronicle/state-cache.bin ``` Junto a `state_file`, como `-cache.bin`, está lo que una pasada aprendió de GitHub y que abarata la siguiente. No hay nada que configurar: su sitio es el del fichero de estado. Guarda cuatro cosas: - la [caché condicional](https://docs.jmrp.io/ghchronicle/es/api/#los-etags-y-por-qué-un-304-es-gratis): cada respuesta pedida dentro del doble de la cadencia más larga que corre la configuración, y nunca menos de un día, 48 horas con las cadencias por omisión, con el ETag con que llegó, así que un reinicio pregunta con `If-None-Match` y recibe un 304 gratis; - las ejecuciones de workflow cuyos jobs ya se escribieron, para que un reinicio no los vuelva a listar; - los [rechazos](https://docs.jmrp.io/ghchronicle/es/api/#un-rechazo-también-se-recuerda), cada uno hasta el final de su propio día; - los tamaños de página que la familia `totals` dio a la consulta de pull requests. Las cuatro vivían antes en el proceso, y cada reinicio las volvía a pagar. Medido en el servicio del autor el 2026-09-26, los primeros 38 minutos tras un reinicio gastaron 1.092 peticiones core cobradas en pasadas que costaban unas 66 con la caché caliente. Las ejecuciones son la afirmación de que cada almacén tiene sus jobs, así que un arranque no las recupera donde eso no se sabe: donde ningún registro de escrituras recuerda lo que tienen los almacenes, que es lo que pasa al borrar el registro para volver a llenar un almacén vaciado, con `dedupe_file: off` o con el `dedupe: false` de un almacén, y en una ejecución que termina con su pasada; y donde se ha añadido un destino desde que se escribió el fichero. Sus jobs se vuelven a listar entonces y se ofrecen otra vez con todos los demás puntos. Dos de esos casos son noticia, un registro que la configuración mantiene y que se lee vacío y un destino añadido, y el arranque los dice: ```text level=INFO msg="the write ledger remembers nothing, listing the jobs of the runs the cache file remembers again" runs=666 level=INFO msg="a store was added since the cache file was written, listing the jobs of the runs it remembers again" stores=influxdb,loki,postgres written_to=influxdb,loki runs=666 ``` Los demás son como corre siempre la configuración, un almacén sin registro o una ejecución que termina con su pasada, y solo se dicen en `debug`, como `not every store keeps a write ledger`. Cada arranque que encuentra el fichero dice lo que ha leído, en su primera pasada, y uno que no encuentra ninguno lo dice en `debug`: ```text level=INFO msg="cache file read" file=/var/lib/ghchronicle/state-cache.bin written=2026-09-27T15:27:41+02:00 answers=1170 runs=672 refusals=100 page_sizes=54 age=1s ``` Se escribe como mucho cada cinco minutos mientras el recolector corre, y una vez más cuando se para: al final de un `-once`, y cuando al servicio se le pide parar con `SIGTERM` o `SIGINT`, que es lo que envían `systemctl stop`, `docker stop` y Ctrl-C. Un proceso matado sin más no puede, así que `SIGKILL`, el OOM killer del núcleo, `docker kill`, `taskkill /F` y `Stop-ScheduledTask` pierden lo que aprendió desde el último guardado, cinco minutos como mucho, y el siguiente arranque vuelve a pedir esas respuestas. Un guardado que falla lo dice, y también un arranque que encuentra un fichero que no puede leer: ```text level=WARN msg="cache file not saved" file=/var/lib/ghchronicle/state-cache.bin err="open /var/lib/ghchronicle/state-cache.bin.tmp: permission denied" level=WARN msg="cache file not read, the first pass of each family pays in full" file=/var/lib/ghchronicle/state-cache.bin err="..." ``` Una pasada con `-card-only` lo lee y no lo escribe, por la misma razón por la que deja en paz el fichero de estado. Un `-backfill` lo lee y tampoco lo escribe: las páginas que recorre son páginas que ninguna pasada pide, y guardadas desplazarían del fichero las respuestas de las propias pasadas. Una actualización lo conserva: una respuesta se guarda bajo la forma de lo que lee su recolector, así que las únicas respuestas que se vuelven a pedir enteras son las de un recolector que ahora lee otra cosa. Está acotado. Una respuesta de más de un megabyte se queda fuera, y también lo que pase de 64 MB, empezando por lo que menos recientemente se pidió. Medido contra la cuenta del autor, de 37 repositorios, una pasada de todas las familias guarda 1.065 respuestas, 10,3 MB de ellas y 1,6 MB de fichero, ninguna de más de 404 KB. Con los días la caché en memoria crece con las respuestas cuya consulta lleva una ventana móvil: el servicio del autor tenía 6.695 tras cinco días y medio, unos 99 MB, y las 2.517 de ellas pedidas en las últimas 48 horas, que son las que guarda el fichero, suman unos 31 MB. Contiene lo que GitHub respondió sobre repositorios privados además de públicos, y se escribe con modo 600, como el fichero de estado. Borrarlo cuesta una pasada de cada familia a precio completo, el precio que pagaba cada reinicio antes de que existiera, y no pierde nada. Cada respuesta se vuelve a pedir, cada función rechazada se vuelve a preguntar, que es también la manera de que una función activada hoy se note antes de que acabe su día, los jobs de las ejecuciones más recientes se vuelven a listar una vez, y la primera pasada corre `totals` antes de las pull requests, diga lo que diga su cadencia, así que sus páginas se vuelven a dimensionar. Cuando `totals` no tocaba de todos modos, lo dice: ```text level=INFO msg="no page sizes remembered, running totals before the pull requests it sizes" ``` Un fichero que no se carga entero, cortado, dañado o escrito en otro formato, no se lee en absoluto, con el aviso de arriba, y cuesta lo mismo; el siguiente guardado lo sobrescribe. Quiere la misma ruta persistente que los otros dos. ## `backfill` ```yaml backfill: since: 2y ``` Solo se aplica a una ejecución lanzada con `-backfill`, y `-backfill-since` lo sobrescribe. Ver [relleno histórico](https://docs.jmrp.io/ghchronicle/es/how/backfill/). Un relleno lleva un fichero más junto a `state_file`, `-progress.json`, donde anota lo que ya ha escrito para que una parada cueste un repositorio y no el recorrido entero. No hay nada que configurar: aparece cuando arranca un relleno, se borra cuando el recorrido llega al final, y una pasada normal ni lo escribe ni lo lee. Ver [pararlo, y retomarlo](https://docs.jmrp.io/ghchronicle/es/how/backfill/#pararlo-y-retomarlo). ## `migrate` ```yaml migrate: auto ``` Lo que hace una ejecución que escribe en los almacenes, antes de su primera pasada, con un almacén que una versión anterior dejó con una forma que esta ya no escribe. `-migrate` imprime lo que cada almacén configurado guarda de cada cambio así; ver [Migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones). | Valor | Qué hace un arranque | | ------ | ------------------------------------------------------------------------------------------------------------------- | | `auto` | El valor por omisión. Aplica por su cuenta un cambio pendiente que no pierde nada, lo dice en `WARN`, vuelve a leer lo que despejó, y lo que una ejecución anterior despejó y no terminó de leer, y avisa de todos los demás en cada arranque | | `warn` | No aplica nada ni vuelve a leer nada, y avisa de cada cambio pendiente, y de cada almacén al que aún se le debe su historial, en cada arranque | Un cambio no pierde nada cuando se cumplen las tres cosas: GitHub todavía sirve toda la historia, así que volver a leerla trae de vuelta cada fila que guarda el almacén; las filas antiguas se apartan al menos 24 horas en vez de borrarse, que es lo que hacen InfluxDB 3, PostgreSQL y Elasticsearch; y todas las filas del almacén son de esta configuración, lo que se le pregunta al almacén. No hay valor que aplique el resto por su cuenta. Una ejecución de una sola vez, `-once` o `-backfill`, sobre un fichero de estado nuevo tampoco aplica nada por su cuenta: esa es cada ejecución de la Action sin un fichero de estado restaurado, cuyo registro de una relectura aún debida se va con su runner. Cada aviso nombra las dos órdenes, `-migrate` para el plan y `-migrate -yes` para aplicarlo, con el servicio parado. Cualquier otro valor se rechaza al arrancar. El servicio también tiene un bloqueo junto al fichero de estado, `-lock`, mientras corre, que es lo que impide que `-migrate -yes`, y `-uninstall -yes` de los datos o del estado, cambien los almacenes bajo sus pies. No hay nada que configurar: sigue a `state_file`, el sistema operativo lo suelta acabe como acabe el proceso, y el fichero que queda solo nombra el último proceso que lo tuvo. ## 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 | | `state_file: "${X}/state.json" names ${X}, which is unset, ...` | Una ruta nombra una variable sin valor: defínela, o escribe la ruta entera | | `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 | | `migrate: "off" is not auto or warn` | Lo mismo, para `migrate` | > **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](https://docs.jmrp.io/ghchronicle/es/configuration/targets/): qué repositorios, y los valores por defecto de forks y archivados. - [Cadencias](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/): el bloque `every`, y qué significa `0`. - [Registro](https://docs.jmrp.io/ghchronicle/es/configuration/logging/): nivel, formato y el fichero rotatorio. - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/): el bloque `sinks`, una página por almacén. - [Dejar que el binario publique el dashboard](https://docs.jmrp.io/ghchronicle/es/dashboards/#dejárselo-al-binario): el bloque `grafana`. --- # 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://docs.jmrp.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. ## 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.postgres`: un bloque de ajustes - `sinks.postgres.dsn`: string, obligatorio, una credencial, como `${DATABASE_URL}` - `sinks.postgres.batch`: int, como `1000` - `sinks.postgres.dedupe`: bool, 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` - `migrate`: string, por omisión `auto`, uno de `auto`, `warn` - `grafana`: un bloque de ajustes - `grafana.url`: string, como `http://localhost:3000` - `grafana.token`: string, una credencial, como `${GRAFANA_TOKEN}` - `grafana.user`: string, como `admin` - `grafana.password`: string, una credencial, como `${GRAFANA_PASSWORD}` - `grafana.folder`: string, como `GitHub` - `grafana.publish_on_start`: bool, como `false` - `grafana.dashboard_uid`: string, como `my-existing-dashboard` - `grafana.datasource`: un bloque de ajustes - `grafana.datasource.url`: string, como `http://influxdb:8181` - `grafana.datasource.uid`: string, como `ae3x9k2` - `grafana.datasource.sslmode`: string, como `require` - `grafana.datasource.loki_uid`: string, como `be7m1q4` - **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@v2 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](https://docs.jmrp.io/ghchronicle/es/install/actions/) para el workflow que lo rodea, y [el token](https://docs.jmrp.io/ghchronicle/es/start/token/) para lo que el secreto tiene que poder hacer. ## Lo demás - [El fichero](https://docs.jmrp.io/ghchronicle/es/configuration/): para qué sirve cada bloque y qué pasa cuando un valor está mal. - [Objetivos](https://docs.jmrp.io/ghchronicle/es/configuration/targets/): qué repositorios. - [Cadencias](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/): cada familia, su grupo y su intervalo de fábrica. - [Elegir almacén](https://docs.jmrp.io/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://docs.jmrp.io/ghchronicle/es/configuration/targets/ El bloque `targets` decide qué repositorios de GitHub recoge una pasada: los de una cuenta, los de sus organizaciones y los que se nombren. ```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. Nombrar uno de los repositorios de la propia cuenta, un fork tuyo por ejemplo, no cuesta ninguna petición para descubrirlo: el listado ya lo devolvió, con las cuatro cosas que el descubrimiento quiere saber de él. Solo un nombre que ningún listado devuelve, el repositorio de otra persona o uno de una organización que no está en `orgs`, se lee aparte cada vez que se reconstruye la lista. ## 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 ya no da trabajo.** Sus estrellas, sus forks y sus watchers todavía pueden moverse, y esos se leen de todos modos, como se explica abajo. Nada más puede, 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 dos filas en cada pasada de `totals`: `gh_repo_archived`, fechada en el instante en que se archivó, y `gh_repo_total`, sus contadores de toda la vida tal como están, estrellas y forks incluidos, sellada en la pasada. El listado que una pasada ya paga dice qué repositorios están archivados, y la familia `totals` pregunta por todos en una consulta por cada veinticinco a su propia cadencia, así que la tabla _Repositories archived_ existe desde la primera pasada, los totales de estrellas y forks de la cuenta los cuentan, y el coste es un punto por cada veinticinco repositorios archivados en cada pasada de `totals`. Y un [relleno histórico](https://docs.jmrp.io/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 una vez por hora: en la primera pasada una hora después del último listado, con medio tic de margen, el mismo que recibe [la cadencia de una familia](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#heartbeat-el-tic-del-bucle-que-no-es-una-cadencia). 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. Antes de la 2.6.1 la lista no tenía margen y podía sobrevivir por unos milisegundos a la pasada de una hora después, así que con el tic de cuarto de hora un repositorio nuevo esperaba hasta una hora y cuarto la mitad de las veces. Nombrarlo en `repos` no cambia eso; reiniciar el proceso sí, y también cada `-once`, que los lista de nuevo. --- # 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://docs.jmrp.io/ghchronicle/es/configuration/cadences/ Cada familia de métricas de GitHub se recoge con su propio intervalo, y el bloque `every` es donde se cambian esos intervalos. ```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` | `1h` | el calendario de contribuciones se mueve con cada contribución y los contadores del perfil con cada seguimiento y cada estrella, y una pasada es un punto de GraphQL y siete peticiones REST, de las que solo se cobra la del perfil: GitHub casi nunca la respondió con un 304, una vez en 48 lecturas condicionales, y los seis listados de paquetes lo responden hasta que cambia un paquete | | `account` | `achievements` | `1h` | las insignias de la página pública del perfil, que ninguna API lista, y lo que le falta a cada una con niveles para el siguiente; una pasada transfiere la página, 36 KB y fuera del presupuesto, y las pull requests fusionadas desde el día anterior por dos puntos de GraphQL, así que un nivel alcanzado por la tarde se ve por la tarde, y el historial entero se recorre una vez por semana, 24 MB | | `account` | `billing` | `1h` | el mes en curso se mueve mientras corre la integración continua: había cambiado en cada una de las 46 lecturas cada seis horas medidas, y una pasada son dos peticiones, ese mes y el anterior, de las que solo se cobra la primera | | `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` | `1h` | las estrellas dadas, el trabajo en repositorios ajenos y las respuestas aceptadas allí, por unos nueve puntos de GraphQL por pasada, así que la hora no cuesta nada que se note y una respuesta aceptada esta tarde está en el panel esta tarde | | `account` | `profile` | `12h` | paquetes, gists y cuentas sociales, todo ello editado a mano | | `account` | `totals` | `1h` | los números de toda la vida se mueven con cada estrella, fork, fusión y push, y una pasada es un punto de GraphQL por la cuenta, uno por cada diez repositorios y uno por cada veinticinco archivados que se dejan fuera, y una petición del presupuesto de búsqueda | | `audience` | `forks` | `12h` | la lista entera cabe en una página, y un fork es un suceso raro | | `audience` | `stars` | `1h` | una estrella puede llegar a cualquier hora, y tras el único recorrido completo de la lista una pasada es un punto de GraphQL por cada diez repositorios para las cien más nuevas y una petición condicional por repositorio para el historial diario, que respondió un 304 gratis a 184 de las 185 medidas | | `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` | `30m` | es la superficie que lee un dashboard de entrega, y la página más nueva es un punto de GraphQL por cada cinco repositorios, que trajo hasta cuatro filas nuevas por pasada medida, así que la media hora cuesta poco | | `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` | `15m` | el log del repositorio guarda cien entradas, que cubrieron veintiséis horas en el repositorio más activo medido, y una pasada son una o dos peticiones condicionales por repositorio, que respondieron un 304 gratis a 2.320 de las 2.356 medidas | | `feeds` | `events` | `15m` | una petición core por pasada, y el feed se había movido en 43 de las 46 medias horas medidas, así que el cuarto de hora es lo pronto que un evento llega al panel; la ventana, los últimos trescientos eventos de los últimos treinta días, es mucho más ancha que eso | | `feeds` | `notifs` | `15m` | una petición core por pasada y veinte una vez al día por la bandeja entera, y un hilo muestra solo su último movimiento, así que un movimiento que otro adelanta antes de la siguiente lectura no se ve nunca; la mitad de las pasadas medidas trajo una fila nueva | | `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 una pasada son tres peticiones REST por repositorio, casi todas un 304 gratis, y dos puntos de GraphQL por cada diez repositorios | | `repos` | `rulesets` | `24h` | un ruleset se edita unas pocas veces al año y cada versión conserva su propia fecha; las dos peticiones responden un 304 gratis hasta que alguien edita uno, también en la primera pasada tras un reinicio, porque la caché de ETag se guarda junto al fichero de estado | | `repos` | `settings` | `6h` | webhooks, rulesets, entornos y claves de despliegue cambian solo cuando alguien los cambia | | `security` | `analyses` | `1h` | GitHub poda los análisis de code scanning y cada push analizado añade alguno, y una pasada es una petición condicional por repositorio con code scanning, tres de ellas cobradas en la pasada mediana medida, con las negativas del resto recordadas un día | | `security` | `security` | `1h` | una alerta es algo sobre lo que actuar hoy, y la lista de abiertas es corta | | `work` | `commits` | `1h` | un punto de GraphQL por cada repositorio cuya rama por defecto tiene un commit de las dos últimas cadencias, que tenían 38 de 999 respuestas medidas, y uno por cada veinticinco repositorios, compartido con issues e issueevents, para preguntar cuáles, así que la hora es lo pronto que se ve en el panel un push | | `work` | `discussions` | `1h` | una discusión se responde en horas o días y pocos repositorios tienen foro, y una pasada son dos puntos de GraphQL por cada uno que lo tiene y nada por el resto | | `work` | `issueevents` | `1h` | la cronología de lo que se movió en dos cadencias, un punto GraphQL por cada repositorio donde se movió una issue o una pull request y nada por los demás, así que la hora es lo pronto que merece verse una transición | | `work` | `issues` | `1h` | uno o dos puntos GraphQL por cada repositorio cuyas issues o pull requests se movieron en dos cadencias, nada por los demás, y una vez al día un punto o más por sus elementos abiertos y hasta cuatro por página de lo que se movió en el día, así que la hora es lo pronto que se ve en el panel una revisión o una fusión | | `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 están sobre 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`. Ninguna familia viene a `2h` desde que `discussions` pasó a la hora, pero el peldaño sigue siendo donde cae un paso abajo desde `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. Lo que GitHub borra no se puede recuperar después, se haga lo que se haga, y está en tres grupos distintos: el feed de eventos, que guarda trescientos eventos y ninguno de hace más de treinta días, y la bandeja, que conserva las notificaciones tres meses salvo las guardadas (`feeds`); la ventana de catorce días del tráfico (`audience`); y los logs de los jobs, que se borran al acabar el periodo de retención del repositorio, noventa días por omisión, y con ellos, desde el 1 de octubre de 2026, las propias ejecuciones de workflows (`ci`). Lo que GitHub borró antes de que ghchronicle lo leyera no existe en ninguna parte. 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` y `gh_star_day` 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. Una familia toca en el primer tic en que su cadencia ha pasado, con medio tic de margen. El bucle lee el reloj unos milisegundos después de despertar, en una cantidad que cambia de un tic a otro, y sin ese margen una familia cuya cadencia es un número entero de tics esperaba un tic de más la mitad de las veces: hasta la 2.6.0, que trajo la corrección escrita para la 2.5.2, las familias de cuarto de hora corrían cada 24 minutos de media y las horarias cada 69. Medio tic absorbe eso y nunca deja correr a una familia un tic antes de tiempo. 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" ``` ## Las familias lentas se turnan El servicio en marcha arranca una familia de cadencia de seis horas o más en cada pasada, o las menos que aún mantienen todas las cadencias cuando una no basta (más abajo), y deja cualquier otra que toque para el tic siguiente. Las familias que corren en la misma pasada quedan marcadas con el mismo instante en el fichero de estado, así que vuelven a tocar juntas en cada cadencia, para siempre. Un grupo así se forma siempre que se marcan muchas familias a la vez: una instalación nueva, un `-once` o un backfill antes del servicio, una familia que se enciende. Hasta la 2.6.0 las ocho familias diarias de la cuenta de producción corrían en la primera pasada del día UTC, todos los días, y sus cinco familias de doce horas juntas en una pasada propia. El 2026-09-26 la pasada diaria gastó 294 peticiones `core` facturables y 24,3 MB frente a 17,5 peticiones de la pasada mediana, y con la de doce horas 45 minutos después, su hora fue ocho veces la hora mediana en peticiones `core`. Las puso ahí el calendario, no el trabajo. La familia que arranca es la que lleva más tiempo tocando, contado desde lo más reciente entre su última pasada y la última vez que el proceso la dejó arrancar. La segunda mitad importa para una familia cuyas pasadas fallan todas: una pasada fallida no se marca como hecha, así que por su última pasada sola sería la familia más atrasada en cada pasada y se quedaría con todos los turnos. Contada así, se pone detrás de las demás. Una familia espera a cada una de las otras como mucho una vez, así que la peor espera es un tic por cada otra familia lenta, y solo la última de un grupo recién formado espera tanto. Con el tic de cuarto de hora que dan las cadencias internas: | Calendario | Familias de 6h o más | Espera más larga | | ------------------------------------------ | -------------------- | ---------------- | | las cadencias internas | 11 | 10 tics, `2h30m` | | con `deps` e `history` a `24h` | 13 | 12 tics, `3h` | | con `deps`, `history` y `joblogs` a un día | 14 | 13 tics, `3h15m` | Esa espera se paga una vez. En cuanto dos familias han corrido en pasadas distintas, tocan en pasadas distintas y nada vuelve a juntarlas: una semana simulada de las trece, todas pendientes en una misma pasada al principio, tuvo cada familia arrancando exactamente una cadencia después de su arranque anterior desde el primer día. El log dice qué familia arrancó y cuáles esperan, así que una familia lenta que falta en una pasada en la que tocaba queda explicada: ```text level=INFO msg="slow families due together take turns" starting=planning waiting=settings,traffic,forks,profile,stats,branches,inventory,keys,policyfiles,rulesets ``` Una por pasada vale mientras puede cumplir todas las cadencias, y una configuración con un tic más largo puede tener más familias lentas que eso. `every.default: 6h` late cada hora, porque nada en ella corre más a menudo, con treinta y una familias a seis horas, y una por pasada arrancaría cada una cada treinta y una horas. Ahí una pasada arranca las menos que caben, seis, y ninguna familia espera tanto como su cadencia. Solo el servicio se turna. `-once` corre todas las familias que tocan, porque no tiene un tic siguiente al que dejar ninguna: lanzado una vez al día por un planificador, la dejaría para el día siguiente. Un backfill, una tarjeta y la primera pasada de un servicio que ceba el [exportador de Prometheus](https://docs.jmrp.io/ghchronicle/es/sinks/prometheus/#cómo-consultarlo) corren todas las familias diga lo que diga el fichero de estado. La pasada cebada anota después solo las familias a las que les tocaba, así que un reinicio no vuelve a poner las demás en un mismo instante, y cada una conserva el turno que tenía. ## Hacerlas más lentas Si el presupuesto que se queda corto es `core`, alarga `artifacts` y después `actions`, los dos mayores costes REST, que crecen con lo activos que estén los repositorios. Si es `graphql`, alarga `issues`, `issueevents` y `commits`, que leen solo los repositorios donde algo se movió y por eso cuestan más cuantos más se muevan. No son los únicos que siguen a la actividad y no al tamaño: `activity` solo se cobra por un repositorio cuyo log se movió, y `outbound` cuesta un punto más por cada centenar adicional de lo que lee. Ver [qué crece con la actividad, no con el tamaño](https://docs.jmrp.io/ghchronicle/es/api/cost/#qué-crece-con-la-actividad-no-con-el-tamaño). 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` lee los últimos trescientos eventos, `notifs` solo el último movimiento de cada hilo y `activity` las últimas cien entradas del log de cada repositorio, así que lo que salga de una de ellas entre dos pasadas se pierde. --- # Registro Nivel, formato, el fichero rotatorio que nunca sustituye a la salida de error, y las dos líneas que merecen alerta. Source: https://docs.jmrp.io/ghchronicle/es/configuration/logging/ ghchronicle escribe su registro en la salida de error, y el bloque `log` fija el nivel, el formato y un fichero rotativo opcional. ```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 Tomado del servicio del autor el 2026-09-27, un arranque y las líneas de sus pasadas: ```text level=INFO msg="ghchronicle running" tick=15m0s tick_from="shortest cadence" level=INFO msg="cache file read" file=/var/lib/ghchronicle/state-cache.bin written=2026-09-27T15:27:41+02:00 answers=1170 runs=672 refusals=100 page_sizes=54 age=1s level=INFO msg="repositories discovered" count=37 archived_aside=17 level=INFO msg="slow families due together take turns" starting=planning waiting=settings,traffic level=INFO msg=written sink=influxdb family=repo points=934 unchanged=1955 level=INFO msg="nothing moved since the window, repositories left unread" family=commits repos=34 level=INFO msg=written sink=influxdb family=commits points=17 unchanged=0 level=INFO msg="rate budget" bucket=core remaining=4477 limit=5000 level=INFO msg="points already written and not sent again" sink=influxdb skipped=54511 level=INFO msg="sweep finished" ``` Las dos primeras salen una vez, cuando arranca el proceso: el tic al que despierta el bucle, y lo que devolvió [la caché que hay junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). La lista de repositorios se rehace una vez por hora, y `archived_aside` cuenta los archivados [apartados](https://docs.jmrp.io/ghchronicle/es/configuration/targets/#por-qué-forks-y-archivados-están-apagados-por-omisión), que siguen recibiendo sus dos filas. En `written`, `points` es lo que llegó al almacén y `unchanged` lo que el registro de escrituras dejó fuera porque el almacén ya lo tiene; ver [qué cuenta la línea de escritura](https://docs.jmrp.io/ghchronicle/es/sinks/#qué-cuenta-la-línea-de-escritura). Una familia de seis horas o más a la que le toca detrás de otra aparece en `waiting`, ver [las familias lentas se turnan](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan), y `commits`, `issues` e `issueevents` dicen cuántos repositorios dejaron sin leer porque nada se movió en ellos, ver [preguntar primero qué se movió](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió). 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 `stats`. ## 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 estas líneas, y ninguna más: - `loaded the written-points ledger`, con cuántos puntos volvió el libro de escrituras, cuando arranca el servicio; - `no cache file yet, the first pass of each family pays in full`, en el primer arranque con fichero de estado; - `not every store keeps a write ledger`, cuando las ejecuciones que recuerda el fichero de caché se vuelven a listar porque un almacén no lleva registro o la ejecución termina con su pasada; - `no targets.user, account-wide families skipped`; - `sink dropped old entries`, con cuántas entradas dejó fuera un destino por ser más antiguas que su horizonte, la única forma de ver que un envío se recortó en lugar de rechazarse; - `card-only sweep, the state file is left as it was`; - `cache file saved`, con cuántas respuestas y ejecuciones guarda y cuánto tardó; - `migration not needed` y `migration applied before`, en cada arranque, por cada cambio que un almacén nunca necesitó o que ya tiene aplicado; - `migration noted: nothing is changed` y `migration frozen`, en cada arranque después del primero, que dice cada una en `INFO`; - `set-aside copy forgotten`, cuando le llega su hora a una copia que apartó una migración y el fichero de estado deja de nombrarla: `the store purges it itself` para una que InfluxDB 3 purga con su propio calendario, y `the store keeps it for good, and -migrate asks the store for it` para una que guarda para siempre. 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. Eso incluye su único reintento: una petición REST respondida con 500, 502, 503 o 504 se repite dos segundos después, en silencio, y también la descarga del log de un job desde el almacenamiento, así que un `collector failed` que nombra uno de esos cuatro ya ha fallado dos veces. > **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.families.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](https://docs.jmrp.io/ghchronicle/es/sinks/loki/). --- # La pasada Qué hace una pasada de ghchronicle por la API de GitHub, en qué orden, y por qué cada familia de métricas tiene su propia cadencia. Source: https://docs.jmrp.io/ghchronicle/es/how/ Una pasada es un recorrido por cada familia cuyo intervalo ya ha vencido, salvo que el servicio en marcha arranca una familia de seis horas o más en cada pasada, más solo donde una no mantendría todas las cadencias, y deja las demás que tocan para los tics siguientes: [las familias lentas se turnan](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan). 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 a fichero, 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](https://docs.jmrp.io/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. Las etiquetas y los hitos se editan unas pocas veces por semana. 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 | | `activity` | 15m | El log de actividad del repositorio, donde queda un force push | | `events` | 15m | El feed de eventos de la cuenta, que guarda solo los últimos 300 | | `notifs` | 15m | La bandeja de notificaciones | | `ratelimit` | 15m | Lo que le queda por gastar al colector, en cada presupuesto | | `deployments` | 30m | Despliegues y sus entornos, en lote sobre todos los repositorios | | `account` | 1h | Perfil, calendario de contribuciones, totales | | `achievements` | 1h | Los distintivos del perfil, y lo que falta para el siguiente nivel | | `analyses` | 1h | Análisis de code scanning, que GitHub poda | | `artifacts` | 1h | Artefactos y su caducidad | | `billing` | 1h | Uso por día, producto, SKU y repositorio | | `commits` | 1h | Líneas cambiadas y estado de la firma, por commit | | `discussions` | 1h | La mitad de foro de un repositorio | | `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 | | `outbound` | 1h | Estrellas dadas, y trabajo en repositorios ajenos | | `repo` | 1h | Estrellas, forks, lenguajes, temas, releases, rulesets | | `security` | 1h | Alertas de Dependabot y de code scanning | | `stars` | 1h | Estrellas por día de cada repositorio, y el recorrido de la lista de estrellas una vez, luego las cien más nuevas | | `totals` | 1h | Las cifras de siempre, preguntadas a GitHub en vez de sumadas aquí | | `planning` | 6h | Etiquetas e hitos | | `settings` | 6h | Webhooks y sus entregas, entornos, claves de despliegue | | `traffic` | 6h | La ventana entera de 14 días, reescrita | | `forks` | 12h | Quién hizo fork, y cuándo | | `profile` | 12h | Paquetes, gists, cuentas sociales | | `stats` | 12h | Commits por semana, el punch card, las definiciones de workflow | | `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](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/). ## El descubrimiento La lista de repositorios se rehace una vez por hora, en la primera pasada que la encuentra con una hora de antigüedad, con medio tic de margen: una pasada lee su reloj unos milisegundos antes o después de la hora, y sin ese margen un repositorio creado entre medias esperaba un tic más. 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](https://docs.jmrp.io/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 [nueve cosas](https://docs.jmrp.io/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 lista de stargazers ocurra una vez y no en cada pasada, como hace `history_read` con el historial diario de estrellas. 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. Junto a él el recolector guarda lo que hace barata una pasada y no lo que la hace completa: la caché de ETags, las ejecuciones de workflow cuyos jobs ya se escribieron, los rechazos y los tamaños de página de las pull requests, en [`-cache.bin`](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). Un reinicio lo vuelve a leer y empieza donde se paró el proceso anterior, no de cero, y borrarlo cuesta una pasada a precio completo de cada familia y no pierde nada. > **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 cuando el servicio alimenta el [exportador de > Prometheus](https://docs.jmrp.io/ghchronicle/es/sinks/prometheus/#cómo-consultarlo) su primera > pasada tras el arranque corre todas las familias activas diga lo que diga el > fichero de estado, salvo que `no_prime` esté puesto. Solo anota como hechas > las familias a las que les tocaba, así que las demás conservan el turno que > tenían. `-once` no arranca exportador y no hace esa pasada completa. Un > [relleno histórico](https://docs.jmrp.io/ghchronicle/es/how/backfill/) también corre todas las > familias, porque llegar tan atrás como permita GitHub es justo lo que se le > pide, y también una pasada que dibuja [una tarjeta](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/api/). Un [relleno histórico](https://docs.jmrp.io/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://docs.jmrp.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 todas las filas 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. Un lote se reduce tal como lo guardan los almacenes. Dos puntos con la misma medida, las mismas etiquetas y la misma hora son una fila en InfluxDB, PostgreSQL, Graphite y Elasticsearch, así que aquí también son una fila: `sum` la suma una vez, `count` la cuenta y la promedia una vez, y `keepLast` se queda con la última de dos lecturas fechadas en el mismo instante. La fila toma del último punto un campo que traen los dos y conserva un campo que solo traía el anterior, como hacen InfluxDB, PostgreSQL y Graphite; Elasticsearch indexa el último punto como el documento entero, así que allí se pierde un campo que solo traía el anterior. Una etiqueta con valor vacío no es etiqueta, como en InfluxDB, PostgreSQL y Elasticsearch; Graphite la escribe como el nodo `none`, así que allí el punto que la lleva y el que no son dos rutas. Entre pasadas no hace falta nada más, porque el gauge de cada pasada sustituye al anterior y `total` recuerda cada elemento que ya ha contado. `sum` suma todos los números menos un precio. La factura repite el precio de un SKU en cada una de sus filas, una por repositorio y día, así que la reducción se queda con el más alto, que es el `MAX(price_per_unit)` que leen los dashboards SQL; sumado, daba el precio multiplicado por los días facturados. `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 doce que no se sirven nunca Doce de las medidas llevan `skip`, así que un exportador de Prometheus y un backend OTLP con `raw: false` no las ven nunca. Nueve 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_release_published` | Historia. La fecha de publicación de cada release; `gh_release` ya es una serie por release | | `gh_star_day` | Historia. Estrellas por día, corregidas hacia atrás cuando se quita una estrella dada en las últimas treinta semanas; el recuento es `gh_repo.stars` | | `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](https://docs.jmrp.io/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. También lo es todo valor que se mueve después de la fecha de su fila: una etiqueta es parte de la identidad de la fila, así que una etiqueta que cambiara abriría una segunda fila en el mismo instante y dejaría la vieja al lado para siempre. Un comentario se acepta como respuesta días después de escribirse, así que desde la 2.6.1 eso es el campo `answers` de `gh_discussion_comment` y no una etiqueta `is_answer`. [La página de medidas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cómo-leer-las-tablas) lista las demás, y qué guarda un almacén escrito antes de la 2.6.1. **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. `gh_star_day` se ancla igual, cada día la `week` que devuelve GitHub más su índice. El día es el día natural del propio GitHub en America/Los_Angeles, medido y no documentado, y la fila se sella a las 00:00 UTC de esa fecha, así que una estrella dada una mañana europea puede quedar un día antes del instante que le da `gh_star`. **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://docs.jmrp.io/ghchronicle/es/how/backfill/ Un relleno histórico recorre cada superficie de GitHub hasta donde la API responda, una vez, para que la historia empiece antes de la primera pasada. ```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, las lentas [por turnos](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan) en el servicio | todas las activas, diga lo que diga el fichero de estado | | Si se para | no hay nada que guardar; el siguiente tic barre | un punto de control guarda su sitio, y el mismo comando sigue desde ahí | | 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. Y un relleno que se para a medias guarda su sitio, así que el mismo comando lanzado otra vez sigue desde ahí y no desde el primer repositorio. ## Pararlo, y retomarlo Un recorrido sobre una cuenta real dura horas, y siempre aparece un motivo para pararlo: el del autor llevaba seis horas y media cuando hubo que sustituir el binario que lo ejecutaba. Pararlo costaba el recorrido entero. El sitio se guarda en un fichero junto al de estado, con su mismo nombre y `-progress.json` en lugar de `.json`. No se configura. Se escribe después de cada repositorio y no después de cada familia, y eso está medido, no elegido: en una cuenta de cincuenta y nueve repositorios las familias de cuenta tardan cuatro minutos entre todas, y luego `actions` tarda dos horas y tres cuartos y `commits` más todavía, así que un punto de control al final de cada familia guardaría los cuatro minutos y tiraría el resto. Lo que anota es lo que ya se ha entregado. Un repositorio entra en el fichero solo cuando su recorrido completo ha llegado a todos los destinos configurados, y eso es lo que hace seguro saltárselo a la vuelta: no queda cola alguna de su propia paginación en ninguna parte. Un repositorio cuyo colector falló, o cuyas filas rechazó un almacén, no se anota, y el recorrido vuelve a él. Volver a él cuesta peticiones y nada más, porque un punto lleva la fecha en que ocurrió la cosa y recolectarlo otra vez reescribe las mismas filas. ```text level=INFO msg="backfill stopped, and what it had written is kept" file=/var/lib/ghchronicle/backfill-progress.json running_for=6h40m7s families_complete=9 family=actions repositories_written=23 resume="run the same command again" ``` El fichero está pensado para leerse, y dice lo mismo con más detalle: las familias que terminaron y cuándo, la familia que estaba en curso, y cuáles de sus repositorios están hechos. Se borra cuando el recorrido ha cubierto todas las familias que se le pidieron, así que un punto de control que existe es un recorrido con trabajo pendiente. Llegar al final sin error no es lo mismo. Una familia truncada por un límite de ritmo secundario se devuelve como pasada y no como fallo, y una familia que falló en todos sus repositorios se deja a propósito sin marcar; un recorrido puede terminar ordenadamente con cualquiera de las dos detrás. El punto de control se queda, y la línea del final nombra las familias que faltan. Una pasada normal no guarda nada de esto. Ni escribe un punto de control ni lee ninguno: el fichero de estado que lleva habla de cadencias, y un relleno a medias no pinta nada ahí. ### Las dos cosas que rechaza **Un punto de control que no puede leer.** Una máquina que pierde la corriente puede dejar un fichero vacío o escrito a medias. La ejecución se para con una frase que lo nombra en vez de rehacer el recorrido en silencio: lo que ese fichero lista pueden ser horas de la historia de alguien, y empezar de cero es la respuesta que parece un éxito. **Un punto de control de otro recorrido.** El fichero anota lo que se le pidió al recorrido: la API que lee, los objetivos, las familias activas, el límite de fecha tal y como se escribió y los almacenes a los que escribe. Si algo de eso cambió entre la parada y la vuelta, la lista de repositorios que no hay que volver a recorrer significa otra cosa, y la ejecución se para y nombra el ajuste que cambió. Los almacenes están ahí porque un registro en este fichero significa que las filas llegaron a todos los destinos, y todos los destinos son los que tenía aquel proceso. Añade un almacén con el recorrido parado y la vuelta se salta todo lo que anotó la primera mitad, así que el almacén nuevo se queda con la cola del recorrido y con nada de antes. Apunta uno existente a otro sitio y se abre el mismo agujero en otro lugar. Solo se comparan los ajustes que puedes leer: la contraseña de un almacén nunca se escribe en este fichero. El límite es el que fallaría en silencio. Un recorrido limitado a un año anota sus repositorios como escritos; una vuelta sin límite se saltaría todos ellos y dejaría un almacén que parece completo y se corta hace un año, sin que nada en las filas lo diga. Los dos rechazos dejan el fichero donde está. Devuelve el ajuste a lo que era para seguir, o borra el fichero para recorrer otra vez desde el primer repositorio. Sustituir el binario no es uno de ellos. Eso es justo lo que paró el recorrido para el que se escribió todo esto, así que un punto de control escrito por otra versión se retoma, con una línea que dice qué versión escribió lo que el fichero nombra. ### Ver hasta dónde ha llegado ```sh ghchronicle -config config.yaml -backfill-status ``` Lee el punto de control y lo imprime: cuándo empezó el recorrido, cuánto hace que anotó algo por última vez, cuántas familias van completas de cuántas, en qué familia estaba y por dónde, y la orden que lo continúa, con el `-backfill-since` y el `-families` con los que empezó. La relectura en curso de una migración se imprime después, con lo que escribe y dónde. No pregunta nada a GitHub ni escribe nada, así que puede lanzarse con un recorrido en marcha, y no necesita token: la credencial está porque una pasada le pregunta a GitHub, y esto no le pregunta a nadie. Si no hay ningún recorrido a medias lo dice, y nombra el fichero que ha buscado. ### Volver solo a por lo que falta Una pasada puede acabar con familias pendientes y sin un solo error. Lo normal es un mal rato al otro lado: un `502` en dos repositorios de sesenta deja su familia sin marcar, y el recorrido termina ordenadamente con una familia de menos. ```sh ghchronicle -config config.yaml -backfill -backfill-retry 1h ``` espera una hora y vuelve a por lo que quede, y sigue haciéndolo hasta que no quede nada. Cuesta casi nada, porque al retomar solo recorre lo que el punto de control no tiene ya: dos repositorios de sesenta, no sesenta. Se para solo cuando **una pasada no anota nada nuevo**. Esa es toda la regla, y a propósito no es una lista de qué errores merecen reintento: un repositorio borrado falla igual cada hora, y una lista de códigos reintentables queda desfasada en cuanto GitHub responde algo que antes no respondía. Una pasada que no gana nada es una pasada cuyo obstáculo no lo despeja esperar. Hay además un tope de diez pasadas, como red de seguridad y no como ajuste. Sin la opción no espera ni vuelve, que es lo que hacía antes de que esto existiera. ## 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 ``` ## Solo algunas familias ```sh ghchronicle -config config.yaml -backfill -families discussions,outbound ``` recorre las familias nombradas y ninguna otra, en todos los almacenes configurados. Es lo que necesita un almacén que perdió el historial de una familia: borrado a mano, o enviado a través de un Telegraf cuyo almacén se despejó, sin recorrer cada otra familia que aún guarda. Los nombres son las familias que imprime `-groups`. Uno que no es una familia, y la opción sin `-backfill`, se rechazan con 2, como una línea de órdenes que no se entiende; una familia que la configuración apaga se rechaza con 1 antes de preguntar nada, ni a GitHub ni a los almacenes, incluida la comprobación de migraciones del arranque, porque el recorrido no leería nada de ella y acabaría completo. Su punto de control es el del relleno, y registra las familias que se le pidieron, así que un relleno de otras familias, o de todas, lo rechaza y nombra la familia que difiere, y `-backfill-status` imprime la línea para retomarlo con el mismo `-families`. ### La relectura de una migración Una migración que despejó un almacén vuelve a leer su historial con un relleno propio, la relectura: las familias que escriben lo despejado, escribiendo solo eso en los almacenes de los que se despejó; ver [volver a leer el historial](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#volver-a-leer-el-historial). Guarda su punto de control junto al del relleno, con `-refill.json` en el nombre, así que un relleno en curso y una relectura ni se rechazan ni se pisan, y `-backfill-status` imprime los dos. ## 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 y forks estaban completos, porque la primera pasada los recorre hasta el final de todos modos, y también releases, despliegues y alertas, porque ninguno de esos repositorios pasaba de los cien de cada uno que lee una pasada. ## 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. - Cada ejecución de workflow que GitHub todavía conserve, expandida en sus jobs, y en sus pasos mientras GitHub los siga sirviendo. Medido el 24 de septiembre de 2026, GitHub listaba todos los jobs de una ejecución de hace 278 días, pero ningún paso de las ejecuciones creadas antes del 12 de abril, unos cinco meses y medio atrás. Un job así, si terminó en éxito, en fallo o por tiempo agotado, se escribe sin el campo `steps` en lugar de con un 0, ya que ejecutó al menos uno. - 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. Lo que sí sigue moviéndose en un repositorio archivado, sus estrellas, sus forks y sus watchers, una pasada lo lee sin relleno: el listado que ya paga dice qué repositorios están archivados, y cada pasada de `totals` pregunta por todos ellos, una consulta por cada veinticinco, por dos filas de cada uno. Una es `gh_repo_archived`, fechada cuando se archivó el repositorio. La otra es su `gh_repo_total`, sellada en la pasada como la de un repositorio recogido, y esa es la fila de la que se leen los totales de estrellas y forks de la cuenta. - Todas las pull requests e issues, en páginas de cincuenta, donde una pasada lee lo que cambió desde la pasada anterior, y una vez al día cada elemento abierto y lo que se movió desde el día anterior. - Todas las pull requests e issues que la cuenta abrió en repositorios ajenos y que ya se fusionaron o se cerraron, en páginas de cien ordenadas por cuándo se movió cada una por última vez, donde una pasada lee cada uno de los tres estados cerrados hasta una cadencia antes de la pasada anterior. Eso le basta a una pasada porque fusionar o cerrar sube el elemento arriba del todo, por mucho tiempo que haga que se abrió, y es una página salvo que en ese tiempo se hayan movido más de cien elementos; una pasada que leía una página y ni una más perdía el elemento que cien actualizaciones posteriores, un bot que bloquea hilos viejos o un reetiquetado, habían empujado a la segunda. Las que siguen abiertas no se acotan ni por página ni por fecha: cada pasada las lee todas, porque cada una recibe una fila por cada día que sigue abierta. En la 2.5.1 y anteriores cada una de las cinco búsquedas leía sus cien más recientes y paraba, igual en una pasada que en un relleno. GitHub sirve mil resultados de cualquier búsqueda y ni uno más, así que una cuenta que pase de mil en uno de esos estados conserva los mil que se movieron más recientemente. Eso se dice en el log, una vez por recuento, en lugar de dejar que se note en dos paneles que no cuadran: ```text level=WARN msg="outbound search read fewer items than it counts, GitHub serves a thousand at most" kind=pull_request state=merged count=2860 read=1000 ``` - Todas las páginas de artefactos, de actividad del repositorio y de análisis de code scanning, donde una pasada lee cinco, dos y una. - Cada release, despliegue y discusión, y cada alerta de Dependabot y de code scanning, donde una pasada lee la página más reciente de cada lista. - Cada comentario en issues y en discusiones que la cuenta dejó en cualquier sitio, y cada respuesta suya que se aceptó, donde una pasada lee los cien comentarios más recientes de cada tipo y las quinientas respuestas aceptadas más recientes. - Cada estrella que dio la cuenta, donde una pasada lee las quinientas más recientes. - Las pull requests en coautoría que hay detrás del progreso de Pair Extraordinaire, recorridas sobre toda la vida de la cuenta, donde una pasada suma los días desde la anterior y recorre la historia entera una vez por semana. - Todas las páginas de cada lista de stargazers, donde una pasada lee las cien estrellas más recientes de un repositorio cuya lista ya recorrió entera una vez. Hasta la 2.5.0 un primer recorrido que fallaba se anotaba como hecho, y un relleno leía una lista anotada por su primera y su última página, así que las estrellas de en medio no se leían nunca; ahora un relleno llega a ellas. - La bandeja entera, hilos leídos incluidos, donde una pasada lee los hilos no leídos que se movieron y, una vez al día, veinte páginas de cincuenta con los leídos. - Cada mes de facturación que GitHub todavía responda, hasta tres meses vacíos seguidos, donde una pasada lee dos. - Las cien entregas de webhook más recientes de cada hook, donde una pasada lee treinta. - Como mucho 500 logs de jobs fallidos por repositorio, ninguno de hace más de noventa días, si `joblogs` tiene cadencia; una pasada lee como mucho diez. Dos cosas que una pasada recuerda, un relleno las deja de lado. Un 403 o 404 que una pasada [recuerda durante un día](https://docs.jmrp.io/ghchronicle/es/api/#un-rechazo-también-se-recuerda) se vuelve a preguntar, porque un relleno no consulta esa memoria. Y lee el [fichero de caché](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado) que hay junto al de estado pero no escribe nada en él: las páginas que recorre son unas que ninguna pasada pide, y guardadas allí echarían las de las pasadas, así que un servicio arrancado después empezaría más frío de lo que se paró. ## 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](https://docs.jmrp.io/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, y con cincuenta commits.** Pedir cien pull requests con sus revisiones en una consulta responde un **502 en HTML** al cabo de unos diez segundos, y lo mismo pedir cincuenta commits de un repositorio activo con las comprobaciones que lleva cada uno: doce páginas de los recorridos de commits del propio autor se toparon con él, en seis repositorios. Ambos recorridos parten su página por la mitad y reintentan sobre el mismo cursor, en silencio: ninguno de los dos colectores lleva registrador, así que un relleno de un repositorio activo lo muestra solo como una familia más lenta, nunca como una línea. La página se parte mientras sea mayor que diez, así que cincuenta se vuelve a pedir con veinticinco, doce y seis. Una página con la que la pasarela aún se rinde en ese tamaño más pequeño, y un tiempo límite en cualquier recorrido sin página más pequeña que pedir, es un fallo: se escriben las filas leídas antes, la línea `collector failed` dice que la consulta es demasiado grande para una sola petición, y el repositorio no se anota en el punto de control, así que al reanudar se vuelve a recorrer. Hasta 2.6.3 el recorrido de commits tomaba el tiempo límite por el final del historial y anotaba el repositorio como recorrido. Solo esa respuesta parte la página: un `503`, o un `502` que vuelve antes de diez segundos, no es una consulta con la que a la pasarela se le acabó el tiempo, y [el cliente la vuelve a preguntar una vez más](https://docs.jmrp.io/ghchronicle/es/api/#una-respuesta-que-github-no-pudo-terminar-se-pregunta-una-vez-más) con el mismo tamaño. > **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**, y ninguno de hace más de > treinta días. 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 al acabar el periodo de retención del > repositorio**, noventa días por omisión, y responden 410 después. Un relleno > de `joblogs` solo pide los últimos noventa días, diga lo que diga el ajuste. > Las ejecuciones a las que pertenecen se guardaban cuatrocientos días o más; > desde el 1 de octubre de 2026 GitHub aplica el mismo ajuste de retención a > las propias ejecuciones: noventa días por omisión, como mucho noventa en un > repositorio público y hasta cuatrocientos en uno privado, así que un relleno > posterior a esa fecha solo llega tan atrás como ese ajuste. ## 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 de métricas de GitHub que recoge ghchronicle, qué le pide cada una a la API y por qué existe cada una. Source: https://docs.jmrp.io/ghchronicle/es/collectors/ Treinta y cuatro familias, noventa y cinco medidas. Esta página es para qué sirve cada familia; la [referencia de medidas](https://docs.jmrp.io/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, a partir de dos endpoints. El historial diario de estrellas de GitHub da las estrellas que ganó un repositorio cada día, hasta la semana en que se creó, a cualquiera que pueda ver el repositorio, así que se lee para cada repositorio que recoge la pasada y se escribe como `gh_star_day`: entero en la primera pasada, y después una petición por las treinta semanas más nuevas, un 304 gratis salvo que alguno de sus días ganara o perdiera una estrella o empezara una semana nueva. Las barras diarias de estrellas lo cuentan en todos los almacenes menos Prometheus, y en InfluxDB y PostgreSQL también la curva, así que toda la historia está ahí tras 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. GitHub Enterprise Server no sirve el historial, así que allí esos paneles quedan vacíos. La lista de stargazers dice quién. Pedida con el media type de estrellas devuelve un `starred_at` por usuario, que se escribe como `gh_star`, y se recorre entera una vez; después 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. Desde julio de 2026 GitHub sirve la lista solo a los administradores y colaboradores del repositorio, que la cuenta siempre es en sus propios repositorios y en los de una organización que administra; cualquier otro repositorio tiene sus estrellas contadas por día y a nadie nombrado (ver [lo que GitHub no da](https://docs.jmrp.io/ghchronicle/es/api/limits/)). **`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 la primera pasada de cada día UTC llega hasta la lectura del día anterior y lee además cada elemento abierto, sea cual sea su última actualización, 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. Las tres preguntan solo donde algo se movió. Una consulta GraphQL por cada veinticinco repositorios pregunta antes a cada uno cuándo se hizo el commit de la cabeza de su rama por defecto y cuándo se actualizaron su issue y su pull request más recientes, y una familia no pregunta nada a un repositorio donde nada se movió desde su propia ventana: en la cuenta donde se midió, cerca de la mitad de las consultas de `issues` y de `issueevents` y el 96 por ciento de las de `commits`. La lectura diaria de cada elemento abierto de `issues` y un backfill se leen en todas partes. [Coste de una pasada](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió) tiene la cuenta. **`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, las pull requests que abrió en repositorios ajenos, y esos repositorios, cuántas estrellas tiene cada uno y si es privado. Todo es GraphQL, un punto por página: la lista de estrellas dadas, cinco búsquedas de issues, los dos recorridos de comentarios y las respuestas aceptadas que escribió la cuenta, que se leen aparte de los comentarios para que no se pierda una respuesta aceptada semanas después de escribirse. Una pasada lee hasta cinco páginas de la lista de estrellas dadas, todas las páginas de las dos búsquedas de lo que sigue abierto, y las otras tres, que van ordenadas por lo que se movió último, hasta una cadencia antes de la pasada anterior: su primera página, salvo que en ese tiempo se hayan movido más de cien elementos. Un backfill lee también la lista de estrellas dadas y esas tres hasta el final. **`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. Los repositorios archivados que el filtro por omisión aparta también están, desde la consulta que fecha su archivado, así que sus estrellas y forks siguen en los totales de la cuenta. **`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 cada hora 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 los últimos trescientos eventos, ninguno de hace más de treinta días, 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 conserva las notificaciones de la bandeja tres meses salvo las guardadas, 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 que ghchronicle escribe a partir de la API de GitHub, sus etiquetas, sus campos y cómo se fecha cada una. Source: https://docs.jmrp.io/ghchronicle/es/collectors/measurements/ Noventa y cinco 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`. Las tres viajan juntas o no viaja ninguna, y `repo` es siempre el nombre corto, tanto en el repositorio de otra persona como en el tuyo: `owner=golang`, `repo=go`, `full_name=golang/go`. Eso es lo que permite que un filtro escrito para una medida responda en todas las demás, y es de donde se construye la variable de repositorio de los dashboards. El nombre corto no es una identidad, porque dos dueños pueden usar el mismo, así que una consulta que necesita identidad agrupa por `full_name` y una que necesita una persona agrupa por `owner`. Esto no siempre fue cierto en todas. Doce no llevaban `owner` y metían el nombre completo dentro de `repo`, y `gh_billing_usage` tenía un `repo` corto, ningún `owner` y un `org`. Las tres formas no se solapaban en absoluto, que es peor que solaparse mal: un filtro construido desde una de ellas no encontraba nada de nada en las otras, y una unión de dos contaba cada repositorio dos veces. Ahora son una sola forma. `org` se ha ido con ellas, y no se pierde nada: `organizationName` no es una propiedad del endpoint de facturación que esta herramienta llama, así que la etiqueta solo podía llevar `(none)`, y en la forma de organización de ese informe nombra a la organización cuyo informe se pidió, que es la dueña. `owner` lo lleva. **Actualizar desde una versión anterior a esta forma** es un cambio incompatible con lo ya almacenado, y es más grande que una costura. Lee los cuatro párrafos siguientes antes de actualizar una base de datos que quieras conservar. **Tus filas existentes no se convierten, y la mayoría se vuelven a escribir.** Las filas ya almacenadas conservan las etiquetas viejas y nada las reescribe, porque en InfluxDB el conjunto de etiquetas forma parte de la identidad de un punto. Lo que es fácil pasar por alto es que la mayoría de las doce se fechan en el momento del propio ítem y se vuelven a ofrecer en cada barrido, y el libro de escrituras se indexa por el conjunto de etiquetas. Cambia el conjunto y cada uno de esos puntos falla la búsqueda, así que **el primer barrido tras la actualización vuelve a escribir todo el histórico retenido, con las mismas marcas de tiempo, bajo las etiquetas nuevas, al lado de las filas viejas**. Eso no es una costura que puedas esperar a que pase. Una suma sobre un rango que cubra el histórico reescrito **se duplica de inmediato y sigue duplicada**, mientras GitHub siga sirviendo esos ítems. En la cuenta con la que se desarrolló esto eran un año entero de `gh_contribution_day_repo`, que un panel del producto suma, y varios cientos de filas de `gh_issue_comment`, `gh_external_contribution` y `gh_discussion_comment`. Solo `gh_event` y `gh_notification` se comportan como una costura, porque son ventanas que GitHub olvida. **Así que hay dos salidas honestas, y esperar no es una de ellas.** Recrear la base de datos, que es lo que hizo el autor; o borrar tú las filas de la forma vieja, lo que en InfluxDB 3 significa eliminar las trece tablas, ya que una etiqueta no se puede renombrar in situ y un predicado de borrado no puede nombrar una etiqueta que las filas nuevas no llevan. No hay migración ni la habrá: renombrar una etiqueta significa reescribir cada fila afectada bajo una identidad nueva, que es una restauración y no una actualización. **El destino PostgreSQL deja de cargar hasta que se cambien sus tablas.** Emite `CREATE TABLE IF NOT EXISTS`, que no hace nada contra una tabla creada por una versión anterior, así que el `INSERT` que viene detrás nombra columnas `owner` y `full_name` que no existen y un `ON CONFLICT` con una clave que tampoco existe, y psql lo rechaza en las trece medidas. Elimina esas tablas y deja que el siguiente fichero las recree, o añade las dos columnas y reconstruye cada clave primaria: ```sql ALTER TABLE gh_event ADD COLUMN IF NOT EXISTS "owner" TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS "full_name" TEXT NOT NULL DEFAULT ''; ALTER TABLE gh_event DROP CONSTRAINT IF EXISTS gh_event_pkey; ALTER TABLE gh_event ADD PRIMARY KEY ("time", "action", "full_name", "owner", "ref_type", "repo", "type"); ``` **La clave reconstruida tiene que ser exactamente la que declararía esta herramienta** para esa medida: `time` seguido de las columnas de etiqueta de la tabla de arriba, por orden de nombre. No la armes con las columnas que tu tabla tenga. Un `gh_billing_usage` actualizado aún lleva una columna `org` de la forma vieja que ya no escribe nadie y que no forma parte de la clave; si la incluyes, el `ON CONFLICT` que emite el destino no coincide con ninguna restricción única y la carga vuelve a fallar con un mensaje que no apunta a nada. Para comprobar tu trabajo, mira el `CREATE TABLE` que el destino escribe para esa medida en un fichero nuevo: su lista de `PRIMARY KEY` es la respuesta. Añadir las columnas conserva las filas viejas pero no las convierte: llevan la cadena vacía donde las nuevas llevan un dueño, así que cuentan doble igual que en InfluxDB. Eliminar es la opción limpia en los dos almacenes. **Las consultas que hayas escrito tú hay que actualizarlas, y solo algunas fallan en voz alta.** `gh_billing_usage.org` ya no existe, y en InfluxDB 3 nombrar una columna que ninguna fila ha escrito falla al planificar, así que esa te avisa. Un filtro como `gh_event WHERE repo = 'owner/name'` sigue siendo válido y devuelve nada en silencio; pasa a ser `full_name = 'owner/name'`. 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. Toda medida que la lleva se enlaza ítem a ítem desde al menos una tabla, con dos clases de excepción. Las filas de algunas solo se cuentan o se suman, en una curva, una barra, un stat o una línea de tabla por repositorio, revisor, job o check, donde una url por ítem no tiene fila en la que sentarse: `gh_pull_request_review`, `gh_workflow_job`, `gh_event`, `gh_issue_event`, `gh_artifact`, `gh_contribution_day`, `gh_contribution_day_repo`, `gh_commit_check`, `gh_issue_comment`, `gh_billing_usage`, `gh_dependabot_alert`, `gh_code_scanning_alert`, y las de toda la cuenta `gh_account`, `gh_account_total`, `gh_contributions_total` y `gh_sponsors_listing`. Y dos llevan una url que ninguna tabla enlaza: `gh_release_published`, que no lee ningún panel, porque la tabla de releases enlaza cada release desde `gh_release`, y `gh_upstream_repo`, que _Work elsewhere_ une para su columna Stars mientras cada una de sus filas enlaza el ítem y no el repositorio. 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 valor de reserva 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`. `gh_discussion_comment.is_answer`, que 2.6.1 dejó de escribir, no necesitó nombre nuevo: es el campo `answers` que ya traía cada fila de esa medida, 1 para la respuesta aceptada y 0 para cualquier otro comentario. `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. **Un almacén escrito antes de 2.6.1 y después guarda `gh_discussion_comment` con dos formas.** Un comentario que una versión anterior leyó antes de que lo aceptaran y otra vez después son dos filas en el mismo instante, una por cada valor de `is_answer`. Un comentario que leyó una versión anterior gana una fila más sin esa etiqueta cuando una pasada de 2.6.1 lo vuelve a leer: la segunda para la mayoría, la tercera para uno que ya tenía dos. Las dos tablas del dashboard sobre esta medida leen una fila por comentario en todos los almacenes, aceptado si cualquiera de sus filas lo dice, así que una respuesta aceptada antes de 2.6.1 y retirada después sigue apareciendo como aceptada. Borrar la medida y lanzar un `-backfill` deja una fila por comentario: la tabla en InfluxDB 3 y en PostgreSQL, las rutas `github.discussion_comment` en Graphite, el índice `ghchronicle-gh_discussion_comment` en Elasticsearch. El destino PostgreSQL que se conecta sigue escribiendo en una tabla creada por una versión anterior, resolviendo el conflicto sobre la clave de esa misma tabla, y sus filas nuevas llevan la cadena vacía en `is_answer`. El fichero SQL no puede conocer esa clave, así que, reproducido contra una tabla así, sus sentencias de `gh_discussion_comment` se rechazan mientras el resto del fichero se carga, hasta que se borre la tabla. El exportador de Prometheus no conserva nada entre reinicios y cuenta cada comentario una sola vez. `-migrate` dice cuáles de los almacenes configurados guardan las dos formas, bajo `2.6.1/gh_discussion_comment/is_answer`: ver [Migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones). ### 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](https://docs.jmrp.io/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 cinco, y cada enlace cae en la tabla en la que está. [`gh_account`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_account_total`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_achievement`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_achievement_progress`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_actions_cache`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_actions_cache_entry`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_actions_policy`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_artifact`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_artifact_total`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_billing_usage`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#coste) · [`gh_branch`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_branch_protection`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_code_scanning_alert`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_code_scanning_alert_item`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_code_scanning_analysis`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_code_scanning_setup`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_collector_family`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_commit`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_commit_check`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_commit_punchcard`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_commits_week`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_contribution_day`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_contribution_day_repo`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_contribution_repo`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_contribution_year`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_contributions_total`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_dependabot_alert`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_dependabot_alert_item`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_dependabot_ecosystem`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_dependency`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_dependency_change`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_dependency_license`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_deploy_key`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_deployment`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_discussion`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_discussion_comment`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_environment`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_event`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#actividad) · [`gh_external_contribution`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_fork`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#estrellas-y-forks) · [`gh_gist`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_issue`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_issue_comment`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_issue_event`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_job_log`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#logs-de-jobs) · [`gh_key`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_label`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_milestone`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_notification`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#actividad) · [`gh_package`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_package_version`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_pinned_item`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_policy_file`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_profile_flag`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_pull_request`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_pull_request_review`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_rate_limit`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_release`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_release_asset`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_release_published`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_repo`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_repo_activity`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_repo_archived`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_repo_community`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_repo_created`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_repo_language`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_repo_policy`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_repo_topic`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#repositorios) · [`gh_repo_total`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_review_thread`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_ruleset`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_ruleset_rule`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_ruleset_version`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_secret`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_security_feature`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_security_setting`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#seguridad) · [`gh_social_account`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_sponsors_listing`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_sponsors_tier`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_sponsorship`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_star`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#estrellas-y-forks) · [`gh_star_day`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#estrellas-y-forks) · [`gh_star_given`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#estrellas-y-forks) · [`gh_star_list`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cuenta) · [`gh_traffic`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#audiencia) · [`gh_traffic_path`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#audiencia) · [`gh_traffic_referrer`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#audiencia) · [`gh_upstream_repo`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#desarrollo) · [`gh_webhook`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_webhook_delivery`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) · [`gh_workflow`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_workflow_job`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_workflow_run`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_workflow_run_total`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#integración-continua) · [`gh_workflow_step`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#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_day` | fechado, un punto por día: el día del Pacífico, a las 00:00 UTC | | `stars` | | `gh_star_given` | fechado | `user`, `language` | `stars`, `repo_stars`, `url` | | `gh_fork` | fechado, cuando se creó el fork | `by` | `forks`, `stars`, `seconds_to_push`, `advanced`, `url` | `gh_star_day` son las estrellas que ganó un repositorio cada día, leídas del [historial de estrellas](https://docs.github.com/es/rest/activity/starring#get-repository-star-history) de GitHub para cada repositorio que recoge una pasada, vea lo que vea el token de sus stargazers. No nombra a nadie, así que no lleva etiqueta propia ni `url`: `gh_star` es la fila por estrella, con quién la dio y el segundo, y solo existe donde el token puede leer la lista de stargazers, lo que desde julio de 2026 significa los administradores y colaboradores del repositorio. Ningún panel lee las dos, así que un repositorio que tiene ambas se cuenta una vez, y los paneles que cuentan estrellas a partir de filas leen `gh_star_day`, así que uno cuya lista está oculta se cuenta igual. Un día es el día natural del propio GitHub en America/Los_Angeles, y su fila se sella a las 00:00 UTC de esa fecha, como una fila de `gh_contribution_day` se sella a las 00:00 UTC de la suya. Medido contra las listas de stargazers de diecinueve repositorios, 440 estrellas, el día del Pacífico acertó en todas, y el día UTC equivocaba 38 de los 127 días de un repositorio. Así que una estrella dada una mañana europea puede quedar un día antes de la fecha que le da `gh_star`. Cada día se ancla a la semana que devuelve la API, nunca al reloj, no se escribe ningún día posterior a la pasada, y hoy puede escribirse como 0 antes de que haya empezado en la hora del Pacífico, para que lo corrija la pasada siguiente. La cuenta son los stargazers actuales del repositorio, cada uno en el día en que marcó la estrella, así que quitar una estrella la saca del día en que se dio, no del día en que se retiró. Una pasada lee las treinta semanas más nuevas, una página, y escribe todos sus días, ceros incluidos, así que un día que baja de una a ninguna se reescribe la vez siguiente. Las páginas más antiguas se leen la primera vez que se ve un repositorio, una vez tras actualizar a 2.5.0 y en un backfill; el fichero de estado registra una lectura entera como `history_read`, y solo tras un recorrido que llegó al final del historial, así que un recorrido cortado, por un error o por una página posterior que responde 403 o 404, se vuelve a leer entero en la pasada siguiente. Esas páginas escriben solo los días que tienen estrellas: filas a cero hasta la creación de cada repositorio multiplicarían los ficheros que abre InfluxDB 3, y por eso un día de hace más de treinta semanas que perdió su única estrella la conserva, igual que `gh_star` conserva cada estrella que vio alguna vez. La suma suele quedar en `gh_repo.stars` o un poco por debajo, ya que ese número también cuenta cuentas que GitHub ya no lista: una menos en tres de esos diecinueve repositorios. Una estrella dada hace más de treinta semanas y retirada después de leer el historial puede dejarla por encima: solo un backfill que llegue hasta su día rebaja ese día, y solo mientras el día conserve otra estrella, así que si era la única estrella de su día se queda para siempre. Prometheus y un backend OTLP con `raw: false` no la ven nunca: quitar una estrella baja un día pasado, cosa que ningún contador puede hacer, y `github_repo_stars` ya lleva el número actual de cada repositorio. GitHub Enterprise Server no sirve el historial de estrellas: su API REST, comprobada en 3.21 y 3.22, tiene la lista de stargazers y no `stargazers/history`. Allí cada repositorio lo responde con un 404, que se lee como nada que recoger, así que `gh_star_day` queda vacía, y con ella los paneles que cuentan estrellas a partir de ella: Stars gained over time y Stars over time en InfluxDB y en los almacenes SQL, Stars gained over time en Graphite y Elasticsearch, y Recent stars en Graphite. Recent stars sigue listando nombres de `gh_star` en los demás almacenes, y Prometheus sigue contando a partir de ella. Un historial que respondió 404 no se registra como leído, así que un servidor que empiece a servirlo tiene el historial de cada repositorio leído entero en la pasada siguiente. `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, y `seconds_to_push` dice cuánto después del fork llegó su último empujón. Es negativo cuando GitHub informa de un empujón anterior al propio fork. Cuánto lleva un fork parado no se guarda, porque la fila está fechada cuando se creó el fork: es esa fecha restada de ahora, menos `seconds_to_push`, y lo calcula la consulta. Guardado, era un día más cada día escrito sobre una fila fechada años antes, así que decía "cuándo corrió la pasada" y no algo del fork, y cada reescritura costaba un fichero en la partición del propio fork. ## 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` | | `gh_release_published` | fechado, al publicarse | `tag` | `published`, `prerelease`, `url` | `open_issues` es el campo de GitHub y GitHub cuenta las pull requests dentro. Usa `gh_issue` para contar issues. `gh_release_published` es cuándo se publicó cada release, al segundo, y es la medida por la que agrupar un calendario de releases o de la que leer "la última release estable", la fila más nueva con `prerelease` a false. `gh_release` sigue sellada en la pasada, porque sus descargas se mueven, y su `age_days` son días enteros truncados contados hacia atrás desde la pasada: la fecha que reconstruye llega un día tarde para toda release publicada más tarde dentro del día que la hora de la pasada. `published` vale el entero 1 en todas las filas, así que contar releases es una suma. `prerelease` es aquí un campo booleano, donde en `gh_release` es una etiqueta: una pre-release se promueve desmarcando la casilla en la release ya publicada, y como etiqueta una promoción que conserve la fecha de la publicación escribiría una segunda fila en el mismo instante, que la suma contaría dos veces. Una release que vuelve a borrador y se publica de nuevo escribe una segunda fila si GitHub le da un `published_at` nuevo, así que la cuenta exacta son los valores distintos de `tag` de cada repositorio. Un borrador no tiene publicación, así que no escribe fila aquí, y su fila de `gh_release` no lleva `age_days`: GitHub envía un borrador con `published_at` a null, y la edad medida desde la nada valía 106751 días en cada fila de borrador. La primera pasada tras actualizar fecha la página de releases que lee; `-backfill` fecha el resto. 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, junto a su fila de toda la vida, en una consulta GraphQL por cada veinticinco repositorios, en cada pasada de `totals`: un punto por cada veinticinco 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`, `number`, `kind`, `state` | `contributions`, `merged`, `private`, `additions`, `deletions`, `changed_files`, `title`, `comments`, `seconds_to_merge`, `seconds_open`, `url` | | `gh_upstream_repo` | ahora | | `stars`, `forks`, `private`, `language`, `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 diaria de cada elemento abierto, en vez de cada hora, por mucho que haga que se movió por última vez; 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. `gh_external_contribution` es el trabajo que la cuenta hizo en repositorios que no son suyos, sacado de cinco búsquedas, una por `kind` y `state`: pull requests `merged`, `open` y `closed` sin fusionar, e issues `open` y `closed`. Un elemento que sigue abierto se reescribe al principio de cada día que sigue abierto, así que una pasada lee enteros los dos estados abiertos. Un elemento cerrado se escribe una vez, fechado cuando se cerró, así que los tres estados cerrados se leen por cuándo se movió cada elemento por última vez: una pasada lee cada uno hasta una cadencia antes de la pasada anterior, lo que contiene lo que se fusionó o se cerró desde entonces por mucho tiempo que haga que se abrió y por muchos otros elementos que se movieran entre medias, y es una página de cien salvo que se movieran más de cien. Un relleno sigue leyendo hasta que se acaban las páginas o llegan a `backfill.since`. GitHub sirve mil resultados de cualquier búsqueda y ni uno más, así que una cuenta que pase de mil en un estado tiene los mil que se movieron más recientemente, y el log lo dice como aviso. `gh_account_total.pulls_merged_elsewhere` e `issues_elsewhere` son los recuentos del propio GitHub y aciertan pase lo que pase con ese tope. Un elemento que estuvo abierto y luego se cerró conserva sus filas `open` junto a la cerrada, porque `state` es una etiqueta. `private` dice si el repositorio al que fue el elemento es privado. Las búsquedas se hacen con el token de la cuenta, así que los repositorios privados de una organización vuelven junto a los públicos, y este campo es lo que permite a una página que ven otros dejarlos fuera; es un campo y no una etiqueta, así que no dio una segunda identidad a las filas ya guardadas. Una pull request lleva `additions`, `deletions` y `changed_files`, los nombres que usa `gh_pull_request`, y un issue no lleva ninguno de los tres. Todo viene en los nodos que las búsquedas ya devuelven, y medido el 2026-09-26 la consulta cuesta un punto por página con ello igual que sin ello. `gh_upstream_repo` es el lado del repositorio de esas mismas búsquedas: una fila por repositorio al que llegaron en la pasada, fechada en la pasada, con sus `stars` y sus `forks` como enteros, `private` como booleano, su `language` principal, ausente cuando GitHub no detecta ninguno, y su página. El recuento de estrellas no está en `gh_external_contribution` porque esa fila está fechada cuando se cerró el elemento, y un recuento que se mueve casi cada día reescribiría una fila del pasado cada vez. En InfluxDB y en PostgreSQL la tabla "Work elsewhere" une a cada elemento la más reciente de estas filas dentro del rango, como Stars, y en Prometheus su Stars es el recuento que guarda el exportador; Graphite y Elasticsearch no pueden unir una medida con otra, y sus tablas no tienen columna Stars. Un repositorio tiene fila siempre que una búsqueda lee un elemento suyo: cada estado abierto se lee entero en cada pasada, y cada estado cerrado desde su página más reciente, así que un repositorio cuyos elementos están todos cerrados y más atrás que esa página conserva la fila de la última pasada que leyó uno de ellos, o del último relleno. ## 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`, `live_count`, `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ó, y guarda esa memoria con la caché de ETag en [el fichero junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), así que un reinicio solo pregunta por ejecuciones e intentos nuevos, como habría hecho la pasada anterior; 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 queda recordada solo cuando todos los almacenes aceptaron la pasada que listó sus jobs: una pasada que un almacén rechazó olvida sus ejecuciones, y la siguiente las vuelve a listar y a escribir. El fichero guarda una ejecución mientras el listado la siga devolviendo, y la olvida con el mismo horizonte que una respuesta que ya nadie pide. Un arranque que no encuentra un registro de escrituras que diga lo que tienen los almacenes, o que encuentra un almacén añadido desde entonces, no recupera ninguna y vuelve a listar sus jobs. Los jobs de una ejecución duran más que sus pasos. Medido el 24 de septiembre de 2026, GitHub listaba todos los jobs de una ejecución de hace 278 días, con sus tiempos y su runner, y devolvía una lista de pasos vacía para toda ejecución creada antes del 12 de abril, unos cinco meses y medio atrás. Un job que terminó en éxito, en fallo o por tiempo agotado ejecutó al menos un paso, así que cuando su lista llega vacía se escribe sin el campo `steps`, en lugar de con un 0 que diría que no tuvo ninguno, y sin filas de `gh_workflow_step`: ese cero arrastraría hacia nada toda media de `steps` hasta donde llegó un relleno. Todo otro job conserva `steps` como la longitud de su lista, porque su cero puede ser cierto: un job omitido no ejecuta ningún paso (medido), y uno cancelado puede pararse antes del primero. `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. `gh_artifact_total` lleva tres cuentas porque su tamaño no está sobre ninguna de las evidentes. `count` es el total del propio GitHub para el repositorio y cuenta los artefactos que GitHub ya ha caducado: medido en jmrplens/jmrp.io el 2026-09-17, la página 40 del listado eran caducados hasta la última fila, frente a un total declarado de 29.405. `walked` es hasta dónde dejó llegar el tope de cinco páginas. `live_bytes` es el tamaño de los artefactos que GitHub todavía guarda entre los recorridos, y `live_count` cuántos son, que es la cuenta sobre la que está ese tamaño. Cuando `walked` es menor que `count`, las cifras vivas son un suelo y no un total, que en ese repositorio se quedaba corto por un factor de cincuenta y seis. Un recorrido que una página fallida dejó a medias escribe la fila igualmente, con las páginas anteriores, y su `walked` se queda por debajo de `count` del mismo modo; solo una primera página fallida no escribe ninguna, porque entonces no hay cuenta de la que quedarse corto. `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 `cache` es la clave sin su hash de contenido, porque la clave entera es una serie por build. Una fila de `gh_actions_cache_entry` es una caché en una ref para el día, con sus entradas sumadas en ella: `caches` es cuántas hay, `size_bytes` su total, `days_since_use` y `key` los de la usada más recientemente, y `age_days` el de la más antigua. Hasta la 2.5.2 era una fila por entrada, y todas las entradas de una caché en una ref tenían las mismas etiquetas y el mismo día, así que el almacén se quedaba con la última que se escribía: el 2026-09-26 las quince cachés de CodeQL en main de jmrplens/jmrplens, 57,9 MB entre todas, quedaron guardadas como una de 3,8 MB. Ninguna API lista las cachés de un día pasado, así que esos días se quedan como están, y `-migrate` solo los anota, bajo `2.6.0/gh_actions_cache_entry/sum`. El listado se lee en páginas de cien entradas, hasta diez, donde antes se paraba en la primera: el 2026-09-27 dos repositorios de la cuenta tenían 118 y 232 entradas. Se lee de la creada más recientemente a la más antigua, un orden que un acierto de caché no cambia. Una pasada que lee menos entradas de las que el listado decía tener, porque una se borró entre dos de sus páginas, no escribe ninguna fila de `gh_actions_cache_entry`, y tampoco una a la que le falló una página posterior: sumada, una parte de una caché se escribiría sobre la caché entera que guardaron las pasadas anteriores del día, y las filas las escribe la siguiente pasada del día. El total de `gh_actions_cache`, leído antes que el listado, se escribe en los dos casos. ## 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`, `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`, `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. Una compilación anterior a la 1.0.0 etiquetaba los dos ítems con `state`, y el de code scanning también con `reason`, así que un almacén que escribió guarda esas filas junto a las de hoy: `-migrate` las encuentra, bajo `1.0.0/gh_dependabot_alert_item/state` y `1.0.0/gh_code_scanning_alert_item/state`, en los almacenes a los que se les puede preguntar. 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í contaba como abierta para siempre. Una alerta todavía abierta no lleva ningún campo con cuánto lleva abierta, y es deliberado. La fila está fechada cuando se abrió la alerta, así que la respuesta es ahora menos la marca de tiempo de la propia fila y la calcula el panel cuando se le pregunta. Escrita por el colector, como `seconds_open`, solo era cierta en el instante de la pasada que la escribió y se movía en cada pasada: quien consultara la semana pasada obtenía lo que decidió la última pasada, y cada reescritura archivaba otro fichero parquet en la partición de la fecha original de la alerta. Medido el 2026-09-17, las dos familias de alertas escribían unos 234 ficheros al día entre las dos para 1.700 filas, hubiera o no algo nuevo que contar en GitHub. `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. `open_alerts`, y `open` en `gh_dependabot_alert` y `gh_code_scanning_alert`, cuentan todas las alertas del repositorio que siguen abiertas, por antiguas que sean. Una pasada lee la página más reciente de cada lista, cien alertas en cualquier estado, y cuando esa página vuelve llena la lista se lee otra vez con `state=open`, hasta el final, y las cuentas salen de ahí. Una alerta que sigue abierta detrás de cien más recientes ya corregidas se cuenta; hasta la 2.5.2 no se contaba. `alerts` no es el mismo número en las dos funciones. En code scanning es el total del repositorio: cuando la página vuelve llena es la última página que GitHub declara para una página de una sola alerta, que en un repositorio dio 1.393 donde la página decía 100. La lista de Dependabot pagina por cursor y no declara última página, así que ahí `alerts` es lo que leyó la pasada: 100 en una pasada quiere decir cien o más, y un backfill que recorre la lista entera escribe el total. Las dos medidas de ítem son las alertas que leyó el recorrido en ambos casos, y quedan completas tras un backfill. ## 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 | `kind` (commits, issues, pulls, reviews) | `contributions`, `commits`, `days`, `commits_dated`, `url` | | `gh_contribution_day_repo` | fechado, el día al que pertenecen los commits | `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` | `versions`, `tagged_versions`, `age_days`, `days_since_update`, `url` | | `gh_package_version` | fechado, al publicarse | `package`, `type`, `visibility`, `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 | | `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 | `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 | `own`, `is_reply`, `author`, `comment`, `number` | `comments`, `answers`, `upvotes`, `title`, `reply_to`, `private`, `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 | `own`, `number` | `comments`, `private`, `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. Un paquete que GitHub no asocia a ningún repositorio escribe `(none)` en las tres etiquetas que nombran uno, en vez de omitirlas y acabar en una serie sin columna de repositorio que una consulta pueda nombrar. `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. Un gist fijado no está en ningún repositorio: GitHub lo nombra por su hash, así que `repo` es ese hash y `owner` es la cuenta, que es el único dueño que puede tener un elemento fijado. El campo `kind` dice cuál de los dos es la fila. `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.families.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`, cada hora 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`](https://docs.jmrp.io/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. Sobre la vida entera de una cuenta son unas pocas docenas de puntos y 24 MB (35 consultas y 94 segundos sobre 2.315 pull requests, medido el 2026-09-27), así que se hace una vez y luego se guarda: el fichero de estado tiene el recuento con el último día UTC que cubre, y cada pasada recorre solo las pull requests fusionadas desde entonces, una página por pasada: de 468 a 513 KB en esa cuenta en cada pasada horaria que registró el proxy de producción el 2026-09-27, un tamaño que crece a lo largo del día con lo que se fusiona. En el día en que corre una pasada todavía se fusiona, así que sus pull requests están en la fila de ese día y la pasada siguiente las vuelve a recorrer. El historial entero se vuelve a recorrer una vez por semana, porque el recuento puede bajar (un repositorio que pasa a privado o se borra saca sus pull requests de `is:public`), y siempre que haya cambiado la regla con la que se guardó el recuento. Una pasada a la que la API no responde escribe las insignias y ninguna fila de progreso, y dice qué lectura falló con un aviso que lleva el error: `achievement counts unavailable, no progress rows this pass` o `co-authored pull requests unavailable, no progress rows this pass`. Cuesta esa pasada y nada más. Las filas son las del día, así que las escribe la pasada de una hora después, y un recorrido que se queda a medias deja el recuento guardado como estaba, para que la pasada siguiente vuelva a recorrer sus días. Un recuento que el recorrido no pudo cerrar entero se escribe como un mínimo, y se dice una vez por proceso mientras sus números no cambien: ```text level=WARN msg="co-authored pull request count is a floor" capped=false truncated=3 ``` `capped` es un día que por sí solo tenía más de los mil resultados que pagina una búsqueda, y `truncated` cuántas pull requests tenían más commits de los que caben en una página de cien, ningún trailer en los leídos y el resto de sus commits sin leer porque falló la consulta que los pedía, que es lo más que puede quedarse corto el recuento. Los dos se guardan en el fichero de estado con el recuento, así que una pasada que suma a un mínimo sigue diciendo que lo es. Una pull request con más de cien commits y ningún trailer en los cien primeros se sigue leyendo, cien commits cada vez y diez pull requests por consulta, un punto cada una, hasta que aparece un trailer o se acaban los commits. Hasta la 2.6.0 se quedaba ahí y se contaba como un mínimo: en la cuenta medida, tres pull requests de 143, 144 y 248 commits hacían que cada pasada avisara de `truncated=3`, y leer el resto de sus commits el 2026-09-28, dos consultas y 121 KB, no encontró ningún trailer en ninguna, así que el recuento de 33 había sido exacto desde el principio. Esas consultas las paga el recorrido entero semanal, y también cada pasada que recorre el día en que se fusionó una pull request así. `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 cada hora, 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. Después se leen aparte las respuestas aceptadas de la cuenta, las quinientas más recientes en una pasada y todas en un relleno, así que un comentario aceptado como respuesta cuando ya había salido de esos cien se escribe igualmente con `answers` a 1 en la siguiente pasada. Un comentario que devuelven las dos lecturas se escribe una sola vez. Las dos medidas llevan `private`, si el repositorio en el que se dejó el comentario es privado, por la misma razón que `gh_external_contribution`: `own` no lo dice, porque los repositorios propios de la cuenta pueden ser de las dos clases y los de una organización no son propios. `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: diez son una consulta GraphQL con un alias de búsqueda para cada uno, y `commits`, que la búsqueda de GraphQL no sabe contar, es una búsqueda REST propia. Así que cada número es una fila y es correcto en la primera pasada de una instalación recién hecha. Los tres campos que acaban en `_elsewhere` comparten un mismo sentido de fuera: en un repositorio que no es de la cuenta, porque el calificador de búsqueda `-user:LOGIN` deja fuera los que sí lo son, así que los repositorios de una organización cuentan como fuera aunque la cuenta pertenezca a ella. `pulls_merged_elsewhere` son sus pull requests fusionadas allí e `issues_elsewhere` las issues que abrió allí. `commented_elsewhere` son las issues y pull requests de allí que llevan al menos un comentario suyo (`commenter:LOGIN -user:LOGIN`): hilos, no comentarios, cada uno contado una vez por muchos que dejara. Uno que abrió solo cuenta si además comentó, porque el mensaje inicial no es un comentario: medido el 2026-09-26, 33 de los 102 hilos que la cuenta había abierto fuera. En la 2.5.1 y anteriores era `commenter:LOGIN -author:LOGIN`, que dejaba fuera los hilos que abrió la cuenta y contaba todos los de sus propios repositorios: 125 donde la definición de arriba da 55 en la cuenta con la que se leyó esto, 103 de ellos en casa y 54 de esos pull requests de Dependabot. Un almacén con filas de antes de actualizar ve la serie bajar esa diferencia en la primera pasada después. `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`, `action`, `ref_type` | `events`, `public`, `commits`, `url` | | `gh_notification` | fechado, última actualización | `reason`, `private`, `subject_type` | `is_unread`, `notifications`, `title`, `url` | Ambas son ventanas, no historias. GitHub guarda los últimos trescientos eventos de los últimos treinta días, y descarta las notificaciones a los tres meses salvo las guardadas. 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` | | `gh_collector_family` | ahora | `family`, `scope` (family, repo), `reason` | `repos`, `failed`, `points`, `error` | 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. `gh_repo_total` es de donde se leen las estrellas y los forks de un repositorio archivado, en el Overview y en _Every repository, ever_, y `gh_repo` no lo es. El que el filtro por omisión aparta por estar archivado no recibe fila de `gh_repo` de ninguna pasada, porque ninguna familia lo recorre, y solo tiene la que escribió un relleno histórico, pero sigue recibiendo y perdiendo estrellas y forks, así que la familia `totals` escribe su `gh_repo_total` en cada pasada desde la consulta que fecha su archivado: las mismas etiquetas y campos que la fila de un repositorio recogido, `archived` a true, sellada en la pasada. Hasta la 2.5.1 solo un relleno histórico la escribía, una vez, y el 2026-09-26 una de esas filas decía 4 estrellas donde GitHub decía 3. Las estrellas y los forks de un repositorio vivo en el Overview siguen saliendo de `gh_repo`, la fila que leen también la tabla de la sección Inventory y la tarjeta. No recibe `gh_repo_policy`. Esa consulta pregunta por veinticinco repositorios cada vez: medido ese mismo día, la pasarela respondió a la fila de toda la vida de cincuenta repositorios archivados una vez en 9,2 segundos y la rechazó dos veces pasados unos once, y respondió a veinticinco en menos de siete. 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` y `gh_collector_family` son lo que el colector mide de sí mismo. La primera es lo que le queda por gastar: GitHub lleva quince presupuestos independientes, y sin ella una familia saltada por falta de presupuesto es idéntica a una familia sin nada que contar. La segunda es lo que cada barrido consiguió hacer. Una fila por familia ejecutada, siempre, con cuántos repositorios se le preguntaron (`repos`), cuántos de ellos no pudo recoger (`failed`) y cuántas filas produjo (`points`). En `commits`, `issueevents` e `issues`, `repos` cuenta también los repositorios en los que la [consulta de movimiento](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió) no encontró nada nuevo, que la familia dejó sin leer y por los que no escribió ninguna fila. Y una fila más por cada repositorio que perdió, nombrándolo como lo nombra cualquier otra medida y con `reason`, una palabra acotada de qué la detuvo (el estado HTTP, `rate limited`, `query too large`, `canceled`), con el mensaje entero en el campo `error`. `scope` es lo que distingue las dos: `family` para la primera clase, cuyas etiquetas de repositorio llevan `(none)`, y `repo` para la segunda. `error` lleva `(none)` también cuando no hay mensaje, y eso no es adorno: el protocolo de línea descarta un campo de texto vacío, así que una columna escrita solo al fallar no existiría hasta que fallara algo, y una consulta que la nombrara se rechazaría en vez de responder sin filas. Las filas que siempre llegan son el motivo de todo esto. Una familia sin ninguna fila en un barrido no se ejecutó en ese barrido, que es justo lo que un panel vacío nunca podría decir, y son además lo que hace que la medida exista en una cuenta donde nunca ha fallado nada: una tabla en la que InfluxDB no ha escrito nunca no se dibuja vacía, se rechaza. Un valor de `family` no es una familia. `discover` es el listado de repositorios, que no se configura ni se puede apagar, y está aquí porque toda familia depende de él: un barrido que no puede listar los repositorios no ejecuta ninguna, y sin esto la página mostraría dieciséis familias que nunca corrieron y ningún motivo para nada de ello. Escribe fila solo cuando falló, porque un listado que funcionó ya lo dicen todas las demás filas del mismo barrido. Esto existe por un fallo medido. El 2026-09-16 `gh_workflow_run` y `gh_workflow_job` no tenían absolutamente nada de los cinco repositorios más activos de esta cuenta, cada uno porque una llamada a `/repos//actions/runs//jobs` había respondido `502` una vez y el runner tiraba todo lo que esa familia ya había recogido de ese repositorio. La fila Continuous integration del dashboard se calculaba sobre una cuenta a la que le faltaban sus cinco repositorios más activos, la fila Cost de la misma página decía que uno de ellos había quemado 27,6 K minutos de macOS, y el único registro de la causa era una línea en un diario. Ahora el colector conserva lo que reunió antes de un fallo, y escribe qué falló. `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.families.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](https://docs.jmrp.io/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 borra los logs pasado el periodo de retención del repositorio, noventa días por omisión, y responde 410 cuando ya no están; un relleno solo pide los últimos noventa días, diga lo que diga el ajuste, y como mucho 500 jobs fallidos por repositorio. `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` | `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. No hay etiqueta `org`. El único endpoint de facturación que puede leer una cuenta personal es el suyo, y ese informe no tiene `organizationName`: el campo es del informe de organización, que necesita una organización a la que preguntar. Comprobado contra la descripción OpenAPI publicada y contra el endpoint en vivo, donde ninguno de 487 elementos de uso traía la clave. Escrita igualmente era `(none)` en todas las filas recogidas, que es una columna y una entrada de leyenda que solo dicen que ahí no hay nada. `repo` es `(none)` en un cargo que no pertenece a ningún repositorio, que es lo que es un asiento de Copilot. Eso es una fila real de la factura y no un repositorio, así que la tabla de coste por repositorio lo deja fuera; los totales de gasto de arriba sí lo incluyen. `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, `pull_request`, `pull_requests`, `headline` y `head_repo` en una ejecución que GitHub enlazó, describió o tomó de un fork, `merged` en el trabajo fuera que se fusionó y `additions`, `deletions` y `changed_files` en el trabajo fuera que es una pull request, que una cuenta que solo ha abierto issues en repositorios ajenos no ha escrito nunca, y `language` en un repositorio de fuera en el que GitHub detectó uno. 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. Una de estas la nombra un panel de los que se publican, y es la que más probablemente falte: `gh_pull_request.seconds_to_first_human_review`, que solo existe una vez que alguien que no es el autor ni un bot ha revisado una pull request. En una base donde eso nunca ha pasado, la tarjeta que la lee informa de un error de esquema en vez de decir No data, y se lleva por delante los cinco valores que la acompañan en el mismo panel. Ninguna consulta puede preguntar si una columna existe, así que esto es una propiedad del almacén y no un defecto que reparar: la reparación, si llega a morder, es una sola fila de cualquier tipo que traiga el campo. Lo raro que es, medido el 2026-09-17 en la cuenta sobre la que se desarrolló esto: catorce filas en todo el almacén, de catorce pull requests de cuatro repositorios, la más reciente abierta el 2026-07-05, y ninguna dentro de los últimos quince días, frente a 703 pull requests de 825 de esos quince días que sí tuvieron una primera revisión de un bot o de su propio autor. El campo no está roto; es la respuesta a una pregunta más estrecha de lo que espera quien lee, que es por lo que el panel se llama por esa pregunta. La misma lectura vale para los dos de `gh_workflow_run`. `initial_actor` solo se escribe cuando el `actor` y el `triggering_actor` de GitHub son distintos, que es una reejecución que pidió otra persona: 0 de 10.201 ejecuciones en quince días aquí, y 0 de las 300 ejecuciones más recientes de tres repositorios comprobadas contra la API al mismo tiempo. `head_repo` solo se escribe para una ejecución que vino de otro repositorio: 18 de esas 10.201, todas de la pull request de un mismo fork. Los dos son correctos y los dos son raros, que es el aspecto que tiene un campo que solo se escribe cuando GitHub tiene algo que decir. --- # Elegir almacén Dónde guardar las métricas de GitHub: once almacenes, qué puede y qué no puede responder cada uno, y la propiedad que decide entre ellos. Source: https://docs.jmrp.io/ghchronicle/es/sinks/ Once 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](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/) | la historia fechada | "a qué velocidad fusionábamos en julio" | `url`, `token`, `org`, `bucket` | | [PostgreSQL](https://docs.jmrp.io/ghchronicle/es/sinks/postgres/) | la historia fechada, en una base a la que conecta | quien usa Grafana con un Postgres y sin InfluxDB | `dsn`, `batch` | | [PostgreSQL, a fichero](https://docs.jmrp.io/ghchronicle/es/sinks/postgres/) | las mismas tablas, como SQL que pasas a psql | una carga que ocurre después, o en otro sitio | `dialect`, `path` | | [Graphite](https://docs.jmrp.io/ghchronicle/es/sinks/graphite/) | la historia fechada | un Graphite que ya está ahí | `addr`, `prefix` | | [Elasticsearch](https://docs.jmrp.io/ghchronicle/es/sinks/elasticsearch/) | la historia fechada, como documentos | buscar en todo lo recogido | `url`, `prefix`, `api_key` | | [Prometheus](https://docs.jmrp.io/ghchronicle/es/sinks/prometheus/) | el valor actual | alertas, y un número en una pared | `listen`, `path` | | [OpenTelemetry](https://docs.jmrp.io/ghchronicle/es/sinks/otlp/) | una cosa u otra, según el backend | un pipeline de collector ya existente | `endpoint`, `raw` | | [Loki](https://docs.jmrp.io/ghchronicle/es/sinks/loki/) | los eventos, como líneas de log | "qué pasó, en orden" | `url`, `labels`, `max_age` | | [Telegraf](https://docs.jmrp.io/ghchronicle/es/sinks/telegraf/) | lo que guarden sus salidas | alcanzar todo lo que alcance Telegraf | `url` | | [Fichero y stdout](https://docs.jmrp.io/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](https://docs.jmrp.io/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. Los jobs de las ejecuciones de workflow que recuerda [la caché](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado) vienen con él: un arranque que no encuentra el registro los vuelve a listar. Es el segundo fichero que conviene poner en una ruta persistente, junto al [fichero de estado](https://docs.jmrp.io/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](https://docs.jmrp.io/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. ## Qué cuenta la línea de escritura `points` es lo que aceptó ese destino, no lo que se le entregó. Todos los destinos descartan algo por su cuenta dentro de la escritura: InfluxDB se salta las medidas que nombra [`exclude`](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#exclude), Loki se queda solo con las medidas para las que tiene una representación de evento y tira el resto, los destinos SQL, Graphite, Telegraf, fichero y stdout se saltan un punto que no genera fila ni línea, y los dos exportadores se saltan una medida para la que no tienen regla. Cuando eso pasa aparece una tercera clave: ```text level=INFO msg=written sink=loki family=repos points=0 unchanged=0 filtered=42 ``` `filtered` es lo que el destino no escribió, y cada pasada termina con una línea por destino que lleva el total desde que arrancó la ejecución, igual que se lee el total del propio registro que va al lado. Las líneas que un almacén no supo analizar tampoco cuentan como escritas; esas llevan su propia línea de aviso. Hasta que se contó, la línea decía lo que el runner había entregado, así que una pasada leía `points=440` de una medida que InfluxDB excluye por omisión y de la que no ha tenido nunca una fila, y leer las líneas de Loki no decía nada de lo que Loki había guardado. ## Por dónde empezar - [Quieres la historia](https://docs.jmrp.io/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](https://docs.jmrp.io/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ó](https://docs.jmrp.io/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](https://docs.jmrp.io/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://docs.jmrp.io/ghchronicle/es/sinks/influxdb/ El destino InfluxDB escribe cada punto de ghchronicle, fechado cuando ocurrió, en InfluxDB 2 o 3, y es el almacén contra el que están hechos los dashboards. ```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 repositorio y día. - 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](https://docs.jmrp.io/ghchronicle/es/how/backfill/). Los [dashboards](https://docs.jmrp.io/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](https://docs.jmrp.io/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. Una medida excluida se recoge igual y se le ofrece igual a este destino, así que el log de la pasada dice lo que aceptó la base de datos y no lo que se le entregó: ```text level=INFO msg=written sink=influxdb family=joblogs points=0 unchanged=0 filtered=440 ``` Aquí `filtered` es una medida nombrada arriba, o un punto que no lleva ningún campo que el protocolo de línea pueda representar. Es [la misma clave que usan todos los destinos](https://docs.jmrp.io/ghchronicle/es/sinks/#qué-cuenta-la-línea-de-escritura). Hasta que se contó, la línea decía `points=440` de una medida de la que la base de datos no ha tenido nunca una fila. ## 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`. El borrado no destruye la > tabla en el acto: InfluxDB la renombra `-`, la deja > consultable y la purga 72 horas después por omisión, o nunca antes de la 3.2 > (ver [más abajo](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#antes-de-la-32-la-copia-se-queda)), y el nombre acepta > escrituras nuevas enseguida. Para los cambios que el binario conoce, lo envía > `-migrate -yes`: mira más abajo. ## 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' ``` ## Qué hace aquí una migración Cuando una versión cambia aquello por lo que se identifican las filas de una medida, [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) pregunta al catálogo de InfluxDB 3 si la etiqueta antigua es una columna de etiqueta de la tabla viva, y aplicar el cambio borra esa única tabla: ```http DELETE /api/v3/configure/table?db=&table= ``` Ese borrado es la manera propia de InfluxDB de apartar una tabla. **Medido contra InfluxDB 3 Core de la 3.0.0 a la 3.11.5**: la tabla se renombra `-`, el instante en UTC, por ejemplo `gh_discussion_comment-20261001T091004`, sigue listada y responde a consultas con ese nombre, y la siguiente escritura con el nombre antiguo crea la tabla de nuevo, con la forma nueva, aunque una columna pase de etiqueta a campo. El binario lee el nombre del catálogo, lo imprime y lo guarda en el fichero de estado, con cuándo ha programado el servidor su borrado definitivo, leído de la tabla de sistema de su base de datos `_internal`. Eso es 72 horas después del borrado (medido de la 3.2.1 a la 3.11.5), y el servidor guarda el nombre en su catálogo durante su `--delete-grace-period` después de eso, 24 horas por omisión; donde no se puede leer la hora, entre ellos la 3.2.0, se suponen 72 horas, que es lo que programa también la 3.2.0. Un servidor anterior a la 3.2 no la purga nunca: ver [más abajo](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#antes-de-la-32-la-copia-se-queda). ghchronicle deja la copia al servidor, y la olvida cuando llega la hora del servidor. Nunca pide que se vaya antes: `hard_delete_at` no se envía nunca, porque medido en la 3.11.5 `now` no había quitado las filas once minutos después, y se lleva los días para deshacer el cambio; y desde la 3.10.0 un borrado de la tabla renombrada recibe un 409, con `hard_delete_at` o sin él (medido de la 3.10.0 a la 3.11.5). Hasta entonces las filas de la copia se pueden leer con SQL y escribir de vuelta a través del destino. InfluxDB 3 Core rechaza una consulta que abriría más ficheros Parquet que su `--query-file-limit`, 432 por omisión, que una tabla escrita cada diez minutos supera en días. La comprobación lee el catálogo, que no abre ninguno, y cuenta las filas solo de una tabla que guarda la etiqueta antigua; un recuento que Core rechaza deja la relectura sin límite, y una lectura rechazada de quién son las filas de la tabla deja el cambio necesitando tu palabra, con el rechazo citado en el plan. Ver [el límite de ficheros por consulta](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#el-límite-de-ficheros-por-consulta-de-influxdb-3-core). El token tiene que poder borrar una tabla. Un borrado rechazado deja el cambio pendiente, y el error nombra la misma petición para enviarla a mano. InfluxDB 2 no tiene renombrado. Aplicar el cambio allí borra todas las filas de la medida en el bucket, en todos los instantes que puede guardar, con `POST /api/v2/delete` y el predicado `_measurement=""`, y eso es definitivo. **Medido contra InfluxDB 2.7.12**: se llevó esa medida y dejó como estaban `gh_discussion_comment_x`, las demás medidas del bucket y la misma medida en otro bucket. Por eso un arranque nunca lo aplica por su cuenta; `-migrate -yes` sí. `-uninstall data` deja fuera las tablas que InfluxDB 3 ya ha borrado, y dice de cada una si la purga el servidor o se queda: en un servidor anterior a la 3.2 se quedan todas, y en uno posterior cada una que borró una versión anterior a la 3.2, preguntado a la tabla de sistema del servidor, con la petición que la quita donde la versión la acepta (ver [más abajo](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#antes-de-la-32-la-copia-se-queda)). Las demás le toca purgarlas al servidor, y solo de una versión desde la 3.10 se dice que se niega a que se le pida antes. La tabla de sistema de la 3.2.0 no puede decir cuáles, y allí la nota lo dice. ### Antes de la 3.2 la copia se queda **Medido contra InfluxDB 3 Core 3.0.0, 3.0.3 y 3.1.0**, ninguna de las cuales tiene borrado definitivo: no corre ningún borrador, `hard_delete_at` se acepta y se ignora, `_internal` no tiene `system.tables`, y un borrado de la tabla renombrada responde 200 y la renombra otra vez, `--`. La copia se queda, y responde a consultas, mientras el servidor ejecute una de esas versiones. La 3.2.0 es la primera versión con borrador: en la 3.2.0 y la 3.3.0 se midió que programan un borrado definitivo y lo llevan a cabo como la 3.4.0 y las siguientes. El binario lee la versión de `/ping`, y en un servidor anterior a la 3.2 lo dice donde importa: el plan dice `set aside for good` antes de aplicar nada, aplicar lo dice con el nombre de la copia, y `-migrate` lista cada copia que guarda un servidor así como `kept aside ... for good`, preguntándoselo al propio servidor, de modo que la lista incluye una copia que el fichero de estado ha olvidado, o que un fichero de estado nuevo nunca conoció. `-uninstall data` dice lo mismo de cada tabla que borra allí. Cada una termina en la petición que quita la copia en cuanto el servidor ejecute una versión que la acepte: ```sh curl -X DELETE '/api/v3/configure/table?db=&table=&hard_delete_at=now' \ -H 'Authorization: Bearer ' ``` Una copia hecha antes de la 3.2 no gana hora de borrado definitivo al actualizar: medido con copias hechas por la 3.0.3 y la 3.1.0, abiertas por la 3.2.0, la 3.4.0, la 3.9.13 y la 3.11.5. Así que también se queda después de actualizar, hasta que se le diga que se vaya. La 3.2.0, la 3.4.0 y la 3.9.13 aceptaron la petición para una copia así y la quitaron de su catálogo al pasar su periodo de gracia de borrado, la 3.2.0 después de renombrarla otra vez; de la 3.10.0 a la 3.11.5 responden con un 409. Así que la petición se envía mientras el servidor ejecuta una versión de la 3.2 a la 3.9, de camino hacia arriba. Desde la 3.2.1, `-migrate` lista las copias que la tabla de sistema muestra sin hora de borrado definitivo, que es como se ve allí una copia así, y `-uninstall data` dice que cada una se queda, con esa petición donde la versión la acepta (medido en la 3.4.0 con una copia hecha por la 3.1.0). La tabla de sistema de la 3.2.0 no tiene esa columna, y responde a la pregunta con un 500, así que en la 3.2.0 `-migrate` no lista ninguna y `-uninstall data` dice que no lo sabe. Medido también: la 3.11.5, arrancada sobre el directorio de datos de una 3.0.3, empezó con un catálogo vacío, mientras que la 3.4.0 y la 3.9.13 leyeron los catálogos que habían dejado la 3.0.3 y la 3.1.0. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara InfluxDB con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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 Métricas de GitHub en Prometheus: un exportador, no un emisor, y qué le hace a cada medida la reducción a valores actuales. Source: https://docs.jmrp.io/ghchronicle/es/sinks/prometheus/ El destino Prometheus es un exportador que sirve el valor actual de cada métrica de GitHub para que Prometheus lo recoja. ```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 ``` ## ¿Puede Prometheus guardar la historia? No, y no por elección: no puede servir la historia fechada. 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` | Las filas sumadas. 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 lote se lee tal como lo guardan los almacenes. Un punto con la misma medida, las mismas etiquetas y la misma hora que otro anterior es la misma fila, así que se suma, se cuenta y se promedia una vez, igual que los almacenes de historia lo guardan una vez; [la fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/#el-reductor-y-qué-hace-con-cada-medida) dice qué campos suyos conserva cada almacén. Un precio es el único número que un `sum` no suma: la factura repite el precio de un SKU en cada fila, una por repositorio y día, así que `github_billing_usage_price_per_unit` es el más alto de ellos, el `MAX(price_per_unit)` de los dashboards SQL, y no el precio multiplicado por los días facturados. Cada media es sobre los elementos que traían el campo, no sobre el recuento. Un colector omite un campo cuando no tiene un valor honesto para él: un job sin hora de inicio no tiene `queued_seconds`, y un job del que GitHub ya no lista los pasos no tiene `steps`. Contados como ceros, una pasada en la que la mitad de los jobs hubiera perdido sus pasos dejaría `github_workflow_jobs_steps_mean` en la mitad. Tres campos son la excepción, porque omitirlos ya es la respuesta: `merged` en una contribución externa solo se escribe al fusionarse, `advanced` en un fork solo cuando tiene fecha de push, y `pull_requests` en una ejecución solo cuando se lanzó por alguno. Cada uno de ellos se promedia sobre todos los elementos, así que la media de `merged` es la proporción de contribuciones fusionadas y no 1. 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 diez se saltan por un motivo distinto del tamaño, nueve por ser historia y una por ser texto, así que doce medidas en total no llegan nunca al exportador. La lista, con el motivo de cada una, está en [la fecha del punto](https://docs.jmrp.io/ghchronicle/es/how/dating/#las-doce-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. La pasada de cebado anota como hechas solo las familias a las que les tocaba, y las demás vuelven a correr cuando lo habrían hecho sin el reinicio. Anotadas todas a la vez, las familias de seis horas o más tocarían juntas un día después y [se turnarían](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan), y la última pasaría del día tras el cual el exportador descarta una serie. 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. ## Nada que migrar El exportador guarda en memoria los valores de hoy, y un reinicio con la versión nueva los sirve con su forma, así que [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) no tiene nada que hacer aquí. El servidor de Prometheus conserva las series antiguas hasta su retención, y terminan donde empiezan las nuevas, así que ninguna consulta cuenta un elemento dos veces. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara Prometheus con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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 Enviar métricas de GitHub a un collector de OpenTelemetry o a Prometheus: OTLP sobre HTTP con codificación JSON, y por qué raw vale false por omisión. Source: https://docs.jmrp.io/ghchronicle/es/sinks/otlp/ El destino OTLP empuja las métricas de ghchronicle a un collector de OpenTelemetry, o directamente a Prometheus por su receptor 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 del binario. Todos los destinos salvo PostgreSQL, que necesita su driver, hablan su protocolo solo con la biblioteca estándar. `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, con [la misma reducción](https://docs.jmrp.io/ghchronicle/es/sinks/prometheus/#la-reducción), así que cada media se toma sobre los elementos que traían el campo. 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. ## Nada que migrar El destino envía los valores de hoy, con la forma de la versión que lo ejecuta, así que [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) no tiene nada que hacer aquí. Lo que el receptor conserva de una serie antigua termina donde empieza la nueva. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara OpenTelemetry con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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 Las dos maneras de llegar a PostgreSQL, sentencias INSERT que se pasan a psql y un destino que conecta, el esquema que declaran los dos y por qué la cláusula de conflicto actualiza en vez de no hacer nada. Source: https://docs.jmrp.io/ghchronicle/es/sinks/postgres/ Dos destinos llevan los puntos fechados de ghchronicle a PostgreSQL: uno escribe SQL en un fichero para cargarlo después, el otro conecta e inserta. ```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" ``` Esta es una de las dos maneras de llegar a PostgreSQL, y la otra se conecta: ```yaml sinks: postgres: dsn: ${DATABASE_URL} batch: 1000 ``` `sinks.postgres` escribe las mismas tablas en una base de datos que está corriendo, en una ida y vuelta por lote, con los valores enviados como parámetros en vez de renderizados dentro de la sentencia. Nada más cambia: el esquema de abajo es el que escriben las dos, a propósito, porque los dashboards que publica este proyecto consultan esas tablas y una segunda forma convertiría a uno de los dos en una mentira. Cuál quieres es una pregunta sobre cuándo ocurre la carga. El fichero es la respuesta cuando la base está donde esto no llega, cuando las sentencias se van a leer o a retener antes de ejecutarse, o cuando quien las lee no es PostgreSQL: son SQL corriente y otro motor puede tomarlas. La conexión es la respuesta cuando la base está ahí mismo, y te ahorra el temporizador que mete un fichero por psql. Hay una diferencia más, y está en Grafana y no aquí. El sink que conecta sabe cuál es el servidor porque lo marca, así que `-publish-dashboard` puede construir el datasource a partir del DSN; el de fichero no puede, porque nunca se conecta y en su configuración no hay host, ni puerto, ni usuario. ## 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 '', PRIMARY KEY ("time", "full_name", "kind", "owner", "repo")); ALTER TABLE "gh_traffic" ADD COLUMN IF NOT EXISTS "count" BIGINT; ALTER TABLE "gh_traffic" ADD COLUMN IF NOT EXISTS "uniques" BIGINT; ALTER TABLE "gh_traffic" ADD COLUMN IF NOT EXISTS "url" TEXT; 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, o en el proceso de un sink que conecta, con el tiempo y las etiquetas de las que está hecha la clave. Cada campo de la unión que lleva ese lote lo sigue como `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, y lo mismo un campo que aparece en un lote posterior. Los campos nunca van en el `CREATE TABLE`, porque esa sentencia no le hace nada a una tabla que creó una versión anterior: un campo que esa versión no escribía llegaría al `INSERT` sin columna, y PostgreSQL rechaza la sentencia y el lote que la rodea. Añadir la columna de un campo si falta significa lo mismo para una tabla nueva y para una antigua. Un fichero rotado empieza otra vez sus declaraciones, así que cualquier fichero se puede reproducir por su cuenta. Así que un fichero reproducido en una base de datos que ya tiene sus tablas hace que psql imprima un aviso por cada declaración que encuentra su trabajo hecho, uno por el `CREATE TABLE` y uno por cada columna que la tabla ya tiene: ```text NOTICE: relation "gh_traffic" already exists, skipping NOTICE: column "count" of relation "gh_traffic" already exists, skipping ``` Esos son de esperar. Una línea `ERROR` no lo es. El sink que conecta no envía todos esos `ALTER TABLE`. Pregunta al catálogo qué columnas tiene ya una tabla la primera vez que su proceso se encuentra con ella, y añade solo las que le faltan. PostgreSQL toma el bloqueo exclusivo de un `ALTER TABLE` antes de comprobar `IF NOT EXISTS`, así que uno por campo en cada arranque esperaba a cada consulta de Grafana que leía la tabla y retenía todas las que llegaban detrás. **Medido contra PostgreSQL 18.6** con un lector que mantenía una tabla abierta: `CREATE TABLE IF NOT EXISTS` respondió en menos de un milisegundo, mientras que un `ADD COLUMN IF NOT EXISTS` de una columna que ya existía esperó hasta que un `lock_timeout` de un segundo lo rechazó, y un `SELECT` detrás de él esperó tres segundos. Una sentencia que el servidor rechaza se envía otra vez en la escritura siguiente en vez de darse por hecha. Una etiqueta es otra cosa, porque forma parte de la clave. Una vista por primera vez después de declarar la tabla, en el mismo fichero o en el mismo proceso, 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. Una tabla que creó una versión anterior es otro asunto, y una versión que cambia las etiquetas de una medida cambia lo que se carga en ella. **Medido contra PostgreSQL 18.6** el 2026-09-27: - Una etiqueta que añade una versión posterior no recibe columna de ninguno de los dos destinos, así que cada `INSERT` de esa medida se rechaza con `column ... does not exist`, y en el destino que conecta, con él las demás filas del mismo lote. - Una etiqueta que una versión posterior deja de escribir, como 2.6.1 dejó de escribir `is_answer` en `gh_discussion_comment`, deja el `ON CONFLICT` del fichero nombrando una clave que la tabla no tiene. psql rechaza cada una de esas sentencias con "there is no unique or exclusion constraint matching the ON CONFLICT specification", y el resto del fichero se carga. El destino que conecta lee del catálogo la clave propia de la tabla y choca contra esa, así que sigue escribiendo, y sus filas nuevas quedan junto a las viejas con la cadena vacía en la columna que ya no se escribe, las dos formas que guarda también InfluxDB. La salida limpia de cualquiera de los dos casos es borrar la tabla y dejar que las pasadas la vuelvan a llenar, y un relleno histórico lo anterior, que es lo que hace `-migrate -yes` con los cambios que el binario conoce: mira más abajo. Un colector con el destino que conecta sigue escribiendo tras el borrado. Su primera escritura después se rechaza con `relation ... does not exist`, porque el destino declara una tabla una vez por proceso; entonces olvida las tablas de ese lote, las declara de nuevo y reenvía el lote una vez, lo que crea la tabla de cero. **Medido contra PostgreSQL 18.6**: antes de esto, todas las escrituras siguientes de la tabla, y el resto de su lote, se rechazaban hasta reiniciar. [Cómo leer las tablas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cómo-leer-las-tablas) enseña cómo reconstruir una clave a mano. ## Qué hace aquí una migración Cuando una versión cambia aquello por lo que se identifican las filas de una medida, [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) pregunta a la base de datos del destino que conecta si alguna fila tiene un valor en la columna de la etiqueta antigua, en el esquema en el que escribe el destino, y aplicar el cambio renombra esa única tabla en ese esquema, lo que conserva todas sus filas, índices y claves: ```sql ALTER TABLE ""."gh_discussion_comment" RENAME TO "gh_discussion_comment-20261001T091004"; ``` El nombre es la medida, un guion y el instante en UTC, así que hay que ponerlo entre comillas. El renombrado toma el mismo bloqueo exclusivo que un borrado, que espera detrás de cada consulta de Grafana que lee la tabla, así que se envía con un `lock_timeout` de 5 segundos, tres veces; una tabla que sigue ocupada más tiempo deja el cambio pendiente con la razón del bloqueo. La siguiente escritura del destino crea la tabla con el nombre antiguo y la clave nueva, cuyo índice toma el nombre `gh_discussion_comment_pkey1` junto al de la copia. **Medido contra PostgreSQL 18.6**: el renombrado tardó 7 ms, no tocó ningún otro esquema, y un destino que había escrito la tabla antes siguió escribiendo después. ghchronicle borra la copia cuando lleva 24 horas guardada: tras una pasada del servicio, en el siguiente arranque de cualquier ejecución o en el siguiente `-migrate -yes`, cada uno con su propio `lock_timeout`. `-uninstall data` la lista con las demás tablas `gh_`. Hasta entonces, deshacer el cambio son dos sentencias: ```sql DROP TABLE "gh_discussion_comment"; ALTER TABLE "gh_discussion_comment-20261001T091004" RENAME TO "gh_discussion_comment"; ``` Al fichero SQL no se le puede preguntar, así que decide el registro del fichero de estado de la versión que lo escribió primero, y aplicar el cambio escribe el borrado en el fichero, después de todo lo que ya tiene: ```sql DROP TABLE IF EXISTS "gh_discussion_comment"; ``` Después el destino olvida la tabla, así que las siguientes filas de esa medida la declaran de nuevo tras el borrado. Reproducido en orden, la base de datos pierde la tabla con la forma antigua y gana la de la forma nueva. **Medido con psql contra PostgreSQL 18.6**: sin el borrado, las filas nuevas se rechazan contra la clave antigua; con él, el fichero carga entero. El borrado llega a donde se reproduzca el fichero, donde no se aparta nada, así que un arranque nunca lo aplica por su cuenta; `-migrate -yes` sí. Un fichero reproducido solo, sin el que lleva el borrado, sigue encontrando la tabla antigua, y una rotación que borra ese fichero antes de reproducirlo se lleva el borrado con él. Con `path: "-"` el borrado y las filas releídas van a la salida estándar, y `-migrate -yes` imprime su plan y su informe en la salida de errores, así que la salida estándar es solo el SQL y admite la misma tubería que `-once`: ```sh ghchronicle -config config.yaml -migrate -yes | psql "$DATABASE_URL" ``` **Medido con psql contra PostgreSQL 18.6**: por esa tubería, la tabla que hizo la 2.6.0 se borró y se volvió a crear con la clave nueva, con los comentarios releídos. Antes, el plan iba primero por el mismo flujo, psql leía su primera línea como el comienzo de una sentencia y perdía el borrado con ella, y el destino conservaba la tabla antigua mientras el fichero de estado registraba el cambio como aplicado. Ejecutado sin la tubería, el SQL va a la terminal y a ningún otro sitio, y el cambio igualmente se registra como aplicado. ## 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. Elige el destino. El que conecta necesita `sinks.postgres.dsn` y una base de datos a la que esta máquina llegue, y declara sus tablas en su primera escritura. El de fichero necesita `sinks.sql.path`, un fichero o `-` para la salida estándar. 2. Con el destino de fichero, carga lo que escribió. El que conecta no tiene nada que cargar. ```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`, o, con el destino que conecta, deja que `-publish-dashboard` cree el datasource a partir del DSN y publique el dashboard. ```sql SELECT time, "count" FROM gh_traffic WHERE kind = 'views' AND repo = $repo ``` `dashboards/ghchronicle-postgres.json` tiene los mismos 154 paneles que el de InfluxDB, con cada consulta traducida a PostgreSQL contra este esquema. La traducción compara el texto por sus bytes, como InfluxDB: cada columna de texto por la que una consulta ordena, o de la que toma el menor o el mayor valor, lleva `COLLATE "C"`. Si se deja a la base de datos, el orden es el de su intercalación, y en un PostgreSQL construido sobre glibc cuya base se creó con una configuración regional como `en_US.UTF-8` esa intercalación deja a un lado mayúsculas y signos de puntuación en su primera pasada. **Medido contra PostgreSQL 18.6 en Debian**, cuya base se crea como `en_US.utf8`: antes de esto, "(ghost)" iba después de "alice" y "VALID" después de "unsigned", y tres paneles listaban sus filas en otro orden que el dashboard de InfluxDB. La imagen Alpine coincidía solo porque musl compara bytes se llame como se llame la configuración regional. En la base de datos no cambia nada: sus columnas conservan la intercalación con que se crearon, y solo las consultas del dashboard dicen cómo comparar. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara PostgreSQL con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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 Enviar métricas de GitHub a Graphite: el protocolo de texto plano sobre TCP, y la ruta métrica exacta, que es el contrato del dashboard. Source: https://docs.jmrp.io/ghchronicle/es/sinks/graphite/ El destino Graphite envía los campos numéricos de ghchronicle a Graphite por TCP, cada uno en la fecha en que ocurrió. ```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 ``` El dashboard nombra una serie o una fila por el nodo bajo el que la agrupa, así que muestra un nombre tal como lo guarda la ruta: el `(ghost)` que el colector escribe para una cuenta borrada se lee `_ghost_`, el SKU `Actions Linux` `Actions_Linux`, la categoría `Q&A` `Q_A`, `another/project` `another_project`, y una clave de caché que contiene `go-1.27.1` contiene `go-1_27_1`. Nada los devuelve a su forma al leerlos, porque un guion bajo en un nodo puede haber sido uno también en el nombre, así que el dashboard de Graphite los deja como están, y los otros cuatro almacenes muestran cada nombre como lo escribió el colector. 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 reduce cada serie a los uno o dos números que puede llevar una fila, y la descripción del panel nombra esos y cada columna que descarta. - Un título o cualquier otra cadena no es una métrica allí en absoluto, y el destino la descarta; los paneles que mostrarían una lo dicen. Un booleano se guarda como 1 o 0, igual que cualquier número. - Un panel no puede unir dos medidas, porque cada una vive bajo su propia ruta. Work elsewhere no tiene columna Stars, y el perfil de comunidad conserva la bandera de plantilla de issue de la API en vez del número de plantillas, que vive bajo otra medida. - Un objetivo no puede preguntar por una ventana mientras lee otra. El Overview y Every repository, ever cuentan un repositorio archivado que el filtro por omisión aparta por sus puntos de los últimos siete días, donde los almacenes SQL preguntan si el colector lo sigue escribiendo, así que un rango que terminó hace más de una semana deja fuera esos repositorios. 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 154 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. Cada gráfica en el tiempo suma sus puntos en cubos del ancho que pide el rango, el ancho por el que agrupan los dashboards SQL: el rango entre cien, redondeado como Grafana redondea un intervalo y nunca por debajo del mínimo de la gráfica, un día, una hora o cinco minutos. El dashboard los calcula en tres variables ocultas, `bucket_1d`, `bucket_1h` y `bucket_5m`, y cada gráfica pide 5.000 puntos, más de los cubos que tiene, así que graphite-web devuelve los cubos tal cual. Si se le piden menos puntos de los que tiene una serie, graphite-web la encaja en bandas y mueve cada punto un paso más tarde al hacerlo, y antes de la 2.6.2 eso dejaba la hora más reciente de una gráfica más allá de su borde derecho en la última hora antes de cada límite de banda. Medido contra `graphiteapp/graphite-statsd:1.1.10-5` con una hora por paso a las 15:35 UTC, el recuento de estrellas que un barrido había escrito en esa hora volvió fechado a las 16:00 con cien puntos, después del final del rango, y una gráfica cuyo único punto era ese decía "Data outside time range"; sumado en el cubo del día volvió a las 00:00, y el almacenamiento de artefactos en su cubo de seis horas a las 12:00. Un cubo sigue el rango del dashboard, así que una gráfica fijada a su propio rango, como lo están dos de los paneles de Code, suma en días sea cual sea el rango de la página. Dos gráficas suman en cambio en un día fijo, porque sus filas ya están a un día o a una semana unas de otras y cada una va en su propia fecha: el calendario de contribuciones, una fila por día, y los commits semanales, una fila por semana fechada en el domingo en que GitHub empieza la semana, sin los días entre semanas. Siete días no sirven de cubo ahí: graphite-web los cuenta desde el epoch, un jueves, y cada barra semanal quedaba tres días antes, la primera semana de un rango de treinta días antes de que empezara el rango. Todos los demás paneles piden también 5.000 puntos. Una tabla, una gráfica de barras o un stat no se resume en cubos: reduce los puntos de todo el rango a un número, y el encaje en bandas descarta los primeros, uno menos que los pasos que adelanta el inicio de la primera banda. Grafana pide a un panel que no indica ningún número tantos puntos como píxeles tiene de ancho, así que antes de la 2.6.2 un panel más estrecho que los puntos de un rango perdía la primera hora o las primeras horas del rango, según dónde empezara el rango. Medido en el mismo Graphite, un punto a las 20:00 UTC leído desde las 19:05 durante treinta días sumaba nada con 500 puntos y 1 con 5.000, y "Languages starred" dibujaba como 0 una estrella dada a esa hora en una ventana de 640 píxeles de ancho. Un panel así tiene un punto por paso de almacenamiento, así que 5.000 cubre las 2.880 horas de los ciento veinte días que el esquema de la suite en contenedores guarda a una hora por paso y los 4.380 días de los doce años que guarda a un día. Una retención que guarda más de 5.000 pasos de un rango, una hora por paso durante más de ciento veinte días o cualquier cosa más fina, vuelve a encajarse en bandas. Una gráfica o una gráfica de barras que nombra sus series más activas y junta el resto en una llamada other hace aquí lo mismo que en los dashboards SQL: se nombran las más activas de todo el rango, y other es todas las series menos esas, punto a punto, dibujada solo donde queda algo. ## Qué hace aquí una migración Graphite no se puede vaciar desde aquí. Nada en el puerto de ingesta de carbon borra una ruta, y graphite-web no borra ninguna que no lleve etiquetas: **medido contra graphite-statsd 1.1.10-5**, `/metrics/delete` responde 404 y `/tags/delSeries` responde `true` y deja la serie donde estaba. Lo que una versión dejó con otra forma lo decide el registro del fichero de estado de la versión que escribió primero el almacén, y aplicar el cambio es que `-migrate -yes` imprima, una vez, las órdenes para la máquina de Graphite, y registre el cambio como aplicado, porque si se ejecutaron solo lo sabe quien las ejecuta: ```sh find /whisper/github/discussion_comment -mindepth 11 -name '*.wsp' -delete find /whisper/github/discussion_comment -type d -empty -delete ``` `` es el directorio de almacenamiento de carbon, `/opt/graphite/storage` en la imagen oficial, y `github` es el prefijo. Una ruta es un nodo por etiqueta y después el campo, así que la forma antigua de una medida que perdió una etiqueta queda un nivel más abajo que la nueva, y `-mindepth` llega a ese nivel y a nada por encima. Medido en esa imagen: la orden se llevó los dos ficheros de la forma antigua y dejó los de la forma nueva y los de todas las demás medidas. Los ficheros son de la máquina, y el orden da igual, porque las dos formas son ficheros distintos. Un arranque nunca lo aplica por su cuenta. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara Graphite con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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://docs.jmrp.io/ghchronicle/es/sinks/elasticsearch/ El destino Elasticsearch envía los puntos fechados de ghchronicle a Elasticsearch u OpenSearch en bloque, un documento por punto. ```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. [`-migrate`](https://docs.jmrp.io/ghchronicle/es/sinks/elasticsearch/#qué-hace-aquí-una-migración) lee el mapping antes de preguntar de quién son las filas de un índice, así que una plantilla que mapea las cadenas como `keyword`, sin subcampo `.keyword`, se lee del propio campo. ## El dashboard `dashboards/ghchronicle-elasticsearch.json` tiene los mismos 154 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í, o el más nuevo de cada elemento cuando un elemento tiene varios; todo lo demás es una agregación por cubos. OpenSearch funciona con el mismo plugin. Hay dos cosas que una consulta no puede hacer allí, y los paneles que las necesitan lo dicen. No puede unir dos índices, así que Work elsewhere no tiene columna Stars y el perfil de comunidad conserva la bandera de plantilla de issue de la API en vez del número de plantillas. Y no puede preguntar por una ventana mientras lee otra: el Overview y Every repository, ever cuentan un repositorio archivado que el filtro por omisión aparta por sus documentos de los últimos siete días, donde los almacenes SQL preguntan si el colector lo sigue escribiendo, así que un rango que terminó hace más de una semana deja fuera esos repositorios. ## Qué hace aquí una migración Cuando una versión cambia aquello por lo que se identifican los documentos de una medida, [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) encuentra los documentos de la forma antigua contando los que llevan la etiqueta antigua, y aplicar el cambio aparta el índice en tres pasos. **Medido contra Elasticsearch 9.5.3**: 1. El índice deja de aceptar escrituras, con `PUT <índice>/_block/write`. 2. Se clona a `<índice>-`, el instante en UTC y en minúsculas, como exige el nombre de un índice, por ejemplo `ghchronicle-gh_discussion_comment-20261001t091004`, y el clon tiene que tener tantos documentos como el índice antes de que ocurra nada más. 3. Se borra el índice, y la siguiente escritura del destino lo crea de nuevo, con un mapping propio. Antes de aplicar el cambio, el plan pregunta de quién son las filas del índice y a qué repositorios pertenecen, con una agregación terms sobre el campo que el mapping deje agregar: el subcampo `.keyword` que da el mapping dinámico a una cadena, o el propio campo donde una plantilla lo mapea como `keyword`. Los buckets tienen que sumar todos los documentos que tienen el campo, o la respuesta no se acepta: medido en 9.5.3, una agregación sobre `user.keyword` en un índice cuya plantilla mapeaba `user` como `keyword` respondió sin buckets y sin error, lo que se leía como un índice sin filas de nadie más, y un valor que pasa del `ignore_above` del subcampo tampoco está en ningún bucket. Un documento sin el campo es una fila que no nombra a nadie, y el plan lo dice así. Un paso que falla antes del borrado vuelve a quitar el bloqueo de escritura, así que un clúster que rechazó el clon sigue aceptando las escrituras del destino, y el cambio sigue pendiente con la razón del clúster. Un borrado que falla se vuelve a mirar antes de deshacer nada: medido a través de un proxy que respondió 502 a un borrado que el clúster llevó a cabo, el índice ya no estaba, así que el clon, la única copia de sus documentos, se conserva y el cambio se registra como aplicado; solo a un índice que sigue ahí se le quita el bloqueo y se le borra el clon. La clave de API necesita los privilegios `manage` y `delete_index` sobre los índices del prefijo; una clave que solo puede escribir se rechaza así. El clon conserva el bloqueo de escritura, y ghchronicle lo borra cuando lleva 24 horas guardado: tras una pasada del servicio, en el siguiente arranque de cualquier ejecución o en el siguiente `-migrate -yes`. Hasta entonces, deshacer el cambio es borrar el índice nuevo y clonar la copia de vuelta con su nombre. Toda consulta del dashboard que se distribuye nombra su índice entero, `_index:ghchronicle-gh_discussion_comment`, así que ningún panel lee el clon: la consulta de un panel sobre `ghchronicle-*` contó el único documento nuevo y no los tres del clon. Una data view de Kibana sobre el mismo patrón sin ese filtro sí lo lee, durante el día que se guarda. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara Elasticsearch con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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://docs.jmrp.io/ghchronicle/es/sinks/loki/ El destino Loki envía a Grafana Loki, como líneas de log, la actividad de GitHub que es un evento y no un número (una estrella, una release, una ejecución fallida). ```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. En el registro de la pasada ya no se descarta en silencio. Este destino escribe una fracción de lo que se le ofrece, así que la mayoría de sus líneas llevan [`filtered`](https://docs.jmrp.io/ghchronicle/es/sinks/#qué-cuenta-la-línea-de-escritura) y `points=0`, que es lo que guardó y no lo que se le dio. Una entrada más vieja que `max_age`, o demasiado atrasada respecto a la más reciente de su propio flujo para que Loki la acepte, también se cuenta ahí y se informa en una línea de entradas descartadas. ## 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 ``` Dos representaciones cambiaron en 2.6.0, y una de ellas otra vez en 2.6.1. Una línea que ya está en Loki conserva el texto con el que se envió: - Una contribución externa es una línea cuando el elemento se cierra, en el momento en que se cerró, y dice qué le pasó al elemento y no quién lo hizo, porque la fila no dice quién lo fusionó o lo cerró: una pull request dice `USER's pull request OWNER/REPO#N was merged` o `USER's pull request OWNER/REPO#N was closed without merging`, y una issue `USER's issue OWNER/REPO#N was closed`. Una fila cerrada cuyo estado y cuya marca de fusión no coinciden dice `USER's contribution OWNER/REPO#N`. Un elemento todavía abierto no es ninguna línea desde 2.6.1: su fila se sella al principio de cada día en que se ve abierto, lo que es una lectura y no algo que ocurrió, y abrirlo ya es una línea del feed de eventos, con `action="opened"`. 2.6.0 enviaba esa fila como `... is open`, y solo el día en que una pasada de `outbound` escribía en la primera hora del día UTC. Antes todas las líneas decían `USER merged OWNER/REPO#N`, issues abiertas incluidas. - Una release dice `published release TAG of OWNER/REPO`, o `published prerelease TAG of OWNER/REPO`, en el momento en que se publicó. Antes decía `release TAG of OWNER/REPO, N downloads`, así que una consulta que filtre por `downloads` solo encuentra las líneas antiguas. `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, que es la mitad del `max_chunk_age` del ingester, una hora 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". Contra Loki 3.7.7 con sus límites por omisión, un stream con una entrada de hace cinco minutos aceptó una de hace 55 minutos y rechazó una de hace 75. Así que el horizonte se aplica de dos formas: 1. contra el reloj de pared, 2. contra la entrada más nueva que se ha enviado antes a ese stream. Dentro de un mismo envío nada va atrasado, porque el destino envía cada stream de la más vieja a la más nueva y Loki juzga cada entrada contra la más nueva anterior a ella: un envío con entradas de hace 23 horas, 12 horas y un minuto a un stream vacío se aceptó entero. 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 es la ventana por defecto de Loki. Súbelo solo si has subido a juego el `max_chunk_age` de Loki, ya que la ventana es la mitad. Una release es el evento que muestra lo que cuesta el horizonte, y uno de los dos streams que miran más atrás. Su línea se genera a partir de `gh_release_published`, en el momento en que se publicó la release. Antes salía de `gh_release`, que se sella en la pasada porque sus descargas se mueven, así que cada pasada de repositorios volvía a enviar todas las releases. Medido con 2.5.1 en producción, durante 30,9 horas y 27 pasadas de `repo`, fueron 4.313 de las 10.467 líneas que envió el destino, un 41 por ciento, para las 2 releases publicadas en esas horas. Fechada en la publicación, una release la ve primero la pasada de `repo` siguiente, así que una publicada justo después de que una pasada leyera su repositorio tiene una cadencia entera cuando escribe la siguiente pasada, más lo que esa pasada se retrase: un tick que perdió, las familias más lentas que corrieron antes, un reinicio. Con `repo` y `max_age` en su hora por omisión, `max_age` por sí solo dejaba fuera esa release, y cada pasada posterior solo la veía más vieja. Así que el stream de releases mira atrás la cadencia de `repo` más `max_age`: dos horas por omisión, siete con `repo: 6h`. Una contribución externa es el otro stream que lo hace, por la misma razón: su línea se fecha cuando se cerró el elemento, y la primera pasada que lo ve es la de `outbound` siguiente, así que el stream mira atrás la cadencia de `outbound` más `max_age`, dos horas por omisión. Antes de 2.6.1 tenía solo `max_age`, y un cierre se enviaba solo si la pasada siguiente escribía dentro de la hora, cosa que una pasada horaria no hace con un elemento cerrado en los segundos posteriores a que la anterior leyera las búsquedas, ni, cuando se retrasa, con uno cerrado en los minutos que lleva de retraso. Cada mirada atrás tiene un tope de seis días, un día menos que la semana que permite `reject_old_samples_max_age`, y nunca acorta `max_age`: uno fijado por encima de seis días es el horizonte de estos streams igual que el de cualquier otro. Loki solo rechaza una línea vieja por ir atrasada respecto a una más nueva de su stream, que es lo que la segunda comprobación de arriba sigue haciendo. Una release publicada en la hora anterior a una pasada la envía esa pasada y otra vez la siguiente, la misma línea en el mismo instante, que Loki guarda una sola vez. Una contribución se envía dos veces del mismo modo, y su línea lleva el número de comentarios y el título del elemento, que pueden moverse entre medias: medido contra Loki 3.7.7, una línea enviada otra vez con `comments` pasado de 1 a 2 se guardó como una segunda línea en el mismo instante. Con `-once` la cadencia que cuenta es la del calendario que lanza el binario, así que pon `every.families.repo` y `every.families.outbound` a ese valor. Las dos están en el almacén de métricas en cualquier caso. ## 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í La familia `joblogs`, apagada hasta que `every.families.joblogs` le da una cadencia, 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. Publicar el dashboard desde el colector cambia ese panel por las líneas, 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. Basta con un sink de Loki cuya dirección termine en `/loki/api/v1/push`: se adopta un datasource de Loki que Grafana ya tenga en esa dirección, sin la ruta, y si no lo hay el datasource se crea a partir de ella. Uno que escriba en otro sitio lo dice y toma un `grafana.datasource.loki_uid` en su lugar, y lo mismo un Grafana que llega a Loki por otra dirección. Un token que no puede crear el datasource cuesta este panel y nada más: la ejecución avisa una vez y publica todos los dashboards con la nota. ## Nada que migrar Un stream es su job, su tipo y las etiquetas configuradas, y las etiquetas de un punto van dentro de la línea, así que una versión que mueve una etiqueta no cambia ningún stream, y [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) no tiene nada que hacer aquí. Una línea es lo que se dijo cuando se dijo, y Loki rechaza de todos modos las entradas más antiguas que su ventana. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara Loki con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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 Enviar métricas de GitHub a través de Telegraf: line protocol enviado a http_listener_v2, y la razón de que esto sea un destino en vez de cien. Source: https://docs.jmrp.io/ghchronicle/es/sinks/telegraf/ El destino Telegraf envía los puntos de ghchronicle a Telegraf, que puede reenviarlos a cualquiera de las salidas que Telegraf soporta. ```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. ## Qué hace aquí una migración ghchronicle no ve el almacén que hay detrás de Telegraf, así que lo que una versión dejó allí con otra forma lo decide el registro del fichero de estado de la versión que lo escribió primero. Aplicar el cambio es que `-migrate -yes` diga qué hacer allí, borrar la medida en ese almacén y después volver a leer las familias que la escriben a través de Telegraf con un relleno histórico de esas familias solas, `-backfill -families`, y lo registre como aplicado. Un arranque nunca lo aplica por su cuenta, y a Telegraf no se le envía nada: la relectura que sigue a una migración solo escribe en los almacenes que ghchronicle vació por sí mismo, y un Telegraf que escribe en un fichero guardaría cada fila de ella dos veces. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara Telegraf con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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://docs.jmrp.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. ## Nada que migrar El fichero, y la salida estándar, son un registro de lo que se escribió y cuándo, con la forma de la versión que lo escribió, y [`-migrate`](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) los deja como están. Reproducido en un almacén, un fichero trae de vuelta todas las formas que contiene, y el almacén necesita entonces la migración otra vez. ## Por dónde seguir - [Elegir almacén](https://docs.jmrp.io/ghchronicle/es/sinks/) compara el destino de fichero con los demás, y lleva el registro de escrituras que todos comparten. - [Los dashboards](https://docs.jmrp.io/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 para las métricas de GitHub, uno por almacén, y cómo importar cada uno. Source: https://docs.jmrp.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` | 154 | InfluxDB 3, consultado con SQL | | `ghchronicle-prometheus.json` | 154 | Prometheus | | `ghchronicle-postgres.json` | 154 | PostgreSQL o TimescaleDB, desde el destino que conecta o desde el fichero SQL | | `ghchronicle-graphite.json` | 154 | Graphite, desde el destino de Graphite | | `ghchronicle-elasticsearch.json` | 154 | 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 154 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 | 4 | 4 | 4 | 4 | 4 | 4 | | **Total** | **152** | **152** | **147** | **146** | **128** | **152** | ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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](https://docs.jmrp.io/ghchronicle/es/dashboards/panels/). Todas las secciones salvo el Overview se envían plegadas, por eso Lifetime es ahí una cabecera. ## Dejárselo al binario El dashboard se construye con el mismo código que lleva el binario, así que el binario puede publicarlo. Dándole un Grafana, reconcilia el datasource a partir del sink al que ya escribe, le pregunta a ese datasource si de verdad se le puede alcanzar, y publica el dashboard de cada almacén al que escribe. ```yaml grafana: url: http://localhost:3000 token: ${GRAFANA_TOKEN} ``` ```sh ghchronicle -config config.yaml -publish-dashboard ``` No le pregunta nada a GitHub, así que no necesita token de GitHub, e imprime lo que hizo con cada datasource y cada dashboard. > **Una contraseña de la máquina no se copia** > > pgx lee el entorno de libpq y su fichero de contraseñas, que es lo que quiere > el sink, porque es quien se conecta. Un datasource es otra cosa: se escribe > en un Grafana que ve más gente, así que solo se envía una contraseña que > lleve el propio DSN. Una que venga de `PGPASSWORD` o de un `.pgpass` de la > máquina que publicó se nombra en una línea y se queda donde está. > **Qué tiene que poder hacer el token** > > Publicar un dashboard necesita Editor. Crear un datasource necesita > `datasources:create` y corregirlo necesita `datasources:write`, y un Editor > no tiene ninguno de los dos: un token sin ellos llega hasta el dashboard y es > rechazado en el datasource, diciendo cuál le falta. Así que una cuenta de > servicio que gestione su propio datasource es Admin, o un Editor con los dos > concedidos; una que solo publique contra un datasource nombrado en > `grafana.datasource.uid` es Editor, porque adoptar lee y nunca escribe. El > datasource de Loki nunca detiene una publicación: con un sink de Loki, un > Editor adopta un datasource de Loki que Grafana ya tenga en la dirección del > sink, lee tal cual un `ghchronicle-loki` que creara una ejecución anterior, > con un aviso de que no pudo reescribirlo, y cuando no hay ninguno de los dos > la creación se rechaza, un aviso lo dice y solo el panel de la salida de los > jobs fallidos queda como su nota. `grafana.datasource.loki_uid` nombra el que > hay que leer y evita la pregunta. > **Grafana suele llegar al almacén por otra dirección** > > La dirección a la que escribe el colector y la dirección que consulta Grafana > no suelen ser la misma: un colector en el anfitrión escribe a un puerto > publicado mientras que Grafana en un contenedor llega al mismo almacén por su > nombre en la red de contenedores. Copiar la dirección del sink produce un > datasource que Grafana acepta y no puede usar, así que la pasada se lo > pregunta antes de publicar y se niega ante un datasource que no responde. > `grafana.datasource.url` es la dirección que usar en su lugar. Tres de los cinco sinks describen su propio datasource sin decirles nada más, porque aquello a lo que escriben es lo que Grafana consulta: InfluxDB, Elasticsearch y el sink de PostgreSQL que conecta, cuyo DSN lleva el servidor, la base, el usuario y, cuando el propio DSN la escribe, la contraseña. Otros dos necesitan la dirección y nada más, porque escriben en un sitio que no es al que va una consulta: el sink de Prometheus se raspa en vez de recibir escrituras, y el de Graphite habla el puerto de ingesta mientras que Grafana pregunta a la API web, en otro puerto. Dales la dirección y el datasource se crea igual: ```yaml grafana: datasource: url: http://prometheus:9090 ``` El que no se puede describir de ninguna manera es el sink SQL, el que escribe sentencias a un fichero: nunca se conecta, así que no hay host, puerto, usuario ni contraseña en ninguna parte de su configuración. Ese, y cualquier datasource que prefieras gestionar tú, se nombra en su lugar: ```yaml grafana: datasource: uid: ae3x9k2 ``` Un datasource nombrado así se adopta y se deja exactamente como está, porque corregir uno que no hizo esto sobreescribiría ajustes que nadie le pidió tener. `publish_on_start: true` hace lo mismo una vez al arrancar el colector, antes de la primera pasada. Viene apagado, y es lo que evita que un servidor se quede callado por detrás del binario que lo alimenta: el dashboard se genera del código, así que actualizar el binario actualiza el dashboard. Un fallo ahí avisa y la pasada sigue, porque las métricas de una hora sin recoger no se recuperan y un dashboard publicado al siguiente arranque sí. ### Qué crea, y con qué nombres Aquí no hay nada que asigne Grafana, así que no hay nada que leer de vuelta y escribir en tu configuración. Los dos uid salen del nombre del almacén, y la misma ejecución dos veces escribe en los mismos dos sitios: | Almacén | uid del dashboard | Su datasource | | --------------- | --------------------------- | ------------------------------ | | `influxdb` | `ghchronicle-influxdb` | creado, desde el sink | | `elasticsearch` | `ghchronicle-elasticsearch` | creado, desde el sink | | `postgres` | `ghchronicle-postgres` | creado, desde el dsn | | `prometheus` | `ghchronicle-prometheus` | creado, si le das la dirección | | `graphite` | `ghchronicle-graphite` | creado, si le das la dirección | Un datasource que esto crea toma el uid del dashboard, así que los dos son `ghchronicle-`, y uno que nombres tú conserva el uid que ya tenga. El de Loki, cuando la dirección del sink explica dónde encontrarlo, es `ghchronicle-loki`, salvo que Grafana ya tenga un datasource de Loki en esa dirección y el sink no lleve `tenant_id`: ese se adopta y se deja como está, porque un datasource de Loki sin tenant es su dirección y nada más. Con tenant se crea siempre, porque el tenant viaja en un secreto que Grafana nunca devuelve. El datasource se crea la primera vez y se corrige después, y solo se comparan los campos que esto escribe, así que un tiempo de espera o una descripción que le hayas puesto tú se quedan como están. Uno que lleve credencial se escribe en cada ejecución, porque Grafana informa de qué secretos hay puestos y nunca de su valor: un token que rotes en la configuración no se puede ver desde fuera, y escribirlo es la única manera de asegurar que el datasource no sigue con el viejo. El dashboard se publica con `overwrite`, así que la segunda ejecución actualiza la primera en vez de añadir otra. Eso es lo que hace que `publish_on_start` se pueda dejar encendido. Una ejecución nombra además lo que encuentra bajo un uid al que ya no escribe. El caso que deja uno es cambiar de almacén: el dashboard y el datasource del viejo se quedan donde están, apuntando a algo que nadie llena, y el uid del nuevo es otra cadena, así que nada los sobreescribe. El aviso dice qué uid y qué almacén, y ahí se queda. Borrar un dashboard sin que nadie lo pida no es cosa de un colector, ni siquiera de uno que lo hizo él: puede ser la copia que alguien sigue mirando, o una que ha editado desde entonces. ### Si el dashboard ya está en otro sitio Importar el JSON desde la interfaz conserva el uid que trae el fichero, así que un dashboard importado así es el que esto sobreescribe y no hay nada que hacer. Solo cambia si se le pidió a Grafana importarlo como nuevo, o si se le cambió el uid a mano: entonces el uid generado está libre, una publicación crea un segundo dashboard al lado del que se está mirando, y el que está abierto deja de ser el que se actualiza. Nombra el que ya existe y se escribe ese: ```yaml grafana: dashboard_uid: mi-dashboard-existente ``` ### Quitarlo todo otra vez `-uninstall` quita lo que esto puso, y solo eso. Recibe la lista de qué quitar, y sin `-yes` no quita nada e imprime lo que quitaría, porque la alternativa es una orden mal tecleada que vacía un almacén. ```sh ghchronicle -config config.yaml -uninstall all # dice qué se iría ghchronicle -config config.yaml -uninstall all -yes # y entonces se va ``` | Objetivo | Qué se va | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `dashboard` | Los dashboards que publicó, y los datasources que haya creado él | | `data` | Toda tabla del almacén cuyo nombre empiece por `gh_` | | `state` | El fichero de estado, el libro de deduplicación, la caché, los puntos de control del relleno y de la relectura y el fichero de bloqueo que hay a su lado | | `all` | Los tres de arriba | Con `-yes`, `data` y `state` toman antes el bloqueo que hay junto al fichero de estado, y se niegan, nombrando el proceso, mientras lo tiene el servicio o un `-migrate -yes`: lo que quitan es lo que ese proceso sigue escribiendo, y el fichero de bloqueo está entre los ficheros de estado, así que quitado bajo un servicio en marcha dejaría al siguiente `-migrate -yes` correr a su lado. Un datasource nombrado en `grafana.datasource.uid` o en `grafana.datasource.loki_uid` no se quita nunca, ni tampoco un datasource de Loki que haya adoptado: cada uno era de otro antes de que esto corriera y sigue siéndolo. Un objetivo que no reconoce se rechaza entero, en vez de ejecutar el resto de la lista sin él. Las tablas se le preguntan al almacén en vez de llevarlas compiladas. Una lista dentro del binario serían las mediciones que escribe esta versión, y las que merece la pena quitar son justo las que ya no escribe nadie: lo que recogía una versión anterior, o una familia apagada desde entonces. Preguntando aparecen. No todos los almacenes se pueden vaciar desde aquí, y los que no dicen por qué en vez de callarse, que se leería como que no hay nada que quitar. Graphite no ofrece borrado, así que sus ficheros whisper se quitan a mano. El sink de Prometheus se raspa en vez de recibir escrituras, así que no guardó nada que quitar. El fichero del sink SQL sí se borra, pero las filas que ya cargaste de ahí a una base de datos de verdad las cargaste tú y hay que quitarlas allí: este sink emite sentencias y nunca se conecta. ## 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 en la que escribe el destino `postgres`, o 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 Una sola lista de secciones y paneles produce los cinco, así que un panel arreglado queda arreglado en todos y un almacén que se añade los hereda. Lo que eso significa para ti es la parte que importa: un dashboard editado en Grafana es tuyo hasta que la siguiente publicación lo sobreescriba, así que guarda tus cambios en una copia con un uid propio y nómbrala en `grafana.dashboard_uid`. Cómo funciona el generador, y las comprobaciones que mantienen en paso los ficheros y las consultas de los paneles, están en [CONTRIBUTING](https://github.com/jmrplens/ghchronicle/blob/main/CONTRIBUTING.md). ## 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 cuatro paneles, una captura de cada una, y qué cambia cuando el almacén no puede responder. Source: https://docs.jmrp.io/ghchronicle/es/dashboards/panels/ Un dashboard, renderizado una vez por almacén. Diecisiete secciones, ciento cincuenta y cuatro 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](https://docs.jmrp.io/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. Un repositorio se distingue por su nombre completo en todos los paneles. Dos propietarios pueden dar el mismo nombre a un repositorio, como una cuenta y una organización a la que pertenece, que tienen cada una su `.github`, y una consulta que agrupaba, unía, contaba o tomaba la fila más reciente solo por el nombre corto convertía los dos en uno: "Open the longest" listaba `alice/x#5` y `acme/x#5` como una sola pull request, y cada gráfica por repositorio los dibujaba como una sola serie. Desde la 2.6.1 todos los almacenes identifican un repositorio por su nombre completo, y el exportador conserva el nombre completo en cada gauge que nombra uno. Una tabla sigue mostrando el nombre corto, así que los dos son dos filas con el mismo nombre, y el selector de repositorio sigue filtrando por él. Una gráfica en InfluxDB y PostgreSQL nombra una serie por su nombre corto salvo que dos repositorios del rango lo compartan, y entonces por sus nombres completos; los otros tres almacenes nombran ambas por el nombre corto. Una tarjeta que es una mediana o una proporción no tiene número en un rango sin nada que medir, y desde la 2.6.2 dice qué le faltó al rango en vez de dibujar un hueco bajo su etiqueta: "none merged" bajo el tiempo hasta fusionar y las líneas por pull request, "no issue closed", "none decided" bajo la tasa de éxito, "no runs" y "no jobs" bajo la duración de la ejecución y la espera en cola, "no commits" bajo la proporción firmada, y "no deliveries" en la tasa de fallo de los webhooks. Las palabras bajo una tarjeta se dibujan en el color del texto, no en el del umbral más bajo de la tarjeta, que es rojo para la tasa de éxito y la proporción firmada: "none decided" no es una compilación fallida. Un recuento sobre un rango así da 0. Un total tampoco tiene número, ya que en los almacenes SQL la suma de ninguna fila es nula, y también dice qué le faltó al rango: "no traffic" bajo las visitas y los dos recuentos únicos, "not read" bajo las estrellas y los forks, el almacenamiento de artefactos y la caché, "no commits" bajo las líneas añadidas y eliminadas, "no releases" bajo el total de descargas, "none open" bajo las alertas abiertas, y "no usage" bajo el gasto. Antes de la 2.6.2 un grupo de totales de los que el rango no tenía ninguno, el tráfico o las alertas abiertas de un repositorio sin ellos, era un panel sin nada en absoluto, ni siquiera los nombres: Grafana dimensiona el texto de una tarjeta por su valor, y uno vacío se dibuja sin tamaño. Sobre un rango o un repositorio sin nada, todos los almacenes dibujan las tarjetas que dibujan los almacenes SQL, con 0 o sin valor donde ellos lo hacen: Graphite para una ruta que nunca ha tenido, Elasticsearch para un rango en el que no tiene ningún documento, y Prometheus para una consulta que no encuentra ninguna serie. Antes de la 2.6.2 una tarjeta así salía de su grupo en esos tres, y sobre un repositorio sin nada faltaban dieciocho tarjetas en Graphite y veinte en Elasticsearch. Hasta la 2.6.4 quedaba un caso, en Elasticsearch: un valor sumado del documento más reciente de cada repositorio, release o alerta, los recuentos de estrellas y forks, el almacenamiento de artefactos y la caché, el total de descargas y las alertas abiertas, salía de su grupo cuando no había tal documento, ya que el datasource falla sin más con esa agregación sobre nada, y la tasa de éxito, la duración de la ejecución y la espera en cola junto a los dos totales de bytes salían también, porque ese panel sumaba todos sus valores y habría leído como 0 un valor de nada. Esos cuatro grupos suman ahora sus valores en las propias transformaciones del panel: cada valor llega con el nombre de su tarjeta, una vez por repositorio, release o alerta donde se suma, los valores de un mismo nombre se suman entre sí, y un nombre bajo el que no se leyó nada queda sin valor. Una consulta que responde solo el nombre, sobre cualquier rango, ocupa el lugar de cada total donde nada responde, así que todos los grupos dibujan lo que dibujan los demás almacenes. Sobre un rango al que no llegó ningún barrido, los grupos de las instantáneas de la propia cuenta, de las que los almacenes SQL leen la fila más reciente del rango, también dicen qué le faltó al rango: desde la 2.6.4 cada tarjeta de Community, Account, Since the account began, Sponsorship y Repositories dice "not read" en todos los almacenes, y también cada barra de Contribution mix (last year), que lee la instantánea que leen las contribuciones de Account. Hasta entonces los cuatro primeros grupos decían "No data" en los cinco, ya que los almacenes SQL no tenían fila que leer, el recuento de repositorios salía de su grupo en los cinco, llevándose consigo las estrellas y los forks en Elasticsearch, y el medidor de barras decía "No data" en todos los almacenes menos Graphite, que dibujaba sus cuatro nombres sin nada al lado. Antes de la 2.6.2 Graphite, cuyas rutas responden con nulos a un rango en el que no tienen nada, dibujaba tres de los grupos como un panel sin nada, ni siquiera los nombres, y Elasticsearch dibujaba los cuatro nombres de Sponsorship sin nada al lado. Un año sin ninguno de los cuatro tipos de contribución tampoco tiene mezcla que dibujar. Las barras leen la instantánea más reciente del rango, como la tarjeta Contributions, así que cuando esa instantánea no tiene ninguno de los cuatro cada barra dice "not read" junto a una tarjeta Contributions de 0, en todos los almacenes y aunque una instantánea anterior del rango sí tuviera alguno. Hasta la 2.6.4 los almacenes SQL y Graphite dibujaban ahí la mezcla de esa instantánea anterior: los almacenes SQL dejaban fuera toda instantánea cuyos cuatro tipos sumaban 0, y las barras de Graphite tomaban la última proporción que tenía el rango, pasando por alto la instantánea que no tiene ninguna. ## 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, clonadores únicos), 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 de un repositorio vivo es su `gh_repo`, que una pasada escribe cada hora, y la de uno archivado es su `gh_repo_total`. Un repositorio que el filtro por omisión aparta por estar archivado no recibe fila de `gh_repo` de ninguna pasada, así que el selector deja de ofrecerlo cuando el último relleno histórico queda atrás, pero la gente sigue dándole estrellas y haciéndole fork y cada pasada de `totals` vuelve a leer sus contadores. Con All seleccionado las sumas lo incluyen. Los cuatro almacenes que suman sobre el rango del dashboard pueden dejar fuera su fila en un rango más corto que la cadencia de `totals`, una hora por omisión; Prometheus no toma rango aquí, porque cada suma es una consulta instantánea sobre lo último que empujó el recolector en marcha. Un repositorio archivado que la configuración ya no recoge se queda fuera, como el selector deja fuera a uno vivo: la fila que escribió un relleno histórico con una configuración anterior sigue en el almacén, y en la cuenta con la que se comprobó esto un fork archivado así sumaba 8 estrellas y 3 forks, 393 y 106 donde GitHub daba 385 y 103 para lo que recogen las pasadas. Los dashboards SQL cuentan una fila archivada solo de un repositorio con una fila de `gh_repo_total` en los últimos siete días, la ventana del propio selector. Elasticsearch y Graphite no pueden preguntar eso de una ventana mientras leen otra, así que cuentan un repositorio archivado por sus filas de los últimos siete días, y un rango que terminó hace más de una semana deja fuera los archivados en esos dos. Prometheus solo guarda lo que empuja el recolector en marcha, así que no tiene esa fila que dejar fuera. Elegir repositorios cuenta solo esos. Cada repositorio cuenta una vez, por su nombre completo: dos repositorios del mismo nombre de dos dueños son dos, y uno archivado mientras el recolector funciona no se cuenta a la vez por su fila viva y por la archivada. El recuento de repositorios junto a las sumas es el que da GitHub de los repositorios públicos de la cuenta, que es otro conjunto: tiene los forks públicos que el filtro por omisión deja fuera, y ninguno de los repositorios privados que las sumas incluyen. La [tarjeta SVG](https://docs.jmrp.io/ghchronicle/es/card/) solo suma `gh_repo`, así que sus estrellas y forks dejan fuera los repositorios archivados que el filtro por omisión aparta, y que estas sumas sí cuentan. En la cuenta en la que se midió, el 2026-09-27, el filtro por omisión apartaba diecisiete, con 80 estrellas y 24 forks. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/assets/dashboards/overview.png) La tercera tarjeta de tráfico cuenta a quienes clonaron y no los clones. La integración continua clona todo el día, así que el recuento de clones va junto a la cifra que lo explica y no en un titular: medido en la cuenta con la que se leyó esto, un repositorio se clonó 135.683 veces en quince días desde 1.807 clonadores, y 186 K junto a 2,89 K visitas se lee como audiencia. El recuento está en dos paneles de la sección Audience. La captura de arriba es anterior a ese cambio y todavía pone "clones". Lee `gh_account`, `gh_repo`, `gh_repo_total`, `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 en cuántos hilos se comentó 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/assets/dashboards/lifetime.png) La última tarjeta cuenta hilos y no comentarios, cada issue o pull request una vez por muchos comentarios que dejara la cuenta en ella, y solo en repositorios que no son suyos, el mismo fuera que la tarjeta de al lado. La captura de arriba es anterior a ambas cosas: su tarjeta pone "Comments elsewhere", y sus 96 incluían hilos de los repositorios propios de la cuenta. 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. "Every repository, ever" lista todos los repositorios que tiene el selector, forks y archivados incluidos cuando las pasadas los recogen, porque eso es lo que dice el título. Se ordena por commits, lo que en una cuenta con forks de proyectos concurridos significa que la historia de otra persona supera a todo lo que ha escrito la cuenta: 370.296 commits frente a 3.385 donde se leyó esto. Así que las marcas de fork y archivado son columnas, y la tabla se abre ordenada por la primera y luego por commits, lo que pone los repositorios propios en la primera pantalla y los forks debajo sin dejar ninguno fuera. Con All seleccionado lista también los repositorios archivados que el filtro por omisión aparta, con sus contadores actuales, los ofrezca o no el selector: ninguna pasada les escribe fila de `gh_repo`, que es lo que lista el selector, pero la misma fila de cada uno se vuelve a leer en cada pasada de `totals`. Uno que la configuración ya no recoge se queda fuera, como en el Overview, y Elasticsearch y Graphite aplican aquí también la regla de los siete días del Overview: un rango que terminó hace más de una semana deja fuera los repositorios archivados que el filtro por omisión aparta. Cada fila es la más reciente del repositorio dentro del rango, una por nombre completo, y no el valor más alto que alcanzó cada columna en él: estrellas, ramas, etiquetas, releases e issues abiertos bajan, y una tabla de máximos daba a un repositorio 4 estrellas por la fila de un relleno histórico de hacía nueve días donde GitHub y su fila más reciente decían 3. Un repositorio archivado dentro del rango tiene filas de antes del archivado y de después, y sigue siendo una fila en todos los almacenes. Cuatro de ellos lo marcan como archivado; Graphite guarda solo los commits, sin columna de fork ni de archivado. Elasticsearch solo toma una fila archivada si es de los últimos siete días, así que un repositorio archivado dentro de un rango que terminó hace más de una semana aparece allí por sus filas de antes del archivado, como no archivado. Debajo de la tabla, los repositorios creados y los archivados, cada uno con la fecha en que ocurrió y no la de la pasada que lo vio, y las ejecuciones de workflow que ha tenido cada repositorio, del total del propio listado de ejecuciones. Lee `gh_account_total`, `gh_repo_total`, `gh_repo_created`, `gh_repo_archived` y `gh_workflow_run_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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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. Stars gained over time cuenta `gh_star_day`, el historial diario de estrellas de GitHub, y también la curva acumulada en InfluxDB y PostgreSQL; ghchronicle lee el historial entero en la primera pasada para cada repositorio, salvo con GitHub Enterprise Server, que no lo sirve. Así la curva se remonta a la primera estrella sin que el colector lleve corriendo tanto tiempo, y un repositorio cuya lista de stargazers GitHub oculta al token se cuenta con los demás: desde julio de 2026 GitHub solo sirve esa lista a los [administradores y colaboradores](https://docs.github.com/es/rest/activity/starring#new-access-restrictions) del repositorio, y el historial a cualquiera que pueda ver el repositorio. Cada día es el día natural del Pacífico de GitHub, así que una estrella dada una mañana europea puede quedar un día antes del momento que muestra Recent stars, y quitar una estrella dada en las últimas treinta semanas la saca del día en que se dio. La curva suele quedar en el número que muestra GitHub o un poco por debajo, ya que ese número también cuenta cuentas que GitHub ya no lista, pero una estrella dada hace más de treinta semanas y retirada después puede seguir en ella, así que también puede quedar por encima: solo un backfill que llegue hasta su día rebaja ese día, y solo mientras el día conserve otra estrella. Recent stars nombra a personas, así que lee `gh_star`, y un repositorio cuya lista está oculta tiene sus estrellas en los dos paneles de encima y ningún nombre ahí. Prometheus sigue contando Stars gained y Recent stars desde la lista de stargazers, así que solo donde el token puede leerla, mientras que su Stars over time es el número actual de cada repositorio; en Graphite y Elasticsearch la curva es el número de estrellas de los repositorios tal como lo leyó cada pasada, y empieza el día en que arrancó el colector. Los forks en el tiempo son también un recuento acumulado en InfluxDB y PostgreSQL, 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. Los otros tres dibujan el número de forks que leyó cada pasada, desde el día en que arrancó el colector o el exportador. 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 en un rango de mediados de junio a mediados de septiembre: Stars gained over time como barras diarias apiladas por repositorio para cli, docs-site, edge-cache, parser y telemetry, con los días de más estrellas en cuatro; Stars over time como una sola línea que sube despacio hasta 350, con una leyenda que marca una media de 309 y un máximo de 350; Stars by repository como barras horizontales, telemetry 148, cli 89, parser 62, edge-cache 37 y docs-site 14; Recent stars como una tabla de usuario, momento y repositorio, la más reciente dada el 12 de septiembre; y Forks over time subiendo despacio hasta 51, con una media de 47](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/assets/dashboards/stars-and-forks.png) Lee `gh_star_day`, `gh_star`, `gh_fork` 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, un punto por día de la semana y hora, así que una suma sobre un rango contaría todas las pasadas. Cada panel suma todas las celdas de la rejilla más reciente de cada repositorio en el rango, por hora o por día de la semana, y lo que se lee en esos paneles es la forma. Graphite guarda cada celda como una serie propia, así que una celda que un historial reescrito dejó vacía mantiene allí su última cuenta, donde los almacenes SQL y Elasticsearch leen solo la rejilla más reciente. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/assets/dashboards/contributions.png) Lee `gh_contribution_day`, `gh_contribution_day_repo`, `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 revisión de otra persona, 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. Cada una guarda los veinticinco que llevan más tiempo abiertos, el más antiguo primero; Prometheus, que no guarda ninguna pull request suelta, lista los veinticinco repositorios cuyas pull requests abiertas llevan más tiempo esperando de media. Elasticsearch solo puede quedarse con los valores de arriba dentro del bucket de cada repositorio, así que guarda los que llevan más tiempo abiertos en cada uno y la tabla los corta a veinticinco; antes de 2.6.1 guardaba los veinticinco de cada repositorio con más filas, que en un repositorio con más elementos abiertos podían ser cualesquiera. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/assets/dashboards/pull-requests-and-issues.png) > **Cuidado al leer la espera de 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 human review" en vez de > los pocos segundos de los bots. Se llama así por eso, porque "Time to first review" > sobre un No data se leía como que no se había revisado nada: medido aquí el > 2026-09-17, 703 de 825 pull requests en quince días tuvieron una primera > revisión y ninguna la tuvo de otra persona, y el campo tiene catorce filas en > todo el almacén. 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. > **Las listas de qué hacer ahora dejan fuera lo que no se puede hacer** > > "Open the longest", "Open issues the longest", "Workflows that never ran" y > "Stale branches" son los cuatro paneles que se leen como una cola de trabajo. > En una cuenta con 17 repositorios archivados y 22 forks de proyectos de otras > personas, los cuatro los encabezaban filas sobre las que no se puede hacer > nada: las ocho pull requests abiertas más antiguas eran de dependabot en dos > repositorios archivados, todos los workflows que nunca se habían ejecutado > estaban en uno archivado, y 1.083 de 1.208 ramas eran de un fork. Así que los > dos dashboards SQL unen el repositorio de cada fila con `gh_repo`, la única > medida que lleva las marcas de fork y archivado, y dejan fuera los archivados > en los tres primeros y tanto los archivados como los forks en el de ramas; > los tres primeros conservan una columna Fork, porque la pull request de un > fork sigue siendo una que se puede fusionar. Prometheus, Graphite y > Elasticsearch no tienen join, así que allí esas filas se quedan y cada panel > lo dice. Lee `gh_pull_request`, `gh_pull_request_review`, `gh_review_thread`, `gh_issue` y, para las marcas de archivado y de fork, `gh_repo`. ## 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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. Toda mediana de los dashboards de InfluxDB y PostgreSQL es exacta, así que cuatro jobs que esperaron 30, 35, 60 y 65 segundos leen 47,5 en los dos, donde la estimación con la que respondía InfluxDB leía 52. El percentil 95 de la duración de las ejecuciones y el 90 del tiempo hasta la fusión siguen siendo estimaciones en InfluxDB, porque InfluxDB 3 Core no tiene un percentil exacto antes de la 3.9.0, y los dos paneles lo dicen. 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. Lista un workflow en cuanto ha fallado más de tres veces en el rango, siendo un fallo cualquier conclusión que no sea success, y los cinco almacenes hacen esa misma pregunta. "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. El recorrido de una pasada se para en cinco páginas, quinientos artefactos, y cualquier recorrido se para antes cuando falla una página del listado y la pasada se queda con lo que había leído, así que cualquiera de las dos cosas puede dejarlo corto. Lleva una tercera cuenta, los artefactos vivos entre los recorridos, porque el total del propio GitHub incluye los que ya ha caducado y el tamaño no. Las cuentas salen de la fila más reciente de cada repositorio, la misma que lee la tarjeta, así que un recorrido más completo antes dentro del rango no puede tapar uno corto. La tarjeta del principio de la sección se llama por aquello sobre lo que está, y todo panel que enseña el tamaño enseña esas cuentas o dice en su descripción que es un suelo. Lee `gh_workflow_run`, `gh_workflow_job`, `gh_workflow_step`, `gh_workflow`, `gh_artifact`, `gh_artifact_total`, `gh_actions_cache` y, para las marcas de archivado y de fork, `gh_repo`. ## 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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. Las dos leen una fila por comentario en todos los almacenes, aceptado si cualquiera de sus filas lo dice, porque un almacén escrito antes de 2.6.1 y después puede guardar un comentario en más de una fila, por la razón que da [Cómo leer las tablas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cómo-leer-las-tablas). 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. Debajo, los webhooks configurados, los entornos, las ramas estancadas, las reglas de protección de rama, las reglas de cada ruleset con quién puede saltárselas, cuándo cambió cada ruleset, y los despliegues en el tiempo y por entorno. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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, que el colector adopta o crea a partir de un sink de Loki o toma de `grafana.datasource.loki_uid`, 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_ruleset_rule`, `gh_ruleset_version`, `gh_deploy_key`, `gh_environment`, `gh_branch`, `gh_branch_protection`, `gh_deployment` y, para las marcas de archivado y de fork de las ramas estancadas, `gh_repo`. ## 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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. Debajo, las alertas abiertas más antiguas, los ajustes de seguridad, la configuración por defecto de code scanning, los permisos del token de los workflows y los secretos con cuándo se rotó cada uno por última vez. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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 que cuentan alertas abiertas 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. Una serie es un repositorio por su nombre completo, así que dos repositorios de dos propietarios con el mismo nombre son dos, como lo son en los bytes de artefactos y de caché de "Runs in range" y en "Downloads", que suman sus lecturas más recientes del mismo modo. La tabla de funciones y las cuatro tablas del final de la sección, los ajustes, la configuración de code scanning, los permisos del token y los secretos, también son estado actual, y leen la fila más reciente de cada repositorio, así que una función apagada o una alerta arreglada dentro del rango se leen como están y no como estaban en su punto más alto. Elasticsearch hace lo mismo reduciendo cada serie a su documento más reciente y leyendo ese, y es la excepción en un sitio, que lo dice en su descripción: su curva de alertas abiertas toma la serie más grande de cada severidad en un bucket en vez de sumarlas. Graphite es la excepción en tres, y lo dice en cada una: sus ajustes, su configuración de code scanning y sus permisos del token nombran cada fila a partir de la ruta, de la que forman parte el `status` de un ajuste, el `state`, la `query_suite` y el `schedule` de la configuración, y los `permissions`, así que un cambio dentro del rango son dos filas, la vieja y la nueva. Dos 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. "Oldest open alerts" lista las filas de alerta que dicen abierta, que no siempre son las filas de las que salen esos recuentos. Una pasada escribe una fila por cada una de las cien alertas más recientes de un repositorio, y uno con más toma sus recuentos de una lectura solo de las abiertas. Así que una alerta abierta detrás de cien más nuevas, levantada antes de que arrancara el colector, se cuenta y no se lista, y una alerta arreglada mientras estaba detrás de las cien más recientes sigue listada como abierta, hasta que un relleno histórico lee la lista entera. Lee `gh_dependabot_alert`, `gh_dependabot_alert_item`, `gh_code_scanning_alert`, `gh_code_scanning_alert_item`, `gh_code_scanning_analysis`, `gh_security_feature`, `gh_security_setting`, `gh_code_scanning_setup`, `gh_actions_policy` y `gh_secret`. ## 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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. Un cargo que no pertenece a ningún repositorio, que es lo que es un asiento de Copilot, se queda fuera de esa tabla en todos los almacenes: es una fila de la factura y no un repositorio llamado (none). Los totales de gasto de arriba sí lo incluyen. 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. Cada fila de esa tabla es una caché de un repositorio tal como está: las filas del último día del rango en que el recolector leyó las cachés de ese repositorio, una por ref, sumadas, así que Entries cuenta las entradas que guarda GitHub y no las filas que leyó la tabla. Una ref que GitHub expulsó conserva su última fila en el almacén, y sumar la fila más reciente de cada ref del rango la contaba: en la cuenta con la que se comprobó esto, una caché daba 128 entradas y 9,46 GiB en 108 refs donde el día más reciente, y GitHub, tenían 44 y 3,08 GiB en 24. InfluxDB, PostgreSQL y Elasticsearch leen el día más reciente de cada repositorio. Graphite no puede encontrarlo, así que lee el último día UTC del rango: su tabla está vacía desde la medianoche UTC hasta la primera pasada de `actions` del día, y muestra solo el tamaño, porque una tabla de Graphite se queda con una columna. Prometheus guarda el último valor empujado de cada ref, así que una ref expulsada sigue contando ahí hasta que el exportador la descarta un día después o, empujada por OTLP, hasta que el proceso se reinicia. 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/assets/dashboards/activity.png) Los eventos y las notificaciones son ventanas, no historias. GitHub guarda los últimos trescientos eventos de los últimos treinta días y descarta las notificaciones a los tres meses salvo las guardadas, así que lo que queda almacenado 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. Cada tipo es un sector con un color propio y con su propio nombre en todos los almacenes: la consulta responde una fila por tipo, y el panel hace de cada fila un campo, porque Grafana colorea un pastel por campo y una columna de cuentas es un solo campo, que pintaba todos los sectores del mismo verde. 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. El exportador de Prometheus guarda el lenguaje de cada estrella y no su repositorio, así que allí la segunda tabla tiene una fila por lenguaje, con la media de estrellas de los repositorios marcados en él. La tabla del trabajo en otros repositorios lleva ese mismo número para los repositorios a los que contribuyó la cuenta: las estrellas de cada uno en la pasada más reciente dentro del rango, de `gh_upstream_repo`, unidas a cada elemento en los dashboards de InfluxDB y PostgreSQL y junto a cada repositorio en el de Prometheus. Graphite y Elasticsearch no pueden unir dos medidas en un panel y dejan la columna fuera, diciéndolo. La familia `outbound` escribe esa medida desde 2.6.0, así que un rango sin ninguna fila suya, que es todo rango anterior a la actualización, tiene vacía la columna Stars. Hasta que la primera pasada de `outbound` de 2.6.0 la escribe, InfluxDB y PostgreSQL no tienen tabla que unir y rechazan el panel entero en vez de dejar la columna vacía, lo que cubre [la página de problemas](https://docs.jmrp.io/ghchronicle/es/reference/troubleshooting/#los-datos-parecen-mal). InfluxDB, PostgreSQL y Elasticsearch listan cada elemento una vez, por su fila más reciente dentro del rango, y Elasticsearch no tiene columna Opened, porque esa fecha es la de la fila menos el tiempo que el elemento estuvo abierto, una resta que un bucket no puede hacer. Prometheus tiene una fila por repositorio, y Graphite una por elemento y estado, porque el estado forma parte de la ruta: un elemento visto abierto y luego cerrado dentro del rango son dos filas allí. Lee `gh_event`, `gh_notification`, `gh_external_contribution`, `gh_upstream_repo` 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. Debajo, los ajustes del repositorio, las claves de la cuenta, las cuentas sociales, los cambios de configuración, los ficheros de política, los ecosistemas de Dependabot, y las dependencias por licencia, por ecosistema y según cambiaron. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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`, `gh_policy_file`, `gh_dependabot_ecosystem`, `gh_dependency`, `gh_dependency_license` y `gh_dependency_change`. ## 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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 puede ser un gist, que se nombra por su hash y no está en ningún repositorio que el filtro conozca. Los logros se leen cada hora de la página pública del perfil, porque ninguna API los lista. El recuento de Pair Extraordinaire, pull requests fusionadas en repositorios públicos con un commit en coautoría, es un total que guarda el fichero de estado: cada pasada horaria suma las pull requests fusionadas desde el último día que cubre, y la historia entera se vuelve a recorrer una vez por semana, que es cuando un recuento que bajó, por un repositorio hecho privado o borrado, baja en la tabla. Sin fichero de estado cada arranque la recorre entera. "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 y lo que consiguió hacer: el presupuesto de cada uno de los quince límites de GitHub en el tiempo, una tabla con todos ellos ordenada por cuánto se ha gastado de cada uno, y debajo de esas dos el informe de la fila sobre el propio barrido. ![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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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. Most used y Lowest remaining son en la tabla los extremos del rango, lo más que gastó cualquier lectura y lo menos que le quedaba a cualquiera, así que un cubo agotado hace una hora lo sigue diciendo después de recargarse; Graphite guarda solo el mínimo restante. "Every family" y "What failed, and where" son los dos paneles de debajo, y la captura de arriba es anterior a ambos. El primero lista todos los colectores que corrieron en el rango, qué los detuvo cuando algo lo hizo, en cuántos barridos corrieron y el mayor número de repositorios por los que les preguntó uno de esos barridos, que en `commits`, `issueevents` e `issues` incluye los repositorios en los que la [consulta de movimiento](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió) no encontró nada nuevo y que la familia dejó sin leer; una familia sin fila ahí no corrió en absoluto. El motivo es columna aparte para que los dos tipos de fallo se ordenen por separado: un presupuesto de búsqueda agotado no es el 502 que le costó a un repositorio su historia, y una familia que tuvo ambos tiene una fila de cada. El segundo es una fila por cada repositorio que un colector no pudo recoger, el más reciente primero, con lo que respondió GitHub. Que la segunda tabla esté vacía es el caso bueno, y se dibuja vacía en lugar de rechazada porque las filas de la primera se escriben en cada barrido haya fallado algo o no, así que la medida que hay detrás de las dos existe desde el primero. Están ahí por un fallo que esta página no podía explicar. El 2026-09-16 cinco repositorios de esta cuenta no tenían ninguna ejecución ni trabajo de workflow, cada uno porque una llamada por los trabajos de una ejecución había respondido `502` una vez y el colector había tirado todo lo que había reunido de ese repositorio. La fila Continuous integration se dibujaba sobre una cuenta a la que le faltaban sus cinco repositorios más activos mientras la fila Cost de la misma página decía que uno de ellos había quemado 27,6 K minutos de macOS. Ninguno de los dos paneles usa la variable de repositorio: las filas de familia no pertenecen a ningún repositorio, y un repositorio que un barrido no pudo recoger puede ser uno que la variable ni siquiera lista, porque la variable se construye con filas que escribe ese mismo barrido. Lee `gh_rate_limit` y `gh_collector_family`. ## 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, y compara el texto por sus bytes como InfluxDB, así que una lista sale en el mismo orden en los dos, sea cual sea la intercalación con que se creó la base de datos. - **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 uno o dos números por serie reducidos sobre el rango, así que una tabla que necesita varios campos de una fila se queda con los números que puede llevar una fila y nombra cada columna que suelta, una cadena allí ni siquiera es una métrica, y un booleano se guarda como 1 o 0, igual que cualquier número. - **Elasticsearch** conserva los documentos fechados, así que una tabla por elemento son los documentos más recientes, o el más reciente de cada elemento cuando un elemento tiene varios, y todo lo demás es una agregación por buckets, sobre el subcampo `.keyword` de cada etiqueta. Graphite y Elasticsearch comparten dos límites que los almacenes SQL no tienen. Ninguno puede unir dos medidas en un panel, así que allí Work elsewhere no tiene columna Stars y el perfil de comunidad conserva la bandera de plantilla de issue de la API en vez del número de plantillas. Y ninguno puede preguntar por una ventana mientras lee otra, así que los repositorios archivados que el filtro por omisión aparta cuentan en el Overview y en Every repository, ever por sus filas de los últimos siete días, y un rango que terminó hace más de una semana los deja fuera. Una gráfica o una gráfica de barras que los almacenes SQL pliegan, con sus series más activas nombradas y el resto en una llamada other, se pliega igual en Graphite, y en Prometheus donde el exportador guarda los recuentos que sumaría. Elasticsearch no puede, ya que una agregación terms no dice nada de los valores que no guarda, y cada panel suyo que dibuja todas las series lo dice. 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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 La tarjeta de ghchronicle: un SVG autocontenido de la actividad en GitHub para un README de perfil, dibujado con los mismos puntos que reciben las bases de datos. Source: https://docs.jmrp.io/ghchronicle/es/card/ ghchronicle puede dibujar una tarjeta SVG autocontenida con la actividad en GitHub de una cuenta, para un README de perfil, a partir de los puntos que ya recoge. ```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 leen los mismos números, > con la única excepción que nombra [Qué dibuja](https://docs.jmrp.io/ghchronicle/es/card/#qué-dibuja). ## 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](https://docs.jmrp.io/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](https://docs.jmrp.io/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 los recorridos únicos de la lista de stargazers y del historial diario 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. Cada repositorio cuenta una vez, por su nombre completo, como lo cuenta el Overview: dos repositorios del mismo nombre de dueños distintos, el `.github` de un usuario y el de una organización, son dos repositorios, y los repositorios con más estrellas pueden mostrar ese nombre dos veces. Hasta la 2.6.0 la tarjeta iba por el nombre corto, y uno de los dos sustituía al otro. Esa suma es el único punto en el que la tarjeta y el [Overview](https://docs.jmrp.io/ghchronicle/es/dashboards/panels/#overview) del dashboard se separan. La tarjeta suma `gh_repo`, la fila que una pasada escribe por cada repositorio que recoge. El Overview suma además los repositorios archivados que el filtro por omisión aparta, cuyos contadores lee cada pasada de `totals` en `gh_repo_total`, porque la gente les sigue dando estrellas y haciéndoles fork. En la cuenta en la que se midió, el 2026-09-27, el filtro por omisión apartaba diecisiete, con 80 estrellas y 24 forks que el Overview cuenta y la tarjeta no. ## 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 (por omisión)** 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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@v2 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](https://docs.jmrp.io/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](https://docs.jmrp.io/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://docs.jmrp.io/ghchronicle/es/card/layouts/ La tarjeta de actividad de GitHub tiene varios diseños, y esta orden los lista. ```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](https://docs.jmrp.io/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](https://docs.jmrp.io/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](https://docs.jmrp.io/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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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@v2 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](https://raw.githubusercontent.com/jmrplens/ghchronicle/main/site/src/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@v2 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](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/card/) es lo que las dibuja, y cómo poner una en un README. - [Llamarlo desde un programa](https://docs.jmrp.io/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://docs.jmrp.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 | Toda consulta GraphQL: las pull requests e issues, la cuenta, los commits, las discusiones, los despliegues y el resto, a los que la [tabla de coste](https://docs.jmrp.io/ghchronicle/es/api/cost/) pone precio familia a familia, y la consulta que pregunta qué repositorios se movieron | | `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](https://docs.jmrp.io/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](https://docs.jmrp.io/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` que ningún listado devolvió, 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. La caché sobrevive al proceso. Lo que se pidió dentro del doble de la cadencia más larga se guarda en [un fichero junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), escrito como mucho cada cinco minutos mientras el recolector corre y una vez más cuando se para, y un reinicio lo vuelve a leer, así que su primera pasada pregunta con los validadores que guardó la anterior. Antes de ese fichero, la primera pasada tras un reinicio del servicio del autor hizo 130 peticiones y ninguna recibió un 304. Cada entrada se guarda bajo un resumen de todo lo que decodifica su tipo y no bajo el nombre del tipo, así que una actualización que cambia lo que lee un colector vuelve a pedir enteras las URL de ese colector en lugar de responderlas con un cuerpo guardado sin el campo nuevo. ## 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, el perfil de comunidad de un fork, que GitHub no sirve en absoluto (los 28 forks de la cuenta en la que se midió respondieron 404, y los otros 39 repositorios 200). 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 no vuelve a preguntar. Los rechazos se guardan con la caché de ETags en [el fichero junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), cada uno hasta el final de su propio día, y borrar ese fichero es la manera de volver a preguntar en el acto. 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. ## Una respuesta que GitHub no pudo terminar se pregunta una vez más Cuatro estados dicen que GitHub no terminó una respuesta: un `502` o un `504` es la pasarela que tiene delante rindiéndose con una respuesta que no volvió a tiempo, un `500` es la propia aplicación la que se rinde, y un `503` es un servidor que no pudo atender la petición en ese momento. Los cuatro son intermitentes. Medido en las pasadas de la propia cuenta del autor entre el 2026-09-11 y el 2026-09-29, de 457.098 peticiones REST GitHub respondió 50 con un `502`, cada uno tras diez a once segundos, 2 con un `504` y 9 con un `500`. Siete de esos `500` fueron el listado de artefactos del repositorio con la historia de artefactos más larga, tras ocho segundos y medio: el mismo listado lento, rindiéndose en el límite de la propia aplicación y no en el de la pasarela. El tamaño de la página no es la causa: pedir un artefacto tardó lo mismo que pedir cien. Así que una petición REST que responde cualquiera de los cuatro se pregunta una vez más, dos segundos después, antes de que el colector se entere. De los 21 `502` y `504` que se volvieron a preguntar así desde la 2.6.0, 20 obtuvieron respuesta. Un `500` solo se ha vuelto a preguntar así a mano, una vez, y respondió `500` otra vez; se vuelve a preguntar de todos modos, porque es el mismo listado rindiéndose un paso más adentro, y un reintento que falla cuesta lo mismo que el de un `502`. Esa segunda petición cumple tres cosas: - **Se cobra.** Un `500` y un `502` cuestan cada uno una petición de `core`, incluso cuando se preguntan de forma condicional, donde un `304` no cuesta nada. Así que el reintento pasa por el freno como cualquier otra petición, y un presupuesto que ya está en su reserva no se gasta en él. - **Es condicional.** Lleva el mismo `If-None-Match` que la primera, así que una página que no ha cambiado sigue volviendo como un `304` gratuito. - **Es la única.** Cada intento puede retener a la familia diez segundos, y una petición que falla dos veces se registra y se deja para la pasada siguiente, como antes. Lo que el colector leyó antes se conserva en cualquier caso. El log de un job fallido son dos peticiones: la API responde con una redirección, que se cobra, y el almacenamiento de objetos envía el texto. Cuando el almacenamiento responde uno de los cuatro, se vuelve a preguntar solo al almacenamiento, en la misma URL firmada y sin freno, porque no es la API de GitHub y no gasta presupuesto. A la API no se le pide una redirección nueva. Una consulta GraphQL se vuelve a preguntar una vez más del mismo modo, dos segundos después y pasando por el freno, con una respuesta excluida. GitHub documenta su tiempo límite de GraphQL como un `502` o un `504` cuando una consulta lleva más de diez segundos en marcha, y esa es una consulta demasiado grande para una sola petición. El resto de los cuatro se vuelven a preguntar tal cual, sobre la misma página: un `500`, un `503`, y un `502` o un `504` que volvió antes de diez segundos, que no puede ser ese tiempo límite. Medido en las mismas pasadas, de 66.821 consultas GitHub respondió 42 con un `502` tras 10,45 a 11,20 segundos, 9 con un `504` tras 11,03 a 11,23, y 3 con un `503` tras 0,70 a 1,08 segundos. Cada una de esas tres costó un punto y era una página de un historial de commits, y por eso un fallo rápido no se lee como una consulta demasiado grande: el recorrido de commits tomaba entonces una como el final de lo que podía leer, y cada una de las tres detuvo ahí su recorrido y lo dio por bueno. Una consulta que falla dos veces es un fallo que el colector informa, y la pasada siguiente vuelve a preguntar; también lo es una respuesta que no es JSON y no es ese tiempo límite, que no es respuesta GraphQL alguna. Lo que no se vuelve a preguntar: - **Una negativa.** Cualquier `4xx`, y un `501` o un `505`, es la respuesta que la misma petición recibe cada vez. Un presupuesto agotado le toca esperarlo al freno. - **Una petición que no recibió ninguna respuesta.** El único tipo medido fue un nombre que no se pudo resolver, seis veces, tres de ellas camino del almacenamiento del log de un job. Cada una tardó diez segundos en fallar, que es el resolvedor preguntando dos veces a cada servidor antes de rendirse, así que la resolución ya se había vuelto a preguntar. No apareció ninguna conexión rechazada ni reiniciada, ni una negociación TLS que agotara su tiempo, y una petición cuya conexión persistente se cerró antes de responderla la vuelve a enviar el propio cliente HTTP de Go. - **El tiempo límite de GraphQL de GitHub.** Un `502` o un `504` a una consulta que estuvo diez segundos en marcha significa una consulta demasiado grande para responderla en ellos, con la que la misma consulta volvería a toparse. Los recorridos de pull requests, de commits y de pull requests en coautoría vuelven a preguntar sobre el mismo cursor con media página mientras sea mayor que diez, así que una página de cincuenta se vuelve a pedir con veinticinco, doce y seis, y una consulta sobre varios repositorios a la vez se vuelve a preguntar con la mitad de ellos, hasta uno. Lo que aún agota el tiempo con lo más pequeño, y cualquier otro recorrido o consulta suelta, es un fallo que el colector informa junto con las filas que leyó antes, nunca el final de lo que hay que leer: un relleno no anota ese repositorio como recorrido, y la pasada siguiente lo vuelve a leer. Hasta 2.6.3 un recorrido sin página más pequeña que pedir tomaba el tiempo límite por el final de sus datos y lo daba por bueno, lo que en la cuenta del autor detuvo doce recorridos de commits, todos con páginas de cincuenta; preguntados a mano, veinticinco de los mismos commits respondieron en menos de cinco segundos. ## 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](https://docs.jmrp.io/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 lo que se le pidió es el recorrido entero, y una familia saltada por falta de presupuesto sería una familia que hay que volver a recorrer. Parado a medias, pierde poco: un punto de control junto al fichero de estado guarda su sitio, repositorio a repositorio, y [el mismo comando sigue desde ahí](https://docs.jmrp.io/ghchronicle/es/how/backfill/#pararlo-y-retomarlo). --- # Coste de una pasada El precio medido de una pasada, por familia, y qué familias alargar cuando el presupuesto aprieta. Source: https://docs.jmrp.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: caché de ETag vacía, un mes de ejecuciones de workflows. Un servicio reiniciado ya no empieza ahí: vuelve a leer su caché del [fichero junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado), y solo uno que haya perdido ese fichero vuelve a pagar la caché vacía. **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 la puesta al día 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 | 1h | 1 pt, 7 core | 1 pt, 1 core | una consulta: el calendario, los totales, los pins, el bloque de sponsors y las listas de estrellas; 66 KB, nunca comprimida, nunca condicional; a su lado el perfil, por las organizaciones que el `following` de GraphQL deja fuera, que GitHub casi nunca respondió con un 304, una vez en 48 lecturas condicionales del 2026-09-12 al 2026-09-27, y los seis listados de paquetes que GraphQL no ve, que lo responden hasta que cambia un paquete | | achievements | 1h | 1 página fuera de presupuesto, 1 pt, más el recorrido de coautoría | 1 página fuera de presupuesto, 2 pt | 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 primera pasada recorre la vida entera de la cuenta, y como la búsqueda se detiene a los mil resultados parte en dos cualquier rango que se pase, un punto por página y uno por partición, y uno por consulta que sigue leyendo más allá de los cien primeros commits de una pull request que tiene más, diez pull requests por consulta, que son decenas de puntos en una cuenta antigua y uno en una nueva: 35 consultas y 23,7 MB sobre 2.315 pull requests públicas fusionadas, medido el 2026-09-27. El recuento que encuentra se guarda en el fichero de estado con el día que cubre, así que cada pasada posterior recorre solo las pull requests fusionadas desde entonces, una página, de 468 a 513 KB en cada pasada horaria que registró el proxy de producción el 2026-09-27 en la misma cuenta, un tamaño que crece a lo largo del día con lo que se fusiona, donde el recorrido entero había sido de 18 a 24 MB cada día; el historial entero se vuelve a recorrer una vez por semana, porque un repositorio que pasa a privado o se borra saca sus pull requests del recuento. La página y los dos puntos cada hora, el recorrido entero una vez por semana | | totals | 1h | 1 search, 1 pt, más 1 pt por cada 10 repos y 1 por cada 25 archivados que se dejan fuera | igual | 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 | 15m | 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, 192 al día menos al cuarto de hora; una primera pasada y un backfill siguen leyendo las tres | | notifs | 15m | 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 al cuarto de hora es una página para una ventana que se abre media hora antes del hilo más reciente visto: 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. unos 1.800 core al día menos al cuarto de hora | | billing | 1h | 2 core | 1 core | una por mes recorrido; el mes en curso cambia, el anterior es un 304. Medido del 2026-09-13 al 2026-09-26, cada seis horas, el mes en curso respondió 200 a las 46 lecturas condicionales y el anterior 304 a las 46 | | 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 | 1h | 9 pt, más 1 por cada cien más de estrellas dadas, de elementos abiertos o movidos desde la pasada anterior, o de respuestas aceptadas | lo mismo | todo GraphQL: la lista de estrellas dadas es una consulta de cien por página (13,5 KB frente a 617 KB descomprimidos por REST), de la más nueva a la más antigua, hasta cinco páginas en cada pasada y todas en un backfill, las cinco búsquedas un punto por página (7 KB frente a 100 KB), los dos recorridos de comentarios uno cada uno, y las respuestas aceptadas de la cuenta uno más, leídas aparte para que una respuesta aceptada cuando su comentario ya había salido de los cien más recientes se siga escribiendo (eran 15 el 2026-09-26, una página, y cinco páginas como mucho en una pasada). Las dos búsquedas de lo que sigue abierto se leen hasta el final en cada pasada, una página mientras la cuenta no tenga más de cien abiertos de un tipo, y las tres de lo fusionado o cerrado hasta una cadencia antes de la pasada anterior, ordenadas por lo que se movió último, lo que es una página cada una salvo que se hayan movido más de cien en ese tiempo; un backfill lee también esas hasta el final, diez páginas como mucho, porque la búsqueda sirve mil resultados y ni uno más. 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. Un fork no tiene perfil de comunidad, y el 404 con el que responde, que se cobra entero porque no lleva ETag, se pregunta una vez al día y no en cada pasada: a quince forks se les cobraron 404 en 30,9 horas, y con la memoria se les cobran 15 al día | | branches | 24h | 1 pt por cada 14 repos | 1 pt por cada 14 repos | una consulta por cada catorce repositorios | | stars | 1h | 1 core por repo, más 1 core por cada 30 semanas de su vida | 1 pt por cada 10 repos, más 1 core por repo, casi siempre un 304 gratis | 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. El historial diario de estrellas llegó después de la ronda y no está en sus cifras: 1 core por repositorio, un 304 salvo que un día de las últimas treinta semanas ganara o perdiera una estrella o empezara una semana; su primera lectura es una página por cada treinta semanas de vida del repositorio, 180 páginas para los diecinueve repositorios con estrellas de la cuenta medida, una vez | | issues | 1h | hasta 4 pt por página de un mes de movimientos, 1 pt o más por abiertos | 1 a 2 pt por repo que se movió | 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). La primera pasada de cada día UTC lee además cada elemento abierto de cada repositorio, por mucho que haga que se movió, en páginas dimensionadas desde los recuentos de abiertos de los últimos totales, y lo que se movió desde la lectura del día anterior, un mes atrás con un fichero de estado nuevo, en páginas de hasta veinticinco (5, 10, 20, 25 o 50 cuestan 1, 2, 3, 4 o 9, preguntado con `dryRun` el 2026-09-30). Hasta la 2.6.5 la lectura del día era una página de los cincuenta elementos de cualquier estado actualizados por último, que perdía un elemento abierto en cuanto otros cincuenta se movían después, y chocaba con los diez segundos del gateway en el repositorio más activo cada día. Desde la 2.6.0, la lectura de lo que cambió solo pregunta a los repositorios donde se actualizó una issue o una pull request en su ventana, que antes dice una sola consulta para todos ellos (ver [Preguntar primero qué se movió](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió)); la lectura de cada elemento abierto se hace en todos los repositorios | | issueevents | 1h | varios pt por repo actualizado en el mes, más 1 core por pull request en pila | 1 pt por repo que se movió, 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 que se movió 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. Desde la 2.6.0, una pasada solo pregunta a los repositorios donde se actualizó una issue o una pull request en su ventana (ver [Preguntar primero qué se movió](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió)) | | 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, la segunda es la que paga la puesta al día, porque la página de treinta es una URL nueva para cada repositorio y trae ejecuciones que las veinte de la primera 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. El listado de cachés se lee en páginas de cien entradas, hasta diez, así que solo un repositorio de más de cien paga una segunda, y una página es un 304 mientras el listado no cambia: del 2026-09-25 12:58Z al 2026-09-27 01:39Z los listados de la cuenta respondieron 304 a 3.059 de 3.256 peticiones | | 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 la 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. Solo una lista cuya primera página vuelve llena, en un repositorio con más de cien alertas de ese tipo, pide más: sus alertas abiertas, una página por cada cien que sigan abiertas, y en code scanning una página de una sola alerta cuya última página es el total. Medido el 2026-09-26 son dos listas de esta cuenta y tres peticiones por pasada, condicionales como el resto y un 304 mientras nada se mueve | | stats | 12h | 3 core por repo | 0 core | participación y punch card; GitHub los recalcula despacio y responde 304 | | discussions | 1h | 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 con un commit en el mes | 1 pt por repo que se movió | las dos últimas cadencias de la rama por defecto, desde la 2.6.0 solo en los repositorios cuya cabeza tiene el commit dentro de ellas (ver [Preguntar primero qué se movió](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió)); un mes atrás en la primera pasada, que son unos cientos de kilobytes para un repositorio activo; donde la pasarela se rinde con esa página de cincuenta, como hizo en el repositorio más activo medido, se lee en páginas de veinticinco | | activity | 15m | 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 la siguiente | | analyses | 1h | 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 el último empujón 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 | 30m | 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 una 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 entonces, 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 una 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 la de 15 minutos y la de cada hora, cada una una fracción del total. Las cadencias de la 2.6.0 ejecutan diez familias más a menudo, `events`, `notifs` y `activity` cada cuarto de hora, `deployments` cada media hora, y `stars`, `billing`, `analyses`, `account`, `totals` y `discussions` cada hora, porque sus pasadas de más no cuestan casi nada. Costeadas con el registro de peticiones del proceso de producción del 2026-09-25 a las 12:58Z al 2026-09-26 a las 19:50Z, 37 repositorios, dejando fuera la primera pasada de cada familia para que toda pasada contada tuviera la caché de ETag caliente, añaden unas 250 peticiones `core` y 500 puntos de GraphQL al día, el 0,2 y el 0,4 % de los dos presupuestos diarios, y 22 peticiones de búsqueda. Los puntos son sobre todo `deployments`, ocho por pasada allí, y `totals`, seis; las peticiones `core`, sobre todo `activity`, `events` y `analyses`, que solo se cobran por lo que se movió, porque un 304 es gratis: en el mismo registro, una petición `core` respondida con 304 dejó `x-ratelimit-used` donde lo había puesto la anterior de la misma ventana 11.692 veces de 11.771. Otras dos familias corren cada hora desde la misma versión, cada una con su precio en su fila de arriba: `outbound`, que corría cada doce, y `achievements`, que corría una vez al día mientras cada pasada recorría el historial entero de fusiones. ## Preguntar primero qué se movió `commits`, `issueevents` y la pasada incremental de `issues` solo leen lo que se movió desde una ventana propia, una consulta GraphQL por repositorio, y GitHub cobra una consulta por la página que pide, no por lo que vuelve. Así que una pasada que ejecuta cualquiera de las tres pregunta primero a cada repositorio, veinticinco por consulta a un punto cada una, cuándo se hizo el commit de la cabeza de su rama por defecto y cuándo se actualizaron su issue y su pull request más recientes, y cada familia deja sin leer un repositorio donde nada se movió desde el inicio de su propia ventana. Es la condición en la que la lectura vuelve vacía, no una estimación de ella: medido el 2026-09-27, `history(since:)` contiene una cabeza cuyo commit es del mismo segundo que nombra y nada un segundo después. Un repositorio para el que la consulta no responde se lee como siempre, y ni la lectura diaria de cada elemento abierto de `issues` ni un backfill la hacen. Ninguna fila de la tabla de arriba lleva la consulta, porque no pertenece a ninguna de las tres: es un punto por cada veinticinco repositorios en cada pasada que ejecuta alguna de ellas. Una familia que deja repositorios sin leer dice cuántos, una vez por pasada, y su fila de [`gh_collector_family`](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#configuración-y-entrega) los cuenta en `repos` junto a los que leyó, sin escribir filas por ellos: ```text level=INFO msg="nothing moved since the window, repositories left unread" family=commits repos=34 ``` Una consulta que falló, o que respondió por unos repositorios y no por los demás, se dice con un aviso, y cada repositorio del que no dio respuesta se lee como si no se hubiera preguntado: ```text level=WARN msg="which repositories moved could not be asked, reading those it did not answer for" answered=36 repos=37 err="graphql: the gateway gave up (502); the query is too large for one request (36 repositories were asked successfully)" ``` Del registro de peticiones del proceso de producción entre el 2026-09-25 a las 12:58Z y el 2026-09-26 a las 19:50Z, 27 pasadas de cada familia sobre 37 repositorios, antes de que existiera la consulta: 961 de las 999 respuestas de `commits` no traían ningún commit, y 468 de las 968 respuestas incrementales de `issues` y 486 de las 1.005 de `issueevents` no traían ningún ítem, 2.077 puntos en total, el 43 % de los 4.786 que el proceso gastó en GraphQL. Para esos 37 repositorios la consulta cuesta dos puntos por pasada, 54 en las 27 pasadas, así que ahorra 2.023 puntos, unos 66 por hora: el 42 % de todos los puntos de GraphQL y el 52 % de los de las tres familias. Es un mínimo. Una respuesta cuenta como vacía cuando pesa menos de 300 bytes, el bloque del presupuesto y una lista vacía; un repositorio cuyas issues y pull requests son todas anteriores a la ventana responde igualmente con una página de ellas, no se cuenta aquí, y se salta igual. ## 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 puede correr cada hora y aun así no costar casi nada, y por eso las familias caras son las de REST que crecen con lo activos que estén los repositorios. ## Qué crece 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. Esto 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 y se las vuelve a cobrar cada vez que se añadió o caducó un artefacto. - **`activity`** solo se cobra en un repositorio cuyo log se movió: la página de uno que no se movió es un 304 gratis. - **`commits`**, **`issueevents`** y la pasada incremental de **`issues`** solo leen los repositorios donde algo se movió en su ventana, un punto o dos cada uno, después de [preguntar cuáles](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió) por un punto por cada veinticinco repositorios. La lectura diaria de cada elemento abierto de `issues` se hace en todos los repositorios, activos o no. - **`outbound`** cuesta un punto más por cada cien más de lo que lee: estrellas dadas, elementos abiertos o movidos desde la pasada anterior y respuestas aceptadas. > **Qué alargar primero** > > Si el presupuesto que se queda corto es `core`, alarga `artifacts` y después > `actions`; si es `graphql`, alarga `issues`, `issueevents` y `commits`. Cada > una cuesta más cuanto más activos estén los repositorios, así que alargar su > 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](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/). ## Hacer la cuenta para tu propia cuenta La primera pasada es la cara: el recorrido completo de estrellas, el historial diario de estrellas entero de cada repositorio, un mes de ejecuciones de workflows, las pull requests en coautoría de toda la vida de la cuenta, que `achievements` vuelve a recorrer una vez por semana (35 consultas y 23,7 MB sobre 2.315 pull requests públicas fusionadas, medido el 2026-09-27), y, con `every.families.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](https://docs.jmrp.io/ghchronicle/es/card/) ejecuta todas las familias digan lo que digan las cadencias, porque cada número que dibuja sale de esa única pasada, así que N tarjetas son N pasadas, y un workflow que dibuja tres paga tres. Su GraphQL es el de una pasada entera cada vez, porque GraphQL no lleva ETag. Sus peticiones REST son las de la columna en frío solo la primera vez: toda ejecución lee el fichero de caché que hay junto a su `state_file` y pregunta con los validadores guardados allí, así que desde la segunda tarjeta una página que no cambió es un 304 gratis. Una ejecución cuyo fichero de estado no se conserva de una ejecución a la siguiente las paga en frío cada vez: un contenedor sin nada montado donde va el fichero, o la Action sin `config:`, que empieza cada ejecución con un directorio de estado propio. Una ejecución `-card-only` lee el fichero de caché y nunca lo escribe, así que solo va en caliente cuando otra ejecución mantiene ese fichero. 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://docs.jmrp.io/ghchronicle/es/api/limits/ Cada entrada de aquí se comprobó contra la API real desde una cuenta personal, y la mayor parte no está en la documentación de GitHub, que describe lo que un endpoint debería devolver y no lo que devuelve. 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. ## ¿Por qué dos endpoints de estadísticas no responden 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. - **La lista de stargazers, para quien no administra ni colabora en el repositorio.** [Desde julio de 2026](https://docs.github.com/es/rest/activity/starring#new-access-restrictions) GitHub no la sirve a nadie más. REST responde 404, y `stargazers` en GraphQL responde una lista vacía con `totalCount` 0 mientras `stargazerCount`, a su lado, sigue dando el número real: medido en `cli/cli` y `octocat/Hello-World` con un token con todos los permisos. Por eso ghchronicle escribe `gh_star`, quién dio la estrella y cuándo al segundo, solo donde el token tiene ese acceso, que siempre tiene en los repositorios de la propia cuenta y en los de una organización que la cuenta administra; uno nombrado en `targets.repos` o alcanzado por `targets.orgs` sin él tiene sus estrellas contadas por día con el historial de abajo, y a nadie nombrado. - **`/user/installations`** devuelve 403 sin una GitHub App. ## El historial de estrellas que github.com sirve a cualquiera [`stargazers/history`](https://docs.github.com/es/rest/activity/starring#get-repository-star-history) responde donde la lista de stargazers no lo hace. Lo sirve a cualquiera que pueda ver el repositorio, también sin autenticar: las estrellas dadas por día, agrupadas por semana, treinta semanas por página, el valor por defecto y el máximo que admite `per_page` (uno menor se respeta, uno mayor se recorta a treinta), paginando hasta la primera semana del repositorio (medido en `cli/cli`: trece páginas, hasta 2019). No nombra a ningún stargazer, así que no puede sustituir a `gh_star`, que es una fila por estrella en el instante en que se dio y dice quién la dio. Sí puede sustituir al recuento, y ghchronicle lo lee para cada repositorio que recoge, lo escribe como `gh_star_day` y dibuja con él los paneles que cuentan estrellas por día en todos los almacenes menos Prometheus, que no puede guardar un historial y sigue contando `gh_star`. Tres cosas de él se midieron en lugar de leerse en su documentación. Los días son días naturales de GitHub en America/Los_Angeles, bajo una `week` etiquetada como domingo a las 00:00 UTC: contra las listas de stargazers de diecinueve repositorios, el día del Pacífico acertó en las 440 estrellas y el día UTC no. Cuenta los stargazers actuales del repositorio, cada uno en el día en que marcó la estrella, así que quitar una estrella la saca de un día del pasado y no del día en que ocurrió. Y un 304 suyo no lleva cabecera `Link`, mientras que un 200 lleva `next` y `last`, así que el recorrido no lee `Link` en absoluto y se detiene en una página de menos de treinta semanas o en una vacía. ## 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](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/api/cost/) pone precio a cada familia. --- # La línea de órdenes La línea de órdenes de ghchronicle: las opciones, qué hace cada una y cuáles imprimen algo y salen. Source: https://docs.jmrp.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 | | ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `-setup` | off | Pregunta lo que necesita una configuración que funcione, comprueba cada respuesta contra aquello que nombra, y la escribe | | `-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`) | | `-backfill-status` | apagada | Imprime hasta dónde han llegado el relleno en curso y la relectura de una migración, y sale; no pregunta nada a GitHub, no escribe nada y no necesita token | | `-backfill-retry` | `0` | Cuando un relleno, o la relectura de una migración, acaba con familias pendientes, espera esto y vuelve a por ellas, hasta que una pasada no anote nada nuevo; `0` no vuelve | | `-families` | ninguno | Con `-backfill`, recorre solo estas familias, separadas por comas, los nombres que imprime `-groups`; un nombre que no es una familia, o la opción sin `-backfill`, se rechaza con 2 | | `-publish-dashboard` | off | Publica el dashboard de Grafana y el datasource del que lee, y sale; necesita la sección `grafana`, no le pregunta nada a GitHub y no necesita token | | `-uninstall` | ninguno | Quita lo que esto puso y sale: `dashboard`, `data`, `state`, `all`, separados por comas; lista y no quita nada sin `-yes` | | `-yes` | off | Sigue adelante con `-uninstall` o `-migrate` en vez de solo listar lo que haría | | `-migrate` | off | Imprime, para cada almacén configurado, lo que una versión anterior dejó allí con una forma que esta ya no escribe y lo que haría falta para ponerlo al día, y sale; sin `-yes` no cambia nada, y con él aplica cada cambio pendiente y vuelve a leer lo que despejó | | `-migrate-others` | off | Con `-migrate -yes`, aplica también un cambio a un almacén que guarda filas de cuentas que esta configuración no recoge, o cuyas filas no se pudieron comparar con ella; sin `-migrate` se rechaza con 2 | | `-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](https://docs.jmrp.io/ghchronicle/es/card/layouts/) dibujar | | `-card-fields` | ninguno | Campos que muestra la tarjeta, separados por comas, de entre [los campos](https://docs.jmrp.io/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](https://docs.jmrp.io/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 cinco que imprimen y salen `-version`, `-groups`, `-card-layouts`, `-list` y `-backfill-status` 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. `-backfill-status` lee la configuración para encontrar el [punto de control del relleno](https://docs.jmrp.io/ghchronicle/es/how/backfill/#ver-hasta-dónde-ha-llegado) e imprime hasta dónde ha llegado ese recorrido, y después la relectura de una migración cuando hay una en curso; no le pregunta nada a GitHub, así que no necesita token. ```sh ghchronicle -version ghchronicle -groups ghchronicle -card-layouts ghchronicle -config config.yaml -list ghchronicle -config config.yaml -backfill-status ``` ## Lo que una actualización dejó en los almacenes ```sh ghchronicle -config config.yaml -migrate ``` `-migrate` contrasta cada almacén configurado con la lista de cambios que lleva el binario, cada uno una medida que una versión anterior escribió con una clave que esta ya no usa, e imprime un bloque por almacén: lo que cada cambio encuentra allí, la prueba, lo que haría falta para ponerlo al día y lo que se volvería a leer de GitHub. No cambia nada. A los almacenes se les hacen preguntas, a GitHub solo se le pide la lista de repositorios, y el fichero de estado se lee y nunca se escribe, así que sale con 0 encuentre lo que encuentre. Sin token funciona igual y dice lo que no pudo comparar. [Migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones) explica cómo se decide cada almacén y qué significa cada línea. Cuando hay algo pendiente, el plan termina con la orden que lo aplica y con lo que hace un arranque con el ajuste [`migrate`](https://docs.jmrp.io/ghchronicle/es/configuration/#migrate). Esa orden, y cualquier otra que ghchronicle imprime para que la ejecutes después (la línea de reanudar de `-backfill-status`, los avisos de un arranque, la última línea de `-setup`), nombra la configuración tal como se le dio, entre comillas cuando un shell leería parte de la ruta: comillas simples en Linux y macOS, y dobles en Windows, la única forma que PowerShell y `cmd` leen como un solo argumento. ```sh ghchronicle -config config.yaml -migrate -yes ``` Con `-yes` imprime el mismo plan y después aplica cada cambio pendiente, también los marcados como necesitados de la palabra de alguien, vuelve a leer de GitHub lo que despejó, y dice bajo el plan qué pasó con cada uno. Volver a leerlo es un relleno histórico de las familias que escriben lo despejado, escribiendo solo eso en los almacenes de los que se despejó; `-backfill-retry` vuelve a por lo que deja, y donde un almacén guardó una copia de las filas antiguas, el informe compara las dos y nombra lo que GitHub ya no sirve. Una relectura cortada guarda un punto de control propio, y la misma orden la retoma, aunque no quede nada por aplicar: [volver a leer el historial](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#volver-a-leer-el-historial). Un almacén que guarda filas de cuentas que esta configuración no recoge, o cuyas filas no se pudieron comparar con ella, se deja sin aplicar salvo que se dé también `-migrate-others`. Necesita un token y la lista de repositorios, y se queda el fichero de estado mientras corre: con el servicio en marcha se niega, nombrando el proceso, y no cambia nada, así que para antes el servicio, y pausa un cron que ejecute `-once`. Con el destino SQL en la salida estándar, el plan y el informe van a la salida de errores, así que la salida estándar es solo el SQL. Lo que aplica se registra sobre la marcha, así que volver a ejecutarlo tras un fallo sigue sin hacer nada dos veces. | Salida | Cuándo | | ------ | --------------------------------------------------------------------------------------------------------------------- | | `0` | `-migrate`, encuentre lo que encuentre; `-migrate -yes` cuando todo lo pendiente se aplicó y se volvió a leer, o no había nada | | `1` | `-migrate -yes` dejó algo: un almacén que se negó o no respondió, uno que se dejó sin aplicar, una relectura que no terminó; o se negó a empezar | | `2` | Una línea de órdenes que no se puede leer, entre ellas `-migrate-others` sin `-migrate` | ## 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](https://docs.jmrp.io/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 ``` `-families` limita un relleno a las familias nombradas, en todos los almacenes configurados: [solo algunas familias](https://docs.jmrp.io/ghchronicle/es/how/backfill/#solo-algunas-familias). ```sh ghchronicle -config config.yaml -backfill -families discussions,outbound ``` ## 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](https://docs.jmrp.io/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`](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/configuration/) es el mapa de todas ellas. --- # 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://docs.jmrp.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 y, en un token fine-grained, el permiso de repositorio Administration (lectura); las alertas necesitan `security_events`. Ver [el token](https://docs.jmrp.io/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, también tras un reinicio; borrar [el fichero de caché junto al fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado) vuelve a preguntar en el acto. Ver [un rechazo también se recuerda](https://docs.jmrp.io/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. **`cache file not read, the first pass of each family pays in full`.** El fichero de caché que hay junto al fichero de estado quedó cortado, dañado o escrito en otro formato, así que no se lee en absoluto, y el siguiente guardado lo sobrescribe. Cuesta una pasada de cada familia a precio completo y no pierde nada; ver [la caché que hay a su lado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). **`which repositories moved could not be asked, reading those it did not answer for`.** La consulta que dice a `commits`, `issues` e `issueevents` qué repositorios leer falló, o respondió por algunos de ellos. Cada repositorio del que no dijo nada se lee como si no se hubiera preguntado, así que no se pierde nada y la pasada cuesta lo que costaba antes de que existiera la consulta; ver [preguntar primero qué se movió](https://docs.jmrp.io/ghchronicle/es/api/cost/#preguntar-primero-qué-se-movió). **`outbound search read fewer items than it counts, GitHub serves a thousand at most`.** GitHub sirve mil resultados de una búsqueda y ni uno más, y la cuenta tiene más que eso en uno de los estados que busca `outbound`, las pull requests fusionadas por ejemplo. Se quedan los mil que se movieron más recientemente, y la línea se dice una vez por recuento; ver [qué alcanza que una pasada no](https://docs.jmrp.io/ghchronicle/es/how/backfill/#qué-alcanza-que-una-pasada-no). **`NOTICE: column ... already exists, skipping` de psql.** Reproducir el fichero del destino SQL en una base de datos que ya tiene sus tablas. El fichero declara cada tabla y cada columna de campo que escribe, porque no puede preguntar cuáles existen, y PostgreSQL lo dice por cada una que ya está, con un `relation ... already exists` para la tabla. No ha fallado nada. **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 `stats`. Una familia de seis horas o más también puede tocarle y estar esperando su turno detrás de otra, y el log la nombra bajo `waiting` en `slow families due together take turns`; ver [las familias lentas se turnan](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#las-familias-lentas-se-turnan). ## 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. **`collector failed` que nombra un `500`, un `502 Bad Gateway`, un `503` o un `504 Gateway Timeout`.** GitHub no terminó la respuesta a una petición, en su pasarela o en la aplicación, o no la terminó el almacenamiento del que se lee el log de un job. El cliente pregunta una vez más dos segundos después y no dice nada de ello, así que una línea como esta es una petición que falló dos veces, y la siguiente pasada vuelve a preguntar. Una consulta GraphQL se vuelve a preguntar del mismo modo, salvo por una respuesta: un `502` o un `504` que tardó los diez segundos de GitHub en volver es una consulta demasiado grande, que los colectores que tienen una página más pequeña vuelven a preguntar con ella. Una línea que dice que la consulta es demasiado grande para una sola petición es una que agotó el tiempo incluso con la página más pequeña, o en un recorrido sin página más pequeña que pedir. Lo leído antes se escribe, un relleno no anota ese repositorio como recorrido, y la pasada siguiente lo vuelve a leer. **`state not saved` o `cache file not saved`.** El usuario con el que corre el colector no pudo poner el fichero en su sitio. Cada uno de los dos se escribe con un nombre temporal en el directorio que nombra el fichero de estado y después se renombra a su sitio, así que o no se puede escribir en ese directorio, o no existe y no se puede crear, o el propio fichero no se puede sustituir con un renombrado. Nada de lo que aprende una pasada sobrevive entonces a un reinicio: el siguiente arranque recoge todas las familias y vuelve a recorrer la lista de estrellas, el historial entero de estrellas y las pull requests en coautoría. En un contenedor es un directorio del anfitrión montado sin entregárselo al uid 65532, un volumen nuevo montado en otro sitio que no sea `/var/lib/ghchronicle`, o cualquier volumen nuevo con una imagen anterior a la 2.6.1, ninguno de ellos de ese uid, o un fichero de estado montado solo, sobre el que no se puede renombrar; ver [qué tiene que ser escribible](https://docs.jmrp.io/ghchronicle/es/install/docker/#qué-tiene-que-ser-escribible). **`the state file ... cannot be read` o `does not parse`, y la ejecución se detiene.** El fichero de estado está ahí y esta ejecución no pudo leerlo, o no está entero. Una ejecución que escribe en los almacenes no lo toma por uno nuevo, lo que olvidaría las relecturas que una migración aún debe y pondría un fichero nuevo en su lugar en su primer guardado. Un fichero que dejó root, tras un `-migrate -yes` o un relleno lanzado con `sudo`, es la causa habitual: devuélveselo con `chown` al usuario con el que corre el colector. Si se aparta en su lugar, la siguiente ejecución empieza con uno nuevo, y lo que registraba, entre ello una relectura aún debida, se pierde; ver [el fichero de estado](https://docs.jmrp.io/ghchronicle/es/configuration/#state_file). **`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. Si el cubo que se queda corto es `core`, alarga `artifacts` y después `actions`; si es `graphql`, `issues`, `issueevents` y `commits`. Ver [qué crece con la actividad, no con el tamaño](https://docs.jmrp.io/ghchronicle/es/api/cost/#qué-crece-con-la-actividad-no-con-el-tamaño). **`refill owed: a store a migration cleared does not hold that history yet`.** Una migración despejó la medida que se nombra en ese almacén y volver a leer su historial de GitHub no terminó: se paró la ejecución, un almacén rechazó una escritura o GitHub no respondió. Ahora el almacén parece uno que nunca guardó la forma antigua, así que solo lo sabe el fichero de estado. Bajo `migrate: warn` un arranque lo dice y hace su pasada; `-migrate -yes`, que la línea nombra, lo vuelve a leer desde donde se paró, y también cualquier arranque bajo `migrate: auto`. Ver [volver a leer el historial](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#volver-a-leer-el-historial). ## Los datos parecen mal **`reconciled` en `WARN`, con `not_served`.** Tras volver a leer una medida despejada, ghchronicle comparó la copia de las filas antiguas con la tabla y encontró elementos que GitHub ya no sirve: un repositorio borrado o que ya no se cubre, un comentario borrado, una alerta cuya función se apagó. `first` nombra unos pocos, y `only_in` es la copia que aún los guarda, hasta que se purga: un día después de la migración las de PostgreSQL y Elasticsearch, y cuando diga el calendario del servidor la de InfluxDB 3, 72 horas por omisión, o nunca en un servidor anterior a la 3.2. Traspasarlos se hace a mano, desde esa copia, mientras está. **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 revisión de otra persona 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. No significa que no se haya revisado nada. 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 `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, o una página del listado falló en esa pasada y la fila se escribió con las páginas anteriores. También puede salir más pequeño de lo esperado por una segunda razón: `count` es el total de GitHub e incluye los artefactos que ya ha caducado, mientras que el tamaño es solo sobre los vivos, que son los que cuenta `live_count`. **Un panel responde con un error de esquema en vez de decir No data.** InfluxDB 3 crea una columna la primera vez que una fila la trae, así que una consulta que nombra una que ninguna fila ha escrito falla al planificarse. Una versión que añade un campo le hace eso a una base ya existente hasta que la familia que lo escribe hace una pasada: `gh_fork.seconds_to_push` y `gh_artifact_total.live_count` fueron dos, que nombran los paneles Forks y "Artifact storage counted", y la familia `forks` corre cada doce horas. Una versión que añade una medida con la que un panel hace un join le hace lo mismo al panel entero, también en PostgreSQL, donde lo que falta es la tabla y no la columna: la tabla "Work elsewhere" hace un join con `gh_upstream_repo` para las estrellas de cada repositorio, y hasta que la primera pasada de `outbound` de la versión nueva la escribe, que es dentro de una hora, los dos almacenes rechazan la tabla en vez de dejar vacía su columna Stars. Espera esa pasada o publica los dashboards después. Un `-once` no la adelanta: corre solo las familias a las que les toca, y a `outbound` no le toca hasta una hora después de su última pasada. Las demás columnas que pueden faltar están en [Columnas que solo existen una vez escritas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#columnas-que-solo-existen-una-vez-escritas). **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](https://docs.jmrp.io/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](https://docs.jmrp.io/ghchronicle/es/how/dating/). ## ¿Por qué 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 esto a eso y nada más: el tamaño del libro de escrituras al arrancar, un primer arranque sin fichero de caché, un arranque que vuelve a listar jobs porque un almacén no lleva registro, las familias de cuenta saltadas por falta de `targets.user`, las entradas que un destino dejó fuera por viejas, una pasada solo de tarjeta que deja en paz el fichero de estado, cada guardado del fichero de caché, las migraciones que un arranque encuentra innecesarias o ya aplicadas, las anotadas o congeladas que el primer arranque dijo en `INFO`, y cada copia que apartó una migración y que el fichero de estado olvida porque el almacén la purga o la guarda para siempre. No hay registro por petición en ningún nivel. Ver [depuración](https://docs.jmrp.io/ghchronicle/es/configuration/logging/#depuración). ```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 dos cosas más** > > Recuerda nueve cosas, y siete 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. `last_head` es el commit desde el que arrancaba cada diff de > dependencias, y sin él la pasada siguiente tiene la fotografía y no el diff. > `stores` es lo que el fichero registra de cada almacén, entre ello una > relectura que una migración aún debe, y sin él nada vuelve a leer ese > historial hasta que lo haga un `-backfill -families`. Ver [el fichero de > estado](https://docs.jmrp.io/ghchronicle/es/configuration/#state_file). El fichero de caché que > hay a su lado cuesta cuota y nada más: ver [la caché que hay a su > lado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). ## ¿Por qué 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, la mitad del `max_chunk_age` del ingester y así una hora 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 `max_chunk_age` de Loki. Ver [Loki](https://docs.jmrp.io/ghchronicle/es/sinks/loki/). ## Publicar el dashboard **`Permissions needed: datasources:create`.** El token publica dashboards y no puede crear datasources. Un Editor puede lo primero y no lo segundo, así que dale ese permiso a la cuenta de servicio, hazla Admin, o deja que adopte un datasource creado por ti nombrándolo en `grafana.datasource.uid`, que solo lee. Corregir un datasource que ya está pide `datasources:write` en su lugar, con las mismas salidas. Eso detiene la ejecución para el datasource del almacén, que leen todos los paneles. Para el de Loki es solo un aviso, más abajo. **`datasource ... does not answer: connection refused`.** Grafana llegó a la dirección y no había nadie escuchando, que casi siempre significa que la dirección es la buena para el colector y la mala para Grafana. Un colector en el anfitrión escribe a un puerto publicado; Grafana en un contenedor llega al mismo almacén por su nombre en la red de contenedores, y `127.0.0.1` allí es el contenedor. Pon en `grafana.datasource.url` la dirección que usaría Grafana. La ejecución se niega en vez de publicar, porque un dashboard contra un datasource inalcanzable no dibuja nada y no dice por qué. **`asking datasource ... whether it answers: ... datasources:query`.** El token no puede consultar el datasource, que no es lo mismo que un datasource que no llega a su almacén, y la dirección no es lo que hay que cambiar. Medido en Grafana 13, una cuenta de servicio sin rol recibe esto y un Editor no, así que dale el rol de Editor. **`Invalid API key`.** El token llegó a Grafana como los caracteres `${GRAFANA_TOKEN}` y no como un token, lo que significa que la variable no está donde corre el colector. En una unidad de systemd eso es un `EnvironmentFile` que la unidad no lee, o una variable nombrada en el fichero y no exportada. **El datasource está y no tiene contraseña.** Solo se envía una contraseña que escriba el propio DSN. Una que pgx encontrara en `PGPASSWORD` o en un `.pgpass` de la máquina que publicó se nombra en una línea y se queda ahí, porque un Grafana es compartido y una credencial que la configuración nunca mencionó no es cosa de copiar dentro. **`the dsn asks for sslmode=prefer and Grafana's datasource has no such mode`.** Lo que hace libpq por omisión es intentar TLS y seguir sin él, y el datasource o exige o rechaza. Se le puso `disable`; pon `grafana.datasource.sslmode` en `require`, `verify-ca` o `verify-full` si eso no encaja con tu servidor. **Dos dashboards, y el que miras deja de cambiar.** El generado tiene su propio uid y cada publicación lo sobreescribe, así que esto solo pasa cuando el tuyo vive en otro sitio: importado como nuevo, o renombrado a mano. Nombra el que lees en `grafana.dashboard_uid`. **`... is still in Grafana and no ... sink is configured here`.** Algo que esto creó está bajo un uid al que ya nadie escribe, que es lo que deja atrás cambiar de almacén. Es un aviso y no se tocó nada; bórralo en Grafana cuando hayas terminado con él. **`no sink this builds a dashboard for is configured`.** Hay dashboards para cinco almacenes y el sink que tienes no es uno de ellos. Un sink que reenvía no tiene dashboard propio porque el dashboard pertenece al destino, y `file`, `stdout` y `loki` no son almacenes de métricas. **`warning: the Loki datasource ghchronicle-loki could not be set up`.** El token no puede crear el datasource de Loki del que lee el panel de la salida del trabajo fallido, y la línea nombra el permiso que le falta: `datasources:create` para un Editor, porque el datasource aún no está. Nada más espera por él: ese único panel queda como la nota que llevan los ficheros exportados, y los dashboards se publican siempre que se pueda el datasource del propio almacén. Nombra en `grafana.datasource.loki_uid` un datasource de Loki que Grafana ya tenga, o concede el permiso. La línea enumera los datasources de Loki que tiene Grafana, con sus direcciones, porque el que hay que nombrar suele estar ya ahí bajo una dirección a la que el colector no envía, como el nombre de Loki en la red de contenedores de Grafana. La 2.4.0 y la 2.5.0 detenían aquí toda la publicación, con `the Loki datasource: writing datasource ghchronicle-loki`, y `loki_uid` también lo arregla en ellas. **`warning: the Loki datasource ghchronicle-loki is used as Grafana has it`.** Lo creó una ejecución anterior con un token que podía escribir datasources, y este token no puede reescribirlo: la línea nombra `datasources:write`. Un sink con `tenant_id` pide esa reescritura en cada arranque, porque el tenant es un secreto que Grafana nunca devuelve para comparar, y también la pide un datasource cuya dirección se cambió a mano. El panel lo lee tal cual, así que no se pierde nada salvo que el sink se haya movido desde entonces. Concede al token `datasources:write` para mantener su dirección y su tenant al día con el sink, o pon `grafana.datasource.loki_uid` en `ghchronicle-loki` para leerlo sin intentarlo. **`datasource ... (loki) adopted`.** Grafana ya tenía un datasource de Loki en la dirección de la que sale el extremo de envío del sink, así que el panel lee ese y no se creó un segundo. Adoptar solo lee, así que un token que no puede crear datasources llega hasta aquí. Un sink con `tenant_id` nunca se empareja así: el tenant viaja en un secreto que Grafana nunca devuelve, así que nada puede saber si el datasource de esa dirección lee el mismo tenant, y se crea uno. **El panel de la salida del trabajo fallido sigue siendo una nota.** Ese panel pasa a ser las líneas cuando hay un sink de Loki cuya dirección termina en `/loki/api/v1/push`, que es lo que dice dónde está el extremo de consulta. Un sink que escriba en otro sitio lo dice y deja el panel como está; nombra un datasource de Loki en `grafana.datasource.loki_uid`. Un token que no puede crear el datasource también lo deja así, con el aviso de arriba. **No se publicó nada y no falló nada.** `publish_on_start` está apagado salvo que lo enciendas, y se salta en `-once`, `-backfill` y `-card`, porque encenderlo en una unidad de systemd no quería decir reescribir el dashboard en cada ejecución de una tarjeta programada. **El colector arrancó y el dashboard no apareció.** Una publicación que falla al arrancar avisa y la pasada sigue. Las métricas de una hora sin recoger no se recuperan y un dashboard publicado al siguiente arranque sí, así que el sitio donde mirar es la línea del log. ## Quitar cosas **`-uninstall` imprimió una lista y no quitó nada.** Eso es lo que hace por omisión. Añade `-yes`. **El datasource sobrevivió al uninstall.** Uno nombrado en `grafana.datasource.uid` se adopta, no se crea, así que era de otro antes de que esto corriera y sigue siéndolo. **`unknown -uninstall target`.** Se rechaza la lista entera en vez de la parte que se entendió, así que una errata no quita nada. Los objetivos son `dashboard`, `data`, `state` y `all`. **Las filas siguen en la base después de `-uninstall data`.** Tres almacenes no se pueden vaciar desde aquí y lo dicen: Graphite no tiene borrado, el sink de Prometheus se raspaba en vez de recibir escrituras, y el fichero del sink SQL se va pero las filas que ya cargaste de ahí a una base de verdad las cargaste tú. **Tablas o índices llamados `gh_...-20261001T091004`.** Una migración apartó las filas antiguas de la medida con ese nombre, la medida, un guion y el instante en UTC: la tabla renombrada de PostgreSQL, el clon de Elasticsearch, allí en minúsculas, y el nombre que InfluxDB 3 da a una tabla que borró. Ninguno de los paneles que se distribuyen lee ninguna. ghchronicle purga las de PostgreSQL y Elasticsearch cuando llevan 24 horas guardadas, e InfluxDB 3 purga las suyas con su propio calendario, 72 horas después del borrado por omisión, salvo una que borró una versión anterior a la 3.2, que no se purga nunca, ni siquiera después de actualizar (ver [antes de la 3.2 la copia se queda](https://docs.jmrp.io/ghchronicle/es/sinks/influxdb/#antes-de-la-32-la-copia-se-queda)); `-migrate` las lista bajo su almacén como `kept aside`. `-uninstall data` quita las de PostgreSQL y Elasticsearch con todo lo demás, y deja fuera las de InfluxDB 3 con una nota que dice cuáles se quedan, porque el servidor rechaza el borrado de una tabla que ya ha borrado, o, antes de la 3.2, la renombra otra vez. ## Tras una actualización **`migration pending`, en cada arranque.** Un almacén guarda filas de una medida con una forma que esta versión ya no escribe, y este arranque no lo puso al día. `not_applied` dice por qué: `migrate: warn`, o un motivo por el que el cambio necesita la palabra de alguien, como un InfluxDB 2, cuya única vía es un borrado definitivo, un fichero SQL, un Graphite o un Telegraf, donde ghchronicle no aparta nada, filas de cuentas que esta configuración no recoge, filas que volver a leer no traería de vuelta, o filas cuyas cuentas o repositorios no se pudieron leer, lo que un InfluxDB 3 Core que pasa de su límite de ficheros por consulta se niega a decir; o una ejecución de una sola vez sobre un fichero de estado nuevo, cada ejecución de la Action sin uno restaurado, que no aplica nada por su cuenta. `plan` y `apply` son las dos órdenes, y `first`, en el servicio, dice que lo pares antes de la segunda. Se dice en cada arranque hasta que el almacén se pone al día: mira [Migraciones](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#migraciones). **`applying a migration before the first sweep`.** No es un error. Con `migrate: auto` el arranque encontró un cambio que puede aplicar sin perder nada, y lo está aplicando: `found` es lo que guarda el almacén, `action` adónde van las filas antiguas, y `refill` lo que se vuelve a leer de GitHub. Sale una vez por almacén y cambio, seguido de `refill starting`, `refill complete` y `reconciled`, y luego la primera pasada. **`migration noted: nothing is changed` o `migration frozen`.** Tampoco son errores. Una nota es un cambio que nada puede arreglar, como las filas de `gh_actions_cache_entry` fechadas antes de la 2.6.0, y una medida congelada es una que ya no escribe nada de lo que corre esta configuración. Cada una se dice una vez en `INFO` y después en `DEBUG`. **`migration check failed: the store did not answer`.** Al arrancar se preguntó al almacén si guarda una forma antigua, y lo rechazó o no respondió en 30 segundos. No se anotó nada ni cambió nada; la pasada sigue, y el siguiente arranque vuelve a preguntar. **`migration failed`.** El almacén rechazó lo que aplicar le pidió, y el cambio sigue pendiente; los demás almacenes siguieron adelante. `err` dice por qué, y el motivo habitual es una credencial que puede escribir y no borrar: el token de InfluxDB 3 tiene que poder borrar una tabla, y el error lleva el mismo borrado como un `curl` para ejecutarlo a mano; el usuario de PostgreSQL tiene que ser dueño de la tabla, como lo es cuando la creó su propio destino; una clave de Elasticsearch necesita `manage` y `delete_index` sobre los índices de su prefijo. PostgreSQL además se rinde tras tres esperas de 5 segundos cada una detrás de una consulta que tiene la tabla, y una consulta larga de Grafana puede serlo. Ejecuta la misma orden otra vez cuando esté arreglada la causa: lo aplicado queda anotado y no se hace dos veces. **`refill did not finish, and is still owed`.** Volver a leer lo despejado se detuvo: se paró la ejecución, un almacén rechazó una escritura o GitHub no respondió. `resume` dice cómo seguir. El recorrido guarda su punto de control junto al fichero de estado, `-refill.json`, y `refill owed`, en [cosas que sí son errores](https://docs.jmrp.io/ghchronicle/es/reference/troubleshooting/#cosas-que-sí-son-errores), es lo que dice de él un arranque posterior. **`the store may have been cleared, so the refill stays owed`.** Despejar el almacén falló de una manera que no dice si se llevó a cabo: un 502 de un proxy, un tiempo agotado, una conexión que se rompió después de que el almacén hiciera lo que se le pidió. La relectura se anotó como debida antes de tocar el almacén y sigue debiéndose, así que el historial se vuelve a leer ocurriera o no el despeje; el cambio se vuelve a preguntar en el siguiente arranque. Como mucho eso lee el historial una vez para nada. **`the state file records a refill owed to ... where it no longer points`.** Una migración despejó un almacén, la relectura no terminó, y el destino apunta ahora a otro almacén. El registro se conserva, y la relectura se lee la próxima vez que el destino apunte allí; hasta entonces nada la lee. `-migrate` la muestra bajo el almacén como `owed there`. **`not reconciled: the copy and the table could not be compared`.** El historial se volvió a leer, y la copia de las filas antiguas no se pudo leer, o ya tocaba purgarla. Lo que volvió está en la tabla; solo falta la lista de lo que GitHub ya no sirve, y mientras la copia siga ahí se puede leer a mano. **`set-aside copy not purged` o `set-aside copies not listed`.** Un día después de que una migración la apartara, ghchronicle no pudo quitar la copia de PostgreSQL ni borrar el clon de Elasticsearch, o no pudo preguntar al almacén qué copias guarda. Lo vuelve a intentar tras cada pasada y en cada arranque; `aside` nombra la copia, que también se puede quitar a mano. InfluxDB 3 purga las suyas con su propio calendario, o, antes de la 3.2, nunca. **`the cache file still claims what the cleared store held`.** Una migración no pudo reescribir el fichero de caché para olvidar los rechazos, y las ejecuciones de workflow ya escritas, de las familias que vuelve a leer, así que esas familias pueden seguir saltándose lo que el almacén despejado ya no guarda. Con el servicio parado, borrar `-cache.bin` cuesta una pasada a precio completo: [la caché que hay a su lado](https://docs.jmrp.io/ghchronicle/es/configuration/#la-caché-que-hay-a-su-lado). **`-migrate -yes` responde `process N (ghchronicle ..., service, since ...) holds ...-lock`.** El servicio, u otro `-migrate -yes`, tiene el fichero de estado, y no se cambió nada. Páralo, o deja que termine, y ejecuta la orden otra vez. En Docker, N es el proceso con el número que le da el contenedor del propio servicio. **`waiting for the process holding the state file to finish`.** El servicio se arrancó mientras `-migrate -yes` corría sobre el mismo fichero de estado. Espera a que termine y luego arranca sobre lo que dejó. **`another ghchronicle service keeps the same state file`.** Se arrancó un segundo servicio sobre un fichero de estado que ya tiene uno en marcha, y se rechazó, porque dos servicios que guardan un mismo fichero de estado deshacen las marcas del otro. Para uno, o da a cada uno su propio `state_file`. **`the state file cannot be locked, so -migrate -yes cannot tell this service is running`.** El servicio no pudo crear ni abrir `-lock` junto al fichero de estado: el directorio no es escribible, o una ejecución como otro usuario, casi siempre root, creó el fichero. El servicio corre sin el candado, y entonces `-migrate -yes` se niega a correr, porque nada impediría que el servicio arrancara por debajo de él a medias. Ejecútalo con el usuario del servicio: [tras una actualización](https://docs.jmrp.io/ghchronicle/es/install/systemd/#tras-una-actualización). ## El PostgreSQL que conecta **`could not reach its database`.** El sink conecta en la primera escritura y no al arrancar, así que esto es la primera pasada y no la lectura de la configuración. El DSN admite las dos formas que admite libpq, y el mensaje es el del driver. **Falta una columna en vez de la fila.** Un campo sin valor no ocupa celda: una cadena vacía se deja fuera, y un float que no es un número se guarda como NULL. La columna aparece la primera vez que un punto lleva algo para ella. **Un campo cambió de tipo y el insert falla.** Una columna se crea con el tipo del primer valor visto y no se altera después, así que un campo que era un número y ahora es una cadena no tiene dónde ir. Renombra el campo, o tira la tabla, y la siguiente escritura del colector la declara otra vez. No hace falta reiniciar: el destino declara una tabla una vez por proceso, y el primer insert tras el borrado se rechaza porque la tabla no está, así que el destino olvida las tablas de ese lote, las declara de nuevo y reenvía el lote una vez. **Se rechazan las sentencias de `gh_discussion_comment` del fichero SQL.** Con `there is no unique or exclusion constraint matching the ON CONFLICT specification`, reproducido en una tabla que creó una versión anterior a la 2.6.1. Esa tabla conserva `is_answer` en su clave, que la 2.6.1 ya no escribe, y el fichero no puede leer la clave de una tabla como hace el destino que conecta, así que sus upserts nombran una clave que la tabla no tiene. El resto del fichero se carga. `-migrate -yes` escribe en el fichero el borrado de esa tabla, antes de las filas que vuelve a leer, así que el fichero reproducido en orden carga entero: ver [qué hace aplicarlo en cada almacén](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#qué-hace-aplicarlo-en-cada-almacén). A mano, tira la tabla, como describe [la página de medidas](https://docs.jmrp.io/ghchronicle/es/collectors/measurements/#cómo-leer-las-tablas), y vuelve a llenarla con un relleno histórico. **El sink de fichero y este no coinciden.** Salvo por la clave de arriba, no pueden: los dos renderizan el mismo esquema desde el mismo código, y `cmd/check_postgres` planifica contra él cada consulta de panel. Si las tablas difieren, una se cargó desde una versión anterior. --- # Historial de versiones La versión actual de ghchronicle y su fecha, leídas al compilar de VERSION y CHANGELOG.md, lo que recoge ese fichero y dónde se publica cada versión. Source: https://docs.jmrp.io/ghchronicle/es/reference/changelog/ La versión actual de ghchronicle es la 2.6.5, etiquetada el 2026-09-30. Ninguna de las dos está escrita a mano en esta página: el número se lee del fichero `VERSION` del repositorio al compilar el sitio, la fecha del encabezado que ese número lleva en `CHANGELOG.md`, y el sitio no compila si falta ese encabezado. Toda orden de instalación que nombra una versión la toma del mismo fichero. ## Qué recoge el changelog [`CHANGELOG.md`](https://github.com/jmrplens/ghchronicle/blob/main/CHANGELOG.md) dice qué cambió en cada versión, por qué, la medida que lo respalda y la parte que nadie verificó. Donde una afirmación suya se midió, dice sobre qué. Tiene una sección por versión, la más reciente primero, cada una encabezada por la versión y el día en que se subió su etiqueta, y desde la 2.5.1 la sección de una release es, palabra por palabra, sus notas en GitHub; la página de la release añade las órdenes para descargar y verificar y un enlace a los commits. Una sección no tiene etiqueta detrás: la 2.5.2 nunca salió por su cuenta, y sus cambios salieron en la 2.6.0. Está en inglés, como todo el repositorio fuera de este sitio. Cómo son las primeras horas al pasar de una release a la siguiente está en [actualizar](https://docs.jmrp.io/ghchronicle/es/install/upgrading/). El sitio enlaza el fichero en vez de copiarlo, así que hay un solo historial que mantener. Las versiones siguen el [versionado semántico](https://semver.org/lang/es/), y un binario dice cuál es la suya con `ghchronicle -version`, una de [las cinco opciones que imprimen y salen](https://docs.jmrp.io/ghchronicle/es/reference/cli/#las-cinco-que-imprimen-y-salen). ## Dónde está cada versión - [Las releases de GitHub](https://github.com/jmrplens/ghchronicle/releases) tienen cada etiqueta numerada con sus notas y sus ficheros: un archivo para Linux, macOS y Windows en amd64 y arm64, un SBOM SPDX por archivo y `checksums.txt` sobre todos ellos. El fichero de sumas y cada SBOM llevan una firma de cosign sin clave, y [comprobar lo que has descargado](https://docs.jmrp.io/ghchronicle/es/install/linux/#comprobar-lo-que-has-descargado) tiene las órdenes. Una release con el nombre de una etiqueta mayor, como `v1`, está ahí para el listado de la Action en el Marketplace y no lleva ficheros. - La imagen de contenedor, `ghcr.io/jmrplens/ghchronicle`, se etiqueta con la versión de cada release precedida de una `v`, y con `latest`, para `linux/amd64` y `linux/arm64`; `latest` pasa a la imagen de una release solo cuando el workflow de release la ha verificado y ejecutado. Desde la 2.5.1 lleva una firma de cosign sin clave con la misma identidad de flujo que el fichero de sumas, y [verificar la imagen](https://docs.jmrp.io/ghchronicle/es/install/docker/#verificar-la-imagen) tiene la orden. - [El feed de releases](https://github.com/jmrplens/ghchronicle/releases.atom) es la misma lista en Atom, para un lector de feeds. - La Action sigue a la versión mayor: `jmrplens/ghchronicle@v2` se resuelve a través de una etiqueta `v2` que el flujo de release mueve a cada release 2.x en cuanto sus ficheros están publicados. Ver [GitHub Actions](https://docs.jmrp.io/ghchronicle/es/install/actions/). --- # 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://docs.jmrp.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/v2/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](https://docs.jmrp.io/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 después los archivados que el filtro aparta, cada uno seguido de `(archived: its archive date and lifetime totals; a backfill collects the rest)`, 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](https://docs.jmrp.io/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=artifacts repo=acme/parser err="/repos/acme/parser/actions/artifacts?per_page=100&page=2: 502 Bad Gateway: {\n \"message\": \"Server Error\"\n}" ``` Un 500, 502, 503 o 504 en una línea así ya ha fallado dos veces: el cliente pregunta una vez más, dos segundos después, antes de rendirse con una petición REST o con la descarga del log de un job desde el almacenamiento, y no registra ninguno de los dos intentos. 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 con un commit nuevo para commits y uno o dos por repositorio con una issue o una pull request que se movió para issues, tras un punto por cada veinticinco repositorios que pregunta cuáles. [Coste de una pasada](https://docs.jmrp.io/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="cache file read" file=/tmp/ghchronicle-adhoc-cache.bin ... level=INFO msg="repositories discovered" count=1 archived_aside=0 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, 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í. El fichero de caché que hay a su lado, `/tmp/ghchronicle-adhoc-cache.bin` aquí, guarda los ETags, así que lo que no ha cambiado desde entonces recibe un 304 gratis; borrar también ese es lo que cuesta el precio completo. > **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](https://docs.jmrp.io/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. --- # 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://docs.jmrp.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. También busca como GitHub. Cada una de las cinco búsquedas salientes pide un tipo en un estado, `is:pr is:merged` o `is:issue is:open`, y el falso le sirve solo los elementos de su fixture que seleccionan esos calificadores, descontando los demás de `issueCount`. Un tipo o un estado que no conoce, un `is:`, `type:` o `state:` para el que no tiene regla, hace fallar la prueba; el autor, los propietarios excluidos y el orden ya son los de la propia fixture, y los pasa por alto. Hasta 2.6.2 respondía a todas las búsquedas con todos los elementos, así que una pasada escribía la única pull request fusionada cinco veces, una de ellas como issue abierta, y los dashboards dibujaban esas filas. Una fixture nunca escribe una fecha reciente. La deletrea como un desplazamiento que el falso resuelve al servir el fichero, `"@DAYS_AGO_12@T00:00:00Z"`, junto a los `@NOW@`, `@TODAY@` y `@SOON@` que ya resolvía, y `@DAYS_AHEAD_n@` para lo que tenga que seguir estando en el futuro. Un panel pide el último día, semana o mes, así que una fecha escrita a mano solo es correcta hasta que sale de esa ventana, y nada avisa del día en que lo hace: la release de 2.4.0 falló con cuatro paneles de cada almacén sin responder nada, doce horas después de que la misma suite pasara, porque entre medias el día en que se fusionó un pull request cruzó `now-30d`. Una prueba que necesite nombrar uno de esos días se lo pide a `fakegh.DaysAgo`, y una fecha escrita a mano hace fallar `TestNoFixtureWritesOutARecentDate` en el acto. 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. Cada panel se envía con su rango resuelto a los dos instantes que manda un navegador, y se le hacen tres preguntas, y a cada tarjeta y cada indicador dos más, abajo: si el datasource lo rechazó, si un panel sobre algo que el barrido escribió dentro del rango respondió con algo, y si los cinco almacenes dibujan lo mismo. Para la tercera, cada respuesta se reproduce a través de lo que Grafana hace entre la consulta y la pantalla, la remodelación propia del datasource de Prometheus de una tabla, las transformaciones del panel y sus overrides de campo, y cada par de almacenes se compara en lo que vería un lector: el número de una tarjeta, su unidad y las palabras que muestra cuando no hay nada, las filas de una tabla sobre las columnas que dibujan ambos almacenes, y el nombre y la longitud de una barra. Una tabla se sujeta además a un solo orden de las columnas que dibujan ambos almacenes, cosa que las filas emparejadas sobre esas columnas no pueden mostrar: en la rama 2.6.2, 28 tablas de Prometheus y 4 de Graphite las encabezaban en otro orden que los almacenes SQL, "Every bucket" con Most used al final. La revisión de la 2.6.1 encontró once diferencias poniendo los dashboards uno junto a otro, entre ellas una tabla que dibujaba siete filas para un repositorio y tarjetas que habían perdido su unidad. Nueve estaban en los dashboards, y ejecutada contra los dashboards de esa versión esta comprobación falla en ocho. La novena eran cuatro columnas de las que carecía "Open the longest" en Elasticsearch, que ninguna comparación de valores puede ver. Lo que un almacén puede guardar es asunto de su propia descripción, así que otra aserción exige que cada columna que dibujan los almacenes SQL en una tabla la dibuje también cada otro almacén que dibuja la tabla, o que la nombren las palabras propias de ese almacén sobre el panel: en la rama 2.6.2, 33 tablas de los otros tres almacenes carecían de una columna que su descripción no nombraba, y cada una la dibuja ahora o dice por qué no. Después se pregunta a cada tarjeta y cada indicador por nada, dos veces: por un repositorio para el que ningún barrido escribió nada, y sobre treinta días en los que no cae ningún punto de ningún barrido, de cuatrocientos días atrás o más. Sobre ninguna fila un recuento SQL es 0 y todo lo demás es nulo, que una tarjeta dibuja como las palabras que su panel da a un valor que no está, y se exige a cada almacén que dibuje las tarjetas que dibujan los almacenes SQL, con las mismas palabras. Se pregunta por el rango porque las instantáneas de la propia cuenta no siguen al selector de repositorios: sobre un rango de cuatrocientos días atrás Graphite dibujaba tres grupos de cifras como paneles sin nada, ni siquiera los nombres. Una tarjeta que un almacén no puede dibujar sobre nada se lista en `tilesLeftOverNothing`, en `test/e2e/docker/tiles_over_nothing_test.go`, con palabras de la propia descripción de ese almacén, y se sujeta a ellas igual que `dashboardsDiffer`. InfluxDB y PostgreSQL ejecutan una misma sentencia sobre las mismas filas, así que también se les exige dibujar las filas de una tabla o de una gráfica de barras en un solo orden, cosa que comparar las filas como conjunto no puede ver: antes de que las sentencias nombraran su desempate, doce de los 49 paneles que ambos dibujan con más de una fila ponían dos de ellas al revés en una ejecución, y once en la siguiente. `TestEverySQLListOrdersItsRowsCompletely`, en `internal/dashboards`, exige al `ORDER BY` de cada lista que nombre lo que distingue dos de sus filas, para que el próximo empate no espere a una ejecución que por casualidad lo dibuje. Dos de las reglas que se exigen a lo dibujado se exigen también a la especificación, ya que una tabla que la fixture deja vacía en los dos almacenes SQL no se dibuja nunca: cada columna que dibujan los almacenes SQL y otro almacén no, la nombra la propia descripción de ese almacén, y una gráfica que los almacenes SQL pliegan en other se pliega en cada otro almacén o dice que no lo hace (`TestEveryColumnAStoreLacksIsNamedInItsDescription` y `TestEveryStoreFoldsTheRestIntoOtherOrSaysItDoesNot`). Y una consulta de Prometheus que solo falla con series que la fixture nunca crea, un valor de etiqueta que nadie escribió o una serie que no se movió en el rango, se pone a promtool dentro del propio Prometheus del stack sobre series escritas para ella (`TestPrometheusAnswersSeriesTheFixtureHasNoneOfAsTheSQLStoresDo`): hasta entonces, Signed commits de una cuenta que nunca firma decía "no commits" junto a un recuento de 57. Un almacén que dibuja un panel de otra forma a propósito dice por qué en su propia descripción del panel, y la diferencia se lista en `dashboardsDiffer`, en `test/e2e/docker/dashboards_agree_test.go`, con esas palabras. La prueba falla cuando las palabras ya no están en la descripción, cuando los almacenes han pasado a dibujar el panel igual, y cuando un almacén que la entrada nombra dibuja el panel como los almacenes que no nombra, así que una entrada no puede sobrevivir a su razón ni excusar a un almacén que no necesita excusa. Lo que causa el propio arnés se absorbe donde surge en vez de listarse: el medio minuto de historia del exporter, los valores que el colector calcula con su propio reloj en tres barridos separados por un minuto, los valores que una consulta calcula desde `now()` en almacenes consultados con segundos de diferencia, y los nombres que Graphite guarda como nodos de ruta. Una aserción aparte falla con cualquier panel, series temporales incluidas, que dibuje un campo con el nombre que le dio su datasource, como `p50.0 seconds_to_merge`. ## 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](https://docs.jmrp.io/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](https://docs.jmrp.io/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`.