Ir al contenido

El fichero

ghchronicle lee un único fichero YAML, y el ejemplo documentado del repositorio es el punto de partida.

Ventana de terminal
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í.

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.

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.

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.

  • Directorio/etc/ghchronicle/
    • config.yaml legible por todos, versionado
    • ghchronicle.env modo 600, nunca versionado
github:
token: ${GITHUB_TOKEN}
reserve_rate: 500
timeout: 30s
# base_url: https://github.example.com/api/v3
# web_url: https://github.example.com
ClavePor omisiónSignificado
tokenobligatoriaUn token personal clásico o fine-grained
reserve_rate500Llamadas 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
timeout30sPor 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_urlapi.github.comUna instancia de GitHub Enterprise usa https://<host>/api/v3. GitHub Enterprise Server no sirve el historial diario de estrellas, así que allí gh_star_day y los paneles que cuentan estrellas a partir de ella quedan vacíos
web_urlderivada de la APIEl 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.

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.
  • 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 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 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

Sección titulada «El otro fichero en el que una pasada se recuerda a sí misma»
sinks:
dedupe_file: /var/lib/ghchronicle/state-written.bin
dedupe_horizon: 720h
ClavePor omisiónSignificado
sinks.dedupe_filejunto a state_file, como <nombre>-written.binEl registro de lo que ya se ha escrito. off lo desactiva para todos los destinos
sinks.dedupe_horizon720hCuá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.

/var/lib/ghchronicle/state-cache.bin

Junto a state_file, como <nombre>-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: 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, 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:

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:

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:

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:

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:
since: 2y

Solo se aplica a una ejecución lanzada con -backfill, y -backfill-since lo sobrescribe. Ver relleno histórico.

Un relleno lleva un fichero más junto a state_file, <nombre>-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.

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.

ValorQué hace un arranque
autoEl 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
warnNo 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, <nombre>-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.

La configuración se valida antes de hacer la primera llamada, y los mensajes nombran la clave y lo que necesita.

MensajeSignifica
github.token is empty and GITHUB_TOKEN is unsetExactamente 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 reposNo 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.<name>: unknown collectorEl nombre no es una familia. El mensaje lista las que existen
sinks.influxdb: url and bucket are requiredCada destino valida sus propias claves obligatorias y dice cuáles
sinks.sql.dialect: "mysql" is not postgres, the only dialect so farEl valor no es uno de los aceptados, y el mensaje los lista
migrate: "off" is not auto or warnLo mismo, para migrate