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:
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";
};
};
};