infrastructure

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 4bda2254b937d00fb6d7c4c76cea7e55f66dda2c
parent c75ce2073ef9244e3287b02d12fe6cfe88458fab
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun,  5 Jul 2026 16:19:03 +0200

feat: add nginx log archival, multi-source query-logs

- Archive nginx access logs hourly alongside app logs
- Serve both under /mnt/volume-hel1-1/logs/{app,nginx}/
- query-logs now takes a source argument: app or nginx
- Remove manifest.csv generation

Usage:
  query-logs app
  query-logs nginx 2026-07-05T16 2026-07-05T17
  query-logs nginx 2026-07-05T16 2026-07-05T17 "SELECT status, count(*) FROM logs GROUP BY status"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Mflake.nix | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mmodules/simple-web-app.nix | 36++++++++++++++++++++++--------------
2 files changed, 81 insertions(+), 33 deletions(-)

diff --git a/flake.nix b/flake.nix @@ -78,41 +78,81 @@ }; query-logs = pkgs.writeShellScriptBin "query-logs" '' set -euo pipefail - BASE_URL="http://10.100.0.7:9090" - SINCE="''${1:-$(date -d '24 hours ago' +%Y-%m-%dT%H)}" - UNTIL="''${2:-$(date +%Y-%m-%dT%H)}" - SQL="''${3:-SELECT * FROM logs ORDER BY timestamp DESC LIMIT 50}" + BASE="http://10.100.0.7:9090" - # Generate hourly URLs between SINCE and UNTIL, skipping missing files + usage() { + echo "Usage: query-logs <source> [since] [until] [sql]" + echo "" + echo "Sources:" + echo " app - simple-web-app journald logs" + echo " nginx - nginx access logs" + echo "" + echo "Examples:" + echo " query-logs app" + echo " query-logs nginx 2026-07-01T00 2026-07-04T00" + echo " query-logs app 2026-07-01T00 2026-07-02T00 \"SELECT * FROM logs WHERE message ILIKE '%error%'\"" + exit 1 + } + + [ "''${1:-}" = "-h" ] || [ "''${1:-}" = "--help" ] && usage + SOURCE="''${1:-app}" + SINCE="''${2:-$(date -d '24 hours ago' +%Y-%m-%dT%H)}" + UNTIL="''${3:-$(date +%Y-%m-%dT%H)}" + SQL="''${4:-SELECT * FROM logs ORDER BY timestamp DESC LIMIT 50}" + + case "$SOURCE" in + app) ext="json.gz"; dir="app" ;; + nginx) ext="log.gz"; dir="nginx" ;; + *) echo "Unknown source: $SOURCE"; usage ;; + esac + + # Generate hourly URLs, skipping missing files urls="" current=$(date -d "''${SINCE}:00:00" +%s) end=$(date -d "''${UNTIL}:00:00" +%s) while [ "$current" -le "$end" ]; do path=$(date -d "@$current" +%Y/%m/%d/%H) - url="$BASE_URL/$path.json.gz" + url="$BASE/$dir/$path.$ext" if ${pkgs.curl}/bin/curl -sf --head "$url" > /dev/null 2>&1; then urls="$urls '$url'," fi current=$((current + 3600)) done if [ -z "$urls" ]; then - echo "No log files found for the given range." + echo "No log files found for $SOURCE in the given range." exit 0 fi urls="[''${urls%,}]" - ${pkgs.duckdb}/bin/duckdb -c " - LOAD httpfs; - CREATE TEMP TABLE logs AS - SELECT - json_extract_string(j, '\$.MESSAGE') as message, - json_extract_string(j, '\$.__REALTIME_TIMESTAMP')::BIGINT / 1000000 as epoch, - to_timestamp(json_extract_string(j, '\$.__REALTIME_TIMESTAMP')::BIGINT / 1000000) as timestamp, - json_extract_string(j, '\$.PRIORITY') as priority, - json_extract_string(j, '\$.SYSLOG_IDENTIFIER') as identifier - FROM read_json_auto($urls, format='newline_delimited', ignore_errors=true) j; - $SQL; - " + if [ "$SOURCE" = "app" ]; then + ${pkgs.duckdb}/bin/duckdb -c " + LOAD httpfs; + CREATE TEMP TABLE logs AS + SELECT + to_timestamp(json_extract_string(j, '\$.__REALTIME_TIMESTAMP')::BIGINT / 1000000) as timestamp, + json_extract_string(j, '\$.MESSAGE') as message, + json_extract_string(j, '\$.PRIORITY') as priority, + json_extract_string(j, '\$.SYSLOG_IDENTIFIER') as identifier + FROM read_json_auto($urls, format='newline_delimited', ignore_errors=true) j; + $SQL; + " + else + ${pkgs.duckdb}/bin/duckdb -c " + LOAD httpfs; + CREATE TEMP TABLE logs AS + SELECT + regexp_extract(line, '^\S+', 0) as remote_addr, + regexp_extract(line, '\[([^\]]+)\]', 1) as timestamp_raw, + regexp_extract(line, '\"(\S+) (\S+) \S+\"', 1) as method, + regexp_extract(line, '\"(\S+) (\S+) \S+\"', 2) as path, + CAST(regexp_extract(line, '\" (\d+) ', 1) AS INTEGER) as status, + CAST(regexp_extract(line, '\" \d+ (\d+)', 1) AS INTEGER) as bytes, + regexp_extract(line, '\"([^\"]*)\"\s*$', 1) as user_agent + FROM read_csv($urls, header=false, columns={'line': 'VARCHAR'}, auto_detect=false, delim=E'\x01', quote=E'\x02', escape=E'\x02') t + WHERE line IS NOT NULL AND length(line) > 0; + $SQL; + " + fi '' ; in diff --git a/modules/simple-web-app.nix b/modules/simple-web-app.nix @@ -71,40 +71,48 @@ in services.nginx.virtualHosts."simple-web-app-logs" = { listen = [{ addr = "0.0.0.0"; port = 9090; }]; locations."/" = { - root = "/mnt/volume-hel1-1/simple-web-app/logs"; + root = "/mnt/volume-hel1-1/logs"; extraConfig = "autoindex on;"; }; }; # Archive logs to volume - systemd.services.simple-web-app-log-collection = { - script = '' - export PREVIOUS_HOUR=$(${pkgs.coreutils}/bin/date -d '1 hour ago 5 minutes ago' +%Y-%m-%dT%H:00:00) - export CURRENT_HOUR=$(${pkgs.coreutils}/bin/date -d '5 minutes ago' +%Y-%m-%dT%H:00:00) - mkdir -p $LOG_STORAGE_LOCATION/$(date -d "$PREVIOUS_HOUR" +%Y/%m/%d) - journalctl -u $SERVICE_NAME \ + systemd.services.log-collection = { + script = let + logDir = "/mnt/volume-hel1-1/logs"; + in '' + PREVIOUS_HOUR=$(${pkgs.coreutils}/bin/date -d '1 hour ago 5 minutes ago' +%Y-%m-%dT%H:00:00) + CURRENT_HOUR=$(${pkgs.coreutils}/bin/date -d '5 minutes ago' +%Y-%m-%dT%H:00:00) + DATEPATH=$(date -d "$PREVIOUS_HOUR" +%Y/%m/%d/%H) + + # App logs (journald) + mkdir -p ${logDir}/app/$(dirname $DATEPATH) + journalctl -u simple-web-app \ --since=$PREVIOUS_HOUR \ --until=$CURRENT_HOUR \ -o json | \ ${pkgs.gzip}/bin/gzip \ - > $LOG_STORAGE_LOCATION/$(date -d "$PREVIOUS_HOUR" +%Y/%m/%d/%H).json.gz + > ${logDir}/app/$DATEPATH.json.gz + + # Nginx access logs (raw lines, not JSON) + mkdir -p ${logDir}/nginx/$(dirname $DATEPATH) + ${pkgs.gawk}/bin/awk -v start="$(date -d "$PREVIOUS_HOUR" '+%d/%b/%Y:%H')" \ + '$0 ~ "\\[" start' /var/log/nginx/access.log | \ + ${pkgs.gzip}/bin/gzip \ + > ${logDir}/nginx/$DATEPATH.log.gz ''; - environment = { - SERVICE_NAME = "simple-web-app"; - LOG_STORAGE_LOCATION = "/mnt/volume-hel1-1/simple-web-app/logs"; - }; serviceConfig = { Type = "oneshot"; User = "root"; }; }; systemd.timers = { - simple-web-app-log-collection = { + log-collection = { wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "hourly"; Persistent = true; - Unit = "simple-web-app-log-collection.service"; + Unit = "log-collection.service"; }; }; };