Preview

file

Background

{"level":"error","ts":1753604356.9313068,"logger":"http.log.error","msg":"dial tcp: lookup remotely_app on 127.0.0.11:53: no such host","request":{"remote_ip":"49.73.126.48","remote_port":"32192","client_ip":"49.73.126.48","proto":"HTTP/2.0","method":"POST","host":"remotely.aiursoft.cn","uri":"/hubs/service/negotiate?negotiateVersion=1","headers":{"User-Agent":["Microsoft SignalR/8.0 (8.0.2+da7e9894ce22ef8cc02e5acc56e95a6f8cf8f644; Windows NT; .NET; .NET 8.0.2)"],"X-Requested-With":["XMLHttpRequest"],"Content-Length":["0"]},"tls":{"resumed":true,"version":771,"cipher_suite":49195,"proto":"h2","server_name":"remotely.aiursoft.cn"}},"duration":0.003472111,"status":502,"err_id":"sz0r0viky","err_trace":"reverseproxy.statusError (reverseproxy.go:1390)"}
{"level":"error","ts":1753604356.9313471,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"49.73.126.48","remote_port":"32192","client_ip":"49.73.126.48","proto":"HTTP/2.0","method":"POST","host":"remotely.aiursoft.cn","uri":"/hubs/service/negotiate?negotiateVersion=1","headers":{"X-Requested-With":["XMLHttpRequest"],"Content-Length":["0"],"User-Agent":["Microsoft SignalR/8.0 (8.0.2+da7e9894ce22ef8cc02e5acc56e95a6f8cf8f644; Windows NT; .NET; .NET 8.0.2)"]},"tls":{"resumed":true,"version":771,"cipher_suite":49195,"proto":"h2","server_name":"remotely.aiursoft.cn"}},"bytes_read":0,"user_id":"","duration":0.003472111,"size":0,"status":502,"resp_headers":{"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Strict-Transport-Security":["max-age=63072000"]}}

Above is the log generated by my Caddy.

Recently, I've been paying close attention to certain data points, such as:

  • Which IP address has the highest request volume? X-axis: time, Y-axis: request count, color: IP.
  • Which application is the most popular? X-axis: time, Y-axis: request count, color: application.
  • For a specific IP, which applications is it requesting? X-axis: time, Y-axis: request count, color: application.
  • For a specific application, which IPs are requesting it? X-axis: time, Y-axis: request count.
  • Which paths of an application are the slowest and which are the fastest? Sort and display response times? Show changes over time?
  • How are error codes distributed for an application? Show changes over time?
  • For a specific error code of an application, which paths are involved? Show changes over time?
  • Which application has the highest error rate? Show changes over time?

Currently, I need to visualize this data into charts and apply filters conveniently. I tried Grafana, but I feel it is not suitable: Grafana can only display numerical data, not string values like paths. It is more like monitoring a sensor that only generates numerical values over time.

What I want is something similar to a time-series database, where each row represents a single request. At the same time, I need to quickly add filters, group by, order by, etc., to generate charts, similar to Microsoft Kusto or Power BI.

I need an open-source solution. Currently, the logs are in stdout. It must be self-hostable and support Docker.

Ultimately, after investigation, I decided to use ClickHouse + Superset to visualize my interface.

Plan

I think the upcoming work will likely require several steps. But primarily, it comes down to four:

  1. Ensure Caddy's logs can be output to a file or stdout, and are properly formatted. JSON is preferred.
  2. Set up a process to consume Caddy's logs and store them in real time in a ClickHouse DB.
  3. Use a graphical viewer connected to the ClickHouse DB to start analyzing the data, ensuring it is clean, usable, and valuable.
  4. Build a web portal that visualizes the analysis results into web-based charts to present to IT personnel.

I believe this plan is very comprehensive, with no likely issues remaining. Ready to start implementation!

In detail, I may need Python for collection. Then create tables in ClickHouse. Finally, use Superset for visualization.

Obtain the Standard Log Stream

For the first step, here is what I did: I compiled Caddy myself, then added a few plugins to ensure it could output logs in the format I needed.

I also added support for brotli compression, transform-encoder, and rate limiting.

# ============================
# Prepare caddy Environment
FROM caddy:builder AS caddy-build-env

RUN xcaddy build \
    --with github.com/ueffel/caddy-brotli \
    --with github.com/caddyserver/transform-encoder \
    --with github.com/mholt/caddy-ratelimit

# ============================
# Prepare Caddyfile build Environment
FROM localhost:8080/box_starting/local_ubuntu AS config-build-env
WORKDIR /app
COPY . .

# Outputs to /app/Dist/Caddyfile
RUN chmod +x /app/build_proxy.sh
RUN /bin/bash /app/build_proxy.sh

# ============================
# Prepare Runtime Environment
FROM caddy

WORKDIR /app

EXPOSE 80 443

COPY --from=caddy-build-env /usr/bin/caddy /usr/bin/caddy
COPY --from=config-build-env /app/Dist/Caddyfile /etc/caddy/Caddyfile

# Check if any fatal errors in caddyfile
RUN caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile && \
    mkdir -p /var/log/caddy && \
    touch /data/caddy/logs/web.log

ENTRYPOINT ["sh", "-c", "caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & tail -f /data/caddy/logs/web.log & wait"]

I use the Dockerfile mentioned above to compile caddy.

And at the beginning of the caddyfile, I added:

{
	email [email protected]
	log {
		format json
		# 注意,日志写到了:/data/caddy/logs/web.log
		# /data 目录是容器的一个 VOLUME, 会穿透到主机。
		output file /data/caddy/logs/web.log {
			roll_size 1gb
			roll_uncompressed
		}
		level debug
	}
	servers :443 {
		listener_wrappers {
			proxy_protocol {
        # 注意:这里的 proxy_protocol 需要开启,才能支持 FRP 的代理。
				timeout 2s
				allow 10.233.0.0/16
			}
			http_redirect
			tls
		}
	}
}

to ensure that it outputs logs.

Then I used the following docker-compose to start my caddy:

version: '3.9'

services:
  sites:
    image: localhost:8080/box_starting/local_sites
    ports:
      # These ports are for internal use. For external, FRP will handle it.
      - target: 80
        published: 80
        protocol: tcp
        mode: host
      - target: 443
        published: 443
        protocol: tcp
        mode: host
    networks:
      - frp_net
      - proxy_app
    volumes:
      # 注意:Sites 容器里的 /data 目录存着 log。 
      - sites-data:/data
    stop_grace_period: 60s
    deploy:
      resources:
        limits:
          cpus: '8.0'
          memory: 16G
      update_config:
        order: stop-first
        delay: 60s

volumes:
  sites-data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /swarm-vol/sites-data

networks:
  proxy_app:
    external: true

Now, I have confirmed that the logs are normal:

anduin@proart:/swarm-vol/Box$ sudo docker ps | grep sites
bd9f24b9c79f   localhost:8080/box_starting/local_sites:latest                                   "sh -c 'caddy run --…"    5 minutes ago        Up 5 minutes                           0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp      incoming_sites.1.mai5g7xgxugy5p4prfwk4ohey
anduin@proart:/swarm-vol/Box$ sudo docker logs -f bd9f24b9c79f
{"level":"debug","ts":1753604514.283448,"logger":"tls.handshake","msg":"matched certificate in cache","remote_ip":"49.13.130.29","remote_port":"3132","subjects":["auth.aiursoft.cn"],"managed":true,"expiration":1759912228,"hash":"35212a5b8ae720958f58e378d367bba7c7f2fb75451851a14eec9cb96b54542e"}
{"level":"debug","ts":1753604514.3975098,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"stathub_stathubserver:5000","total_upstreams":1}
{"level":"debug","ts":1753604514.3991885,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"stathub_stathubserver:5000","duration":0.001577048,"request":{"remote_ip":"119.29.247.197","remote_port":"57854","client_ip":"119.29.247.197","proto":"HTTP/1.1","method":"POST","host":"stathub.aiursoft.cn","uri":"/api/metrics","headers":{"X-Forwarded-Host":["stathub.aiursoft.cn"],"Via":["1.1 Caddy"],"Accept":["application/json"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["2604"],"User-Agent":[""],"X-Forwarded-For":["119.29.247.197"],"X-Forwarded-Proto":["https"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"stathub.aiursoft.cn"}},"headers":{"X-Frame-Options":["SAMEORIGIN"],"Content-Security-Policy":["frame-ancestors 'self'"],"Content-Type":["application/json; charset=utf-8"],"Date":["Sun, 27 Jul 2025 08:21:53 GMT"],"Server":["Kestrel"],"Vary":["Accept-Encoding"]},"status":201}
{"level":"info","ts":1753604514.3996112,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"119.29.247.197","remote_port":"57854","client_ip":"119.29.247.197","proto":"HTTP/1.1","method":"POST","host":"stathub.aiursoft.cn","uri":"/api/metrics","headers":{"Accept":["application/json"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["2604"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"stathub.aiursoft.cn"}},"bytes_read":2604,"user_id":"","duration":0.002170407,"size":56,"status":201,"resp_headers":{"Alt-Svc":["h3=\":443\"; ma=2592000"],"Vary":["Accept-Encoding"],"Content-Security-Policy":["frame-ancestors 'self'"],"Date":["Sun, 27 Jul 2025 08:21:53 GMT"],"Server":["Kestrel"],"Via":["1.1 Caddy"],"Strict-Transport-Security":["max-age=63072000"],"X-Frame-Options":["SAMEORIGIN"],"Content-Type":["application/json; charset=utf-8"]}}
{"level":"debug","ts":1753604514.6455557,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"authentik_server:9000","total_upstreams":1}
{"level":"debug","ts":1753604514.672455,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"authentik_server:9000","duration":0.026780181,"request":{"remote_ip":"49.13.130.29","remote_port":"3132","client_ip":"49.13.130.29","proto":"HTTP/1.1","method":"HEAD","host":"auth.aiursoft.cn","uri":"/application/o/authorize/?client_id=wzt4srMcPxcNgaQh70XZtOoBCAlmTgV6IjeRAyUj&response_type=code&scope=email+nextcloud+openid&redirect_uri=https%3A%2F%2Fnextcloud.aiursoft.cn%2Findex.php%2Fapps%2Fuser_oidc%2Fcode&claims=%7B%22id_token%22%3A%7B%22email%22%3Anull%2C%22name%22%3Anull%2C%22quota%22%3Anull%2C%22groups%22%3Anull%2C%22user_id%22%3A%7B%22essential%22%3Atrue%7D%7D%2C%22userinfo%22%3A%7B%22email%22%3Anull%2C%22name%22%3Anull%2C%22quota%22%3Anull%2C%22groups%22%3Anull%2C%22user_id%22%3A%7B%22essential%22%3Atrue%7D%7D%7D&state=YN7C2YTH1G0GUERS9AWJIEGJE3VIHP6Y&nonce=0DHHR241WY94Q359GYXM9RL1CEOGQMC9&code_challenge=3WDMHL3_vwH_RAtCF1EQe62C957HZPcLqQz3Uhky8uM&code_challenge_method=S256","headers":{"Cache-Control":["no-cache"],"User-Agent":["Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"Accept-Encoding":["gzip, deflate, br"],"X-Forwarded-Proto":["https"],"Via":["1.1 Caddy"],"X-Forwarded-For":["49.13.130.29"],"Referer":["https://nextcloud.aiursoft.cn"],"Accept-Language":["en-US,en;q=0.8"],"X-Forwarded-Host":["auth.aiursoft.cn"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"auth.aiursoft.cn"}},"headers":{"X-Authentik-Id":["8c7197d81ceb4b59b6bea27db85eb80f"],"X-Frame-Options":["DENY"],"Content-Type":["text/html; charset=utf-8"],"Location":["/flows/-/default/authentication/?next=/application/o/authorize/%3Fclient_id%3Dwzt4srMcPxcNgaQh70XZtOoBCAlmTgV6IjeRAyUj%26response_type%3Dcode%26scope%3Demail%2Bnextcloud%2Bopenid%26redirect_uri%3Dhttps%253A%252F%252Fnextcloud.aiursoft.cn%252Findex.php%252Fapps%252Fuser_oidc%252Fcode%26claims%3D%257B%2522id_token%2522%253A%257B%2522email%2522%253Anull%252C%2522name%2522%253Anull%252C%2522quota%2522%253Anull%252C%2522groups%2522%253Anull%252C%2522user_id%2522%253A%257B%2522essential%2522%253Atrue%257D%257D%252C%2522userinfo%2522%253A%257B%2522email%2522%253Anull%252C%2522name%2522%253Anull%252C%2522quota%2522%253Anull%252C%2522groups%2522%253Anull%252C%2522user_id%2522%253A%257B%2522essential%2522%253Atrue%257D%257D%257D%26state%3DYN7C2YTH1G0GUERS9AWJIEGJE3VIHP6Y%26nonce%3D0DHHR241WY94Q359GYXM9RL1CEOGQMC9%26code_challenge%3D3WDMHL3_vwH_RAtCF1EQe62C957HZPcLqQz3Uhky8uM%26code_challenge_method%3DS256"],"Referrer-Policy":["same-origin"],"Set-Cookie":["REDACTED"],"Vary":["Accept-Encoding","Cookie"],"X-Content-Type-Options":["nosniff"],"X-Powered-By":["authentik"],"Content-Length":["23"],"Content-Encoding":["gzip"],"Date":["Sun, 27 Jul 2025 08:21:54 GMT"]},"status":302}
{"level":"info","ts":1753604514.6726158,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"49.13.130.29","remote_port":"3132","client_ip":"49.13.130.29","proto":"HTTP/1.1","method":"HEAD","host":"auth.aiursoft.cn","uri":"/application/o/authorize/?client_id=wzt4srMcPxcNgaQh70XZtOoBCAlmTgV6IjeRAyUj&response_type=code&scope=email+nextcloud+openid&redirect_uri=https%3A%2F%2Fnextcloud.aiursoft.cn%2Findex.php%2Fapps%2Fuser_oidc%2Fcode&claims=%7B%22id_token%22%3A%7B%22email%22%3Anull%2C%22name%22%3Anull%2C%22quota%22%3Anull%2C%22groups%22%3Anull%2C%22user_id%22%3A%7B%22essential%22%3Atrue%7D%7D%2C%22userinfo%22%3A%7B%22email%22%3Anull%2C%22name%22%3Anull%2C%22quota%22%3Anull%2C%22groups%22%3Anull%2C%22user_id%22%3A%7B%22essential%22%3Atrue%7D%7D%7D&state=YN7C2YTH1G0GUERS9AWJIEGJE3VIHP6Y&nonce=0DHHR241WY94Q359GYXM9RL1CEOGQMC9&code_challenge=3WDMHL3_vwH_RAtCF1EQe62C957HZPcLqQz3Uhky8uM&code_challenge_method=S256","headers":{"Referer":["https://nextcloud.aiursoft.cn"],"User-Agent":["Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"Accept-Encoding":["gzip, deflate, br"],"Accept-Language":["en-US,en;q=0.8"],"Cache-Control":["no-cache"],"Connection":["close"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"auth.aiursoft.cn"}},"bytes_read":0,"user_id":"","duration":0.027245944,"size":0,"status":302,"resp_headers":{"Via":["1.1 Caddy"],"Content-Length":["23"],"Date":["Sun, 27 Jul 2025 08:21:54 GMT"],"X-Frame-Options":["DENY"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Content-Encoding":["gzip"],"X-Authentik-Id":["8c7197d81ceb4b59b6bea27db85eb80f"],"Set-Cookie":["REDACTED"],"Strict-Transport-Security":["max-age=63072000"],"Content-Type":["text/html; charset=utf-8"],"Referrer-Policy":["same-origin"],"X-Content-Type-Options":["nosniff"],"Location":["/flows/-/default/authentication/?next=/application/o/authorize/%3Fclient_id%3Dwzt4srMcPxcNgaQh70XZtOoBCAlmTgV6IjeRAyUj%26response_type%3Dcode%26scope%3Demail%2Bnextcloud%2Bopenid%26redirect_uri%3Dhttps%253A%252F%252Fnextcloud.aiursoft.cn%252Findex.php%252Fapps%252Fuser_oidc%252Fcode%26claims%3D%257B%2522id_token%2522%253A%257B%2522email%2522%253Anull%252C%2522name%2522%253Anull%252C%2522quota%2522%253Anull%252C%2522groups%2522%253Anull%252C%2522user_id%2522%253A%257B%2522essential%2522%253Atrue%257D%257D%252C%2522userinfo%2522%253A%257B%2522email%2522%253Anull%252C%2522name%2522%253Anull%252C%2522quota%2522%253Anull%252C%2522groups%2522%253Anull%252C%2522user_id%2522%253A%257B%2522essential%2522%253Atrue%257D%257D%257D%26state%3DYN7C2YTH1G0GUERS9AWJIEGJE3VIHP6Y%26nonce%3D0DHHR241WY94Q359GYXM9RL1CEOGQMC9%26code_challenge%3D3WDMHL3_vwH_RAtCF1EQe62C957HZPcLqQz3Uhky8uM%26code_challenge_method%3DS256"],"Vary":["Accept-Encoding","Cookie"],"X-Powered-By":["authentik"]}}
{"level":"debug","ts":1753604514.6838558,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"stathub_stathubserver:5000","total_upstreams":1}
{"level":"debug","ts":1753604514.6847696,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"stathub_stathubserver:5000","duration":0.000850831,"request":{"remote_ip":"192.168.50.62","remote_port":"58904","client_ip":"192.168.50.62","proto":"HTTP/1.1","method":"POST","host":"stathub.aiursoft.cn","uri":"/api/metrics","headers":{"Accept":["application/json"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["2650"],"User-Agent":[""],"X-Forwarded-For":["192.168.50.62"],"X-Forwarded-Proto":["https"],"X-Forwarded-Host":["stathub.aiursoft.cn"],"Via":["1.1 Caddy"]},"tls":{"resumed":true,"version":772,"cipher_suite":4865,"proto":"","server_name":"stathub.aiursoft.cn"}},"headers":{"Content-Type":["application/json; charset=utf-8"],"Date":["Sun, 27 Jul 2025 08:21:53 GMT"],"Server":["Kestrel"],"Vary":["Accept-Encoding"],"X-Frame-Options":["SAMEORIGIN"],"Content-Security-Policy":["frame-ancestors 'self'"]},"status":201}
{"level":"info","ts":1753604514.684859,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"192.168.50.62","remote_port":"58904","client_ip":"192.168.50.62","proto":"HTTP/1.1","method":"POST","host":"stathub.aiursoft.cn","uri":"/api/metrics","headers":{"Accept":["application/json"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["2650"]},"tls":{"resumed":true,"version":772,"cipher_suite":4865,"proto":"","server_name":"stathub.aiursoft.cn"}},"bytes_read":2650,"user_id":"","duration":0.001125525,"size":56,"status":201,"resp_headers":{"Content-Security-Policy":["frame-ancestors 'self'"],"Content-Type":["application/json; charset=utf-8"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"X-Frame-Options":["SAMEORIGIN"],"Date":["Sun, 27 Jul 2025 08:21:53 GMT"],"Server":["Kestrel"],"Vary":["Accept-Encoding"],"Via":["1.1 Caddy"],"Strict-Transport-Security":["max-age=63072000"]}}
{"level":"debug","ts":1753604514.756761,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"stathub_stathubserver:5000","total_upstreams":1}
{"level":"debug","ts":1753604514.7580035,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"stathub_stathubserver:5000","duration":0.001143758,"request":{"remote_ip":"45.77.178.215","remote_port":"41356","client_ip":"45.77.178.215","proto":"HTTP/1.1","method":"POST","host":"stathub.aiursoft.cn","uri":"/api/metrics","headers":{"Via":["1.1 Caddy"],"Accept":["application/json"],"Content-Type":["application/json; charset=utf-8"],"Content-Length":["2591"],"User-Agent":[""],"X-Forwarded-For":["45.77.178.215"],"X-Forwarded-Proto":["https"],"X-Forwarded-Host":["stathub.aiursoft.cn"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"stathub.aiursoft.cn"}},"headers":{"Date":["Sun, 27 Jul 2025 08:21:53 GMT"],"Server":["Kestrel"],"Vary":["Accept-Encoding"],"X-Frame-Options":["SAMEORIGIN"],"Content-Security-Policy":["frame-ancestors 'self'"],"Content-Type":["application/json; charset=utf-8"]},"status":201}

Consuming Logs

Regarding the Caddy logs above, it is crucial to understand: they are not in standard JSON format. Instead, each line is a separate JSON object. You must read them line by line. Attempting to deserialize the entire file at once will fail. Furthermore, as new requests arrive, new lines are continuously appended to the file.

Before proceeding, to ensure that my deep understanding of the file structure is correct, I accessed the host:

anduin@proart:/swarm-vol/sites-data/caddy/logs$ ls
web.log
anduin@proart:/swarm-vol/sites-data/caddy/logs$ ls -ashl
total 6.3M
4.0K drwxr-xr-x 2 root root 4.0K Jul 27 08:44 .
4.0K drwxr-xr-x 7 root root 4.0K Jul 27 08:44 ..
6.2M -rw------- 1 root root 6.2M Jul 27 08:54 web.log
anduin@proart:/swarm-vol/sites-data/caddy/logs$ 

This confirms that sites — specifically, the /sites-data/caddy/logs directory of the caddy container — already contains web.log.

Now, I believe the next step should focus on setting up the ClickHouse database and configuring the parsing of Caddy logs so they can be streamed into ClickHouse.

We can proceed in three steps:

  • Deploy ClickHouse
  • Design and create the log table schema
  • Use Python to collect Caddy JSON logs, parse them, and write them to ClickHouse

Deploy ClickHouse

To deploy Clickhouse, I modified docker-compose.yml

  clickhouse:
    image: hub.aiursoft.cn/clickhouse/clickhouse-server:latest
    ports:
      - target: 8123
        published: 8123
        protocol: tcp
        mode: host
      - target: 9000
        published: 9000
        protocol: tcp
        mode: host
    volumes:
      - click-house-data:/var/lib/clickhouse
    stop_grace_period: 60s
    deploy:
      resources:
        limits:
          cpus: '4.0'
          memory: 8G
      update_config:
        order: stop-first
        delay: 60s

Pretty fast, I got clickhouse running:

anduin@proart:/swarm-vol/Box$ sudo docker ps | grep clickhouse
99dae96430f9   hub.aiursoft.cn/clickhouse/clickhouse-server:latest                              "/entrypoint.sh"          8 seconds ago    Up 7 seconds              0.0.0.0:8123->8123/tcp, [::]:8123->8123/tcp, 0.0.0.0:9000->9000/tcp, [::]:9000->9000/tcp, 9009/tcp   incoming_clickhouse.1.3w3lo1xg8fywgzswzyvufi072
anduin@proart:/swarm-vol/Box$ sudo docker logs -f 99dae96430f9
/entrypoint.sh: neither CLICKHOUSE_USER nor CLICKHOUSE_PASSWORD is set, disabling network access for user 'default'
Processing configuration file '/etc/clickhouse-server/config.xml'.
Merging configuration file '/etc/clickhouse-server/config.d/docker_related_config.xml'.
Logging trace to /var/log/clickhouse-server/clickhouse-server.log
Logging errors to /var/log/clickhouse-server/clickhouse-server.err.log
^Canduin@proart:/swarm-vol/Box$ cd /swarm-vol/sites-click-house-data/
anduin@proart:/swarm-vol/sites-click-house-data$ ls
access  data  flags  format_schemas  metadata  metadata_dropped  preprocessed_configs  status  store  tmp  user_files  uuid
anduin@proart:/swarm-vol/sites-click-house-data$ 

It looks like ClickHouse is working fine, so I quickly jumped in as well:

anduin@proart:/swarm-vol/sites-click-house-data$ sudo docker exec -it 99dae96430f9 clickhouse-client --user default
ClickHouse client version 25.6.4.12 (official build).
Connecting to localhost:9000 as user default.
Connected to ClickHouse server version 25.6.4.

Warnings:
 * Delay accounting is not enabled, OSIOWaitMicroseconds will not be gathered. You can enable it using `echo 1 > /proc/sys/kernel/task_delayacct` or by using sysctl.

99dae96430f9 :) 

Creating a ClickHouse Table

Next, we should carefully analyze the caddy logs. Here, we need to proceed with caution when designing the tables, as changing them may be very difficult in the future. After thinking about it for a while, I executed:

99dae96430f9 :) CREATE DATABASE IF NOT EXISTS logs;

CREATE TABLE logs.caddy_requests (
  ts             DateTime64(3, 'UTC'),
  level          LowCardinality(String),
  logger         LowCardinality(String),
  msg            String,
  remote_ip      String,
  remote_port    UInt16,
  method         LowCardinality(String),
  host           LowCardinality(String),
  uri            String,
  status         UInt16,
  duration_ms    Float32,
  bytes_sent     UInt64,
  user_agent     String,
  err_id         LowCardinality(String),
  err_trace      String
)
ENGINE = MergeTree()
-- 按“年-月”分区,也可改成 toYYYYMMDD(ts) 做日分区
PARTITION BY toYYYYMM(ts)
-- 主键兼排序键
ORDER BY (ts, host, remote_ip)
-- 自动 90 天后清理旧数据(可选)
TTL ts + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;


CREATE DATABASE IF NOT EXISTS logs

Query id: 418c462f-58cd-42d2-af34-192a793ebd92

Ok.

0 rows in set. Elapsed: 0.002 sec. 


CREATE TABLE logs.caddy_requests
(
    `ts` DateTime64(3, 'UTC'),
    `level` LowCardinality(String),
    `logger` LowCardinality(String),
    `msg` String,
    `remote_ip` String,
    `remote_port` UInt16,
    `method` LowCardinality(String),
    `host` LowCardinality(String),
    `uri` String,
    `status` UInt16,
    `duration_ms` Float32,
    `bytes_sent` UInt64,
    `user_agent` String,
    `err_id` LowCardinality(String),
    `err_trace` String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (ts, host, remote_ip)
TTL ts + toIntervalDay(90)
SETTINGS index_granularity = 8192

Query id: 91e21c7a-e33a-40fc-a65c-48a35105358b

Ok.

0 rows in set. Elapsed: 0.007 sec. 

99dae96430f9 :) 

It seems there are no issues. To verify that the table exists and has a reasonable structure, I ran the following:

99dae96430f9 :) -- 列出所有数据库,确认 logs 库存在
SHOW DATABASES;

-- 列出 logs 库中的所有表,确认 caddy_requests 在列
SHOW TABLES FROM logs;

-- 查看表结构,确认各列类型和分区定义
DESCRIBE TABLE logs.caddy_requests;

-- 或者直接看完整的 CREATE TABLE 语句
SHOW CREATE TABLE logs.caddy_requests;


SHOW DATABASES

Query id: 6fa522c4-8e60-4be9-a47c-496e89d8cd97

   ┌─name───────────────┐
1. │ INFORMATION_SCHEMA │
2. │ default            │
3. │ information_schema │
4. │ logs               │
5. │ system             │
   └────────────────────┘

5 rows in set. Elapsed: 0.002 sec. 


SHOW TABLES FROM logs

Query id: 4372beac-da98-48c8-94b8-32b653a87a2a

   ┌─name───────────┐
1. │ caddy_requests │
   └────────────────┘

1 row in set. Elapsed: 0.002 sec. 


DESCRIBE TABLE logs.caddy_requests

Query id: 8c24a725-5a93-4ec3-9199-6265cb3a60e4

    ┌─name────────┬─type────────────────────────────┬─default_type─┬─default_expression─┬─comment─┬─codec_expression─┬─ttl_expression─┐
 1. │ ts          │ DateTime64(3, 'UTC') │              │                    │         │                  │                │
 2. │ level       │ LowCardinality(String)          │              │                    │         │                  │                │
 3. │ logger      │ LowCardinality(String)          │              │                    │         │                  │                │
 4. │ msg         │ String                          │              │                    │         │                  │                │
 5. │ remote_ip   │ String                          │              │                    │         │                  │                │
 6. │ remote_port │ UInt16                          │              │                    │         │                  │                │
 7. │ method      │ LowCardinality(String)          │              │                    │         │                  │                │
 8. │ host        │ LowCardinality(String)          │              │                    │         │                  │                │
 9. │ uri         │ String                          │              │                    │         │                  │                │
10. │ status      │ UInt16                          │              │                    │         │                  │                │
11. │ duration_ms │ Float32                         │              │                    │         │                  │                │
12. │ bytes_sent  │ UInt64                          │              │                    │         │                  │                │
13. │ user_agent  │ String                          │              │                    │         │                  │                │
14. │ err_id      │ LowCardinality(String)          │              │                    │         │                  │                │
15. │ err_trace   │ String                          │              │                    │         │                  │                │
    └─────────────┴─────────────────────────────────┴──────────────┴────────────────────┴─────────┴──────────────────┴────────────────┘

15 rows in set. Elapsed: 0.001 sec. 


SHOW CREATE TABLE logs.caddy_requests

Query id: ff56bcfd-6cd5-4d30-a760-0cca533bdef3

   ┌─statement─────────────────────────────────┐
1. │ CREATE TABLE logs.caddy_requests         ↴│
   │↳(                                        ↴│
   │↳    `ts` DateTime64(3, 'UTC'),↴│
   │↳    `level` LowCardinality(String),      ↴│
   │↳    `logger` LowCardinality(String),     ↴│
   │↳    `msg` String,                        ↴│
   │↳    `remote_ip` String,                  ↴│
   │↳    `remote_port` UInt16,                ↴│
   │↳    `method` LowCardinality(String),     ↴│
   │↳    `host` LowCardinality(String),       ↴│
   │↳    `uri` String,                        ↴│
   │↳    `status` UInt16,                     ↴│
   │↳    `duration_ms` Float32,               ↴│
   │↳    `bytes_sent` UInt64,                 ↴│
   │↳    `user_agent` String,                 ↴│
   │↳    `err_id` LowCardinality(String),     ↴│
   │↳    `err_trace` String                   ↴│
   │↳)                                        ↴│
   │↳ENGINE = MergeTree                       ↴│
   │↳PARTITION BY toYYYYMM(ts)                ↴│
   │↳ORDER BY (ts, host, remote_ip)           ↴│
   │↳TTL ts + toIntervalDay(90)               ↴│
   │↳SETTINGS index_granularity = 8192         │
   └───────────────────────────────────────────┘

1 row in set. Elapsed: 0.001 sec. 

99dae96430f9 :) 

Writing a Simple Collector in Python

Next, I believe we should focus on writing Caddy's data into the ClickHouse database. To accomplish this, I plan to write a collector in Python.

Of course, we also need to add a service to docker-compose. However, before that, we first need to write a Dockerfile. This Dockerfile will configure Python to read logs from JSON, convert each line of JSON into structured fields, and then write them to ClickHouse.

Once this Docker image has been built, we can formally add it to docker-compose.

So I wrote the file Dockerfile:

# ===========================
# Build/Runtime in one stage
# ===========================
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update && apt-get install -y --no-install-recommends \
        curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

RUN pip install clickhouse-connect==0.7.19 uvloop==0.20.0 watchdog==4.0.1

WORKDIR /app
COPY main.py /app/main.py

# state 目录保存 offset
VOLUME ["/state"]

# 默认环境变量(可在 compose/Swarm 覆盖)
ENV LOG_FILE=/data/caddy/logs/web.log \
    CLICKHOUSE_URL=http://clickhouse:8123 \
    CLICKHOUSE_USER=default \
    CLICKHOUSE_PASSWORD=123456 \
    CLICKHOUSE_DATABASE=logs \
    CLICKHOUSE_TABLE=caddy_requests \
    BATCH_SIZE=1000 \
    FLUSH_INTERVAL_SEC=2

CMD ["python", "/app/main.py"]

At the same time, I also wrote the main.py file. This Python program is a custom-built log ETL pipeline: it continuously tails Caddy’s line-based JSON log files (with support for log rotation), filters only http.log.access records, parses and cleans them into a fixed set of 15 columns (timestamp, IP, method, URI, status code, duration, etc.), converts Caddy’s floating-point timestamps into local-timezone DateTime64(3), explicitly casts numeric fields, and safely extracts the first item from the User-Agent header. It then buffers records in an asynchronous queue and triggers batch inserts based on either record count or elapsed time, writing to ClickHouse via clickhouse-connect. On startup, it first queries max(ts) and uses binary search to locate the file offset, avoiding duplicate imports. Upon receiving SIGINT/SIGTERM, it performs a graceful shutdown and executes a final flush. The overall goal is to avoid the complexity of Vector while maintaining recoverability, low latency, batch efficiency, and type stability.

#!/usr/bin/env python3
import os
import json
import time
import signal
import errno
import threading
import asyncio
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import logging

import uvloop
from clickhouse_connect import get_client

# === Logging setup ===
logging.basicConfig(level=logging.DEBUG, format='[%(levelname)s] %(asctime)s %(message)s')
logger = logging.getLogger(__name__)

# === Configuration ===
LOG_FILE = os.getenv('LOG_FILE', '/data/caddy/logs/web.log')
CH_URL = os.getenv('CLICKHOUSE_URL', 'http://clickhouse:8123')
CH_USER = os.getenv('CLICKHOUSE_USER', 'default')
CH_PASS = os.getenv('CLICKHOUSE_PASSWORD', '')
CH_DB = os.getenv('CLICKHOUSE_DATABASE', 'logs')
CH_TABLE = os.getenv('CLICKHOUSE_TABLE', 'caddy_requests')
BATCH_SIZE = int(os.getenv('BATCH_SIZE', '1000'))
FLUSH_INTERVAL = float(os.getenv('FLUSH_INTERVAL_SEC', '2'))
TZ = ZoneInfo(os.getenv('TZ', 'UTC'))

# Columns in ClickHouse order
COLUMNS = [
    'ts', 'level', 'logger', 'msg',
    'remote_ip', 'remote_port', 'method', 'host', 'uri',
    'status', 'duration_ms', 'bytes_sent',
    'user_agent', 'err_id', 'err_trace'
]

# Shutdown event
stop_event = threading.Event()


def create_ch_client():
    secure = CH_URL.startswith('https')
    parts = CH_URL.split('://')[-1].split(':')
    host = parts[0]
    port = int(parts[1]) if len(parts) > 1 else 8123
    logger.debug("Creating ClickHouse client to %s:%d (secure=%s)", host, port, secure)
    return get_client(host=host, port=port, username=CH_USER, password=CH_PASS, database=CH_DB, secure=secure)


def get_last_timestamp():
    client = create_ch_client()
    q = f"SELECT max(ts) FROM {CH_DB}.{CH_TABLE}"
    logger.debug("Querying last timestamp with: %s", q)
    res = client.query(q)
    rows = getattr(res, 'result_rows', None)
    if rows and rows[0][0] is not None:
        val = rows[0][0]
        try:
            ts = val.timestamp() if hasattr(val, 'timestamp') else float(val)
            logger.info("Last timestamp in ClickHouse: %s", ts)
            return ts
        except Exception as e:
            logger.error("Failed to parse max(ts) result '%s': %s", val, e)
    logger.info("No existing timestamp found, starting from 0")
    return 0.0


def find_start_offset(path, last_ts):
    logger.debug("Finding start offset in %s for ts > %s", path, last_ts)
    with open(path, 'r', encoding='utf-8', errors='ignore') as f:
        f.seek(0, os.SEEK_END)
        file_size = f.tell()
        low, high, best = 0, file_size, 0
        while low < high:
            mid = (low + high) // 2
            f.seek(mid, os.SEEK_SET)
            f.readline()  # skip partial
            pos = f.tell()
            line = f.readline()
            if not line:
                high = mid
                continue
            try:
                rec = json.loads(line)
                ts_val = float(rec.get('ts', 0.0))
            except Exception:
                low = pos + len(line)
                continue
            if ts_val <= last_ts:
                best = pos + len(line)
                low = best
            else:
                high = mid
        logger.info("Determined start offset: %d", best)
        return best


def to_datetime(ts_float):
    dt_utc = datetime.fromtimestamp(ts_float, tz=timezone.utc)
    return dt_utc.astimezone(TZ)


def extract_first(hdrs, key):
    v = hdrs.get(key)
    if isinstance(v, list):
        return v[0]
    if isinstance(v, str):
        return v
    return ''


def transform(rec):
    logger.debug("Transforming record with ts=%s, logger=%s", rec.get('ts'), rec.get('logger'))
    request = rec.get('request', {}) or {}
    headers = request.get('headers', {}) or {}
    try:
        ts_dt = to_datetime(float(rec.get('ts', 0.0)))
    except Exception:
        ts_dt = datetime.now(TZ)
    duration = rec.get('duration')
    duration_ms = float(duration) * 1000.0 if duration is not None else float(rec.get('duration_ms', 0))
    row = (
        ts_dt,
        str(rec.get('level', '')),
        str(rec.get('logger', '')),
        str(rec.get('msg', '')),
        str(request.get('remote_ip', '')),
        int(request.get('remote_port', 0) or 0),
        str(request.get('method', '')),
        str(request.get('host', '')),
        str(request.get('uri', '')),
        int(rec.get('status', 0) or 0),
        float(duration_ms),
        int(rec.get('size', rec.get('bytes_sent', 0)) or 0),
        str(extract_first(headers, 'User-Agent')),
        str(rec.get('err_id', '')),
        str(rec.get('err_trace', ''))
    )
    logger.debug("Transformed row: %s", row)
    return row


async def flusher(queue):
    client = create_ch_client()
    batch, last_flush = [], time.time()
    while not stop_event.is_set():
        try:
            item = await asyncio.wait_for(queue.get(), timeout=FLUSH_INTERVAL)
            batch.append(item)
            logger.debug("Queued batch size: %d", len(batch))
        except asyncio.TimeoutError:
            pass
        now = time.time()
        if batch and (len(batch) >= BATCH_SIZE or now - last_flush >= FLUSH_INTERVAL):
            try:
                logger.info("Flushing %d rows to ClickHouse", len(batch))
                client.insert(CH_TABLE, batch, column_names=COLUMNS)
            except Exception as e:
                logger.error("Insert failed: %s", e)
            else:
                batch.clear()
                last_flush = now
    if batch:
        try:
            logger.info("Final flush of %d rows", len(batch))
            client.insert(CH_TABLE, batch, column_names=COLUMNS)
        except Exception as e:
            logger.error("Final insert failed: %s", e)


async def reader(queue):
    last_ts = get_last_timestamp()
    logger.info("Starting reader loop")
    start_offset = find_start_offset(LOG_FILE, last_ts)
    f = open(LOG_FILE, 'r', encoding='utf-8', errors='ignore')
    inode = os.fstat(f.fileno()).st_ino
    f.seek(start_offset)
    while not stop_event.is_set():
        line = f.readline()
        if not line:
            try:
                st = os.stat(LOG_FILE)
            except OSError as e:
                if e.errno == errno.ENOENT:
                    await asyncio.sleep(0.5)
                    continue
                else:
                    raise
            if st.st_ino != inode:
                logger.info("Detected log rotation, recalculating start offset")
                f.close()
                start_offset = find_start_offset(LOG_FILE, last_ts)
                f = open(LOG_FILE, 'r', encoding='utf-8', errors='ignore')
                inode = os.fstat(f.fileno()).st_ino
                f.seek(start_offset)
                continue
            await asyncio.sleep(0.2)
            continue
        try:
            rec = json.loads(line)
        except Exception as e:
            logger.warning("Failed to parse JSON line: %s", e)
            continue
        if rec.get('logger') != 'http.log.access':
            continue
        try:
            row = transform(rec)
        except Exception as e:
            logger.error("Transform error: %s", e)
            continue
        await queue.put(row)
        logger.debug("Row queued, timestamp=%s", row[0])


def handle_signal(signum, frame):
    logger.info("Received signal %d, shutting down", signum)
    stop_event.set()


def main():
    signal.signal(signal.SIGTERM, handle_signal)
    signal.signal(signal.SIGINT, handle_signal)
    uvloop.install()
    loop = asyncio.get_event_loop()
    queue = asyncio.Queue(maxsize=50000)
    tasks = [
        loop.create_task(reader(queue)),
        loop.create_task(flusher(queue)),
    ]
    try:
        loop.run_until_complete(asyncio.gather(*tasks))
    finally:
        for t in tasks:
            t.cancel()
        loop.stop()
        loop.close()


if __name__ == '__main__':
    main()

Using Collectors to Ingest Caddy Logs into ClickHouse

Now that I've prepared the Vector container, it's time to add it to my docker-compose.yml!

At this point, the three key service definitions are as follows:

version: '3.9'

services:
  sites:
    image: localhost:8080/box_starting/local_sites
    ports:
      # These ports are for internal use. For external, FRP will handle it.
      - target: 80
        published: 80
        protocol: tcp
        mode: host
      - target: 443
        published: 443
        protocol: tcp
        mode: host
    networks:
      - frp_net
      - proxy_app
    volumes:
      - sites-data:/data
    stop_grace_period: 60s
    deploy:
      resources:
        limits:
          cpus: '8.0'
          memory: 16G
      update_config:
        order: stop-first
        delay: 60s

  clickhouse:
    image: hub.aiursoft.cn/clickhouse/clickhouse-server:latest
    environment:
      - CLICKHOUSE_USER=default
      - CLICKHOUSE_PASSWORD=123456
    networks:
      - internal
    volumes:
      - click-house-data:/var/lib/clickhouse
    stop_grace_period: 60s
    deploy:
      resources:
        limits:
          cpus: '4.0'
          memory: 8G
      update_config:
        order: stop-first
        delay: 60s

  pysyncer:
    image: localhost:8080/box_starting/local_pysyncer
    depends_on:
      - clickhouse
    networks:
      - internal
    volumes:
      - sites-data:/data:ro
    stop_grace_period: 60s
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G
      update_config:
        order: stop-first
        delay: 60s

Before proceeding, let's first review the format of Caddy's log files:

anduin@proart:/swarm-vol/Box$ cd /swarm-vol/sites-data/
anduin@proart:/swarm-vol/sites-data$ ls
caddy
anduin@proart:/swarm-vol/sites-data$ cd caddy/logs/
anduin@proart:/swarm-vol/sites-data/caddy/logs$ ls
web.log
anduin@proart:/swarm-vol/sites-data/caddy/logs$ tail -n 20 ./web.log 
tail: cannot open './web.log' for reading: Permission denied
anduin@proart:/swarm-vol/sites-data/caddy/logs$ sudo tail -n 20 ./web.log 
{"level":"info","ts":1753607471.3656464,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"52.81.114.99","remote_port":"50108","client_ip":"52.81.114.99","proto":"HTTP/2.0","method":"POST","host":"gitlab.aiursoft.cn","uri":"/api/graphql","headers":{"Accept":["*/*"],"Content-Type":["application/json"],"Accept-Language":["zh,zh-CN;q=0.9"],"Accept-Encoding":["gzip, deflate, br"],"Content-Length":["2034"],"User-Agent":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.5232.1704 Safari/537.36"],"Sec-Fetch-Dest":["empty"],"X-Csrf-Token":["3KmgeS9sfn-Nc9rEiGp9Om810EuS-zUlqjEG6Nimm1abwyEf4J74xYV2OEbm96o1UBxsgur_Nn9Hn0ePxO6WzA"],"X-Gitlab-Feature-Category":["source_code_management"],"Sec-Fetch-Mode":["cors"],"Origin":["https://gitlab.aiursoft.cn"],"Sec-Fetch-Site":["same-origin"],"Referer":["https://gitlab.aiursoft.cn/edgeneko/transformers/-/blob/a0857740c0e6127485c11476650314df3accc2b6/src/transformers/models/patchtsmixer/modeling_patchtsmixer.py"],"Cookie":["REDACTED"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"gitlab.aiursoft.cn"}},"bytes_read":2034,"user_id":"","duration":0.146402042,"size":1840,"status":200,"resp_headers":{"X-Permitted-Cross-Domain-Policies":["none"],"X-Content-Type-Options":["nosniff"],"X-Frame-Options":["SAMEORIGIN"],"Content-Security-Policy":[""],"Via":["1.1 Caddy"],"Access-Control-Allow-Origin":["https://gitlab.aiursoft.cn"],"Nel":["{\"max_age\": 0}"],"Server":["nginx"],"Access-Control-Expose-Headers":["Link, X-Total, X-Total-Pages, X-Per-Page, X-Page, X-Next-Page, X-Prev-Page, X-Gitlab-Blob-Id, X-Gitlab-Commit-Id, X-Gitlab-Content-Sha256, X-Gitlab-Encoding, X-Gitlab-File-Name, X-Gitlab-File-Path, X-Gitlab-Last-Commit-Id, X-Gitlab-Ref, X-Gitlab-Size, X-Request-Id, ETag"],"Etag":["W/\"32190250e9efca1b52f562aebee5a31f\""],"X-Download-Options":["noopen"],"X-Ua-Compatible":["IE=edge"],"Permissions-Policy":["interest-cohort=()"],"Vary":["Accept-Encoding","Accept, Origin"],"X-Runtime":["0.113391"],"Referrer-Policy":["strict-origin-when-cross-origin"],"X-Gitlab-Meta":["{\"correlation_id\":\"01K15JD04HXEXJ5RW0ERVCN3FD\",\"version\":\"1\"}"],"Content-Type":["application/json; charset=utf-8"],"Date":["Sun, 27 Jul 2025 09:11:11 GMT"],"X-Request-Id":["01K15JD04HXEXJ5RW0ERVCN3FD"],"Access-Control-Allow-Credentials":["true"],"Cache-Control":["max-age=0, private, must-revalidate"],"X-Xss-Protection":["1; mode=block"],"Access-Control-Allow-Methods":["GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Strict-Transport-Security":["max-age=63072000","max-age=63072000"],"Content-Encoding":["gzip"],"Access-Control-Max-Age":["7200"]}}
{"level":"debug","ts":1753607471.3894894,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"gitlab_gitlab:80","total_upstreams":1}
{"level":"debug","ts":1753607471.4118912,"logger":"http.handlers.reverse_proxy","msg":"selected upstream","dial":"gitlab_gitlab:80","total_upstreams":1}
{"level":"debug","ts":1753607471.4228811,"logger":"http.handlers.reverse_proxy","msg":"upstream roundtrip","upstream":"gitlab_gitlab:80","duration":0.033285786,"request":{"remote_ip":"52.81.114.99","remote_port":"50108","client_ip":"52.81.114.99","proto":"HTTP/2.0","method":"POST","host":"gitlab.aiursoft.cn","uri":"/-/collect_events","headers":{"Accept-Encoding":["gzip, deflate, br"],"Accept":["*/*"],"Content-Length":["2739"],"Sec-Fetch-Site":["same-origin"],"Sec-Fetch-Mode":["cors"],"Referer":["https://gitlab.aiursoft.cn/edgeneko/transformers/-/blob/a0857740c0e6127485c11476650314df3accc2b6/src/transformers/models/patchtsmixer/modeling_patchtsmixer.py"],"X-Forwarded-Proto":["https"],"Cookie":["REDACTED"],"Origin":["https://gitlab.aiursoft.cn"],"Sec-Fetch-Dest":["empty"],"User-Agent":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.5232.1704 Safari/537.36"],"X-Forwarded-For":["52.81.114.99"],"X-Forwarded-Host":["gitlab.aiursoft.cn"],"Content-Type":["application/json; charset=UTF-8"],"Accept-Language":["zh,zh-CN;q=0.9"],"Via":["2.0 Caddy"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"gitlab.aiursoft.cn"}},"headers":{"Date":["Sun, 27 Jul 2025 09:11:11 GMT"],"Connection":["keep-alive"],"Cache-Control":["no-cache"],"X-Frame-Options":["SAMEORIGIN"],"X-Gitlab-Meta":["{\"correlation_id\":\"01K15JD09MW83H4ECSDB3F0W2H\",\"version\":\"1\"}"],"X-Permitted-Cross-Domain-Policies":["none"],"X-Request-Id":["01K15JD09MW83H4ECSDB3F0W2H"],"Content-Security-Policy":[""],"X-Download-Options":["noopen"],"X-Runtime":["0.008243"],"Content-Type":["text/html"],"X-Xss-Protection":["0"],"Strict-Transport-Security":["max-age=63072000"],"Referrer-Policy":["strict-origin-when-cross-origin"],"Server":["nginx"],"Content-Length":["0"],"Nel":["{\"max_age\": 0}"],"X-Content-Type-Options":["nosniff"]},"status":200}

Regarding the Caddy logs mentioned above, it is crucial to understand that they are not in standard JSON format; rather, each line is a separate JSON object. You must read them line by line. If you attempt to deserialize the entire file at once, it will fail. Additionally, as requests arrive, new lines are continuously appended to the log.

After actually deploying the container described above, it worked very well.

anduin@proart:/swarm-vol/Box$ sudo docker ps | grep incoming
12c0c00c8bc1   localhost:8080/box_starting/local_pysyncer:latest                                "python /app/main.py"     19 seconds ago      Up 14 seconds                                                                                                               incoming_pysyncer.1.zcri465vte59q7ykcvkazicmz
3e2644ae35e8   hub.aiursoft.cn/clickhouse/clickhouse-server:latest                              "/entrypoint.sh"          25 seconds ago      Up 16 seconds          8123/tcp, 9000/tcp, 9009/tcp                                                                         incoming_clickhouse.1.g78dnhblb5lo47a3r4slpwlud
b9dbeec4c2e4   localhost:8080/box_starting/local_sites:latest                                   "sh -c 'caddy run --…"    2 hours ago         Up 2 hours             0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp      incoming_sites.1.tchujhi57vpj5h54yb6cvhot0
cd64d810cd87   localhost:8080/box_starting/local_frp:latest                                     "sh -c /entrypoint.sh"    2 days ago          Up 2 days                                                                                                                   incoming_frpc_ningbo.1.sizoca54zijta6fl4f7d6atc0
8038df1ff221   localhost:8080/box_starting/local_frp:latest                                     "sh -c /entrypoint.sh"    2 days ago          Up 2 days                                                                                                                   incoming_frpc_jp.1.cbuf8m2wdfr7qoms9wd9a9jdb
anduin@proart:/swarm-vol/Box$ sudo docker logs -f 12c0c00c8bc1
/app/main.py:226: DeprecationWarning: There is no current event loop
  loop = asyncio.get_event_loop()
[DEBUG] 2025-07-27 10:32:39,699 Creating ClickHouse client to clickhouse:8123 (secure=False)
[DEBUG] 2025-07-27 10:32:39,711 Querying last timestamp with: SELECT max(ts) FROM logs.caddy_requests
[INFO] 2025-07-27 10:32:39,720 Last timestamp in ClickHouse: 1753612350.828
[INFO] 2025-07-27 10:32:39,720 Starting reader loop
[DEBUG] 2025-07-27 10:32:39,720 Finding start offset in /data/caddy/logs/web.log for ts > 1753612350.828
[INFO] 2025-07-27 10:32:39,720 Determined start offset: 139816254
[DEBUG] 2025-07-27 10:32:39,720 Transforming record with ts=1753612350.8285708, logger=http.log.access
[DEBUG] 2025-07-27 10:32:39,720 Transformed row: (datetime.datetime(2025, 7, 27, 10, 32, 30, 828571, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'info', 'http.log.access', 'handled request', '51.222.253.18', 43610, 'GET', 'nuget.aiursoft.cn', '/packages/microsoft.extensions.caching.stackexchangeredis/9.0.6', 200, 905.742426, 6435, 'Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)', '', '')
[DEBUG] 2025-07-27 10:32:39,720 Row queued, timestamp=2025-07-27 10:32:30.828571+00:00
[DEBUG] 2025-07-27 10:32:39,720 Transforming record with ts=1753612350.8735032, logger=http.log.access

Checking Collected Structured Data in ClickHouse

We can query ClickHouse to check if the data is normal:

anduin@proart:~$ sudo docker ps | grep clickhouse
3e2644ae35e8   hub.aiursoft.cn/clickhouse/clickhouse-server:latest                              "/entrypoint.sh"          4 minutes ago       Up 4 minutes           8123/tcp, 9000/tcp, 9009/tcp                                                                         incoming_clickhouse.1.g78dnhblb5lo47a3r4slpwlud
anduin@proart:~$ sudo docker exec -it 3e2644ae35e8  clickhouse-client --user default
ClickHouse client version 25.6.4.12 (official build).
Connecting to localhost:9000 as user default.
Connected to ClickHouse server version 25.6.4.

Warnings:
 * Delay accounting is not enabled, OSIOWaitMicroseconds will not be gathered. You can enable it using `echo 1 > /proc/sys/kernel/task_delayacct` or by using sysctl.

3e2644ae35e8 :) 

Next, you can first check how much data there is:

3e2644ae35e8 :) SELECT count() FROM logs.caddy_requests;

SELECT count()
FROM logs.caddy_requests

Query id: 52567be0-23f9-46d6-896f-04166b1d6fa7

   ┌─count()─┐
1. │   38177 │
   └─────────┘

1 row in set. Elapsed: 0.004 sec. 

Check the recent HTTP requests:

3e2644ae35e8 :) SELECT ts, level, logger, msg, remote_ip, remote_port, method, host, status, duration_ms, bytes_sent, uri
FROM logs.caddy_requests
ORDER BY ts DESC
LIMIT 30
FORMAT Pretty;

SELECT
    ts,
    level,
    logger,
    msg,
    remote_ip,
    remote_port,
    method,
    host,
    status,
    duration_ms,
    bytes_sent,
    uri
FROM logs.caddy_requests
ORDER BY ts DESC
LIMIT 30
FORMAT Pretty

Query id: 334414e5-7810-4e3e-8cda-7140480d9ed2

    ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
    ┃                       ts ┃ level ┃ logger          ┃ msg             ┃ remote_ip                             ┃ remote_port ┃ method   ┃ host                            ┃ status ┃ duration_ms ┃ bytes_sent ┃ uri                                                                                                   ┃
    ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
20. │ 2025-07-27 18:37:56.431 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       43734 │ POST     │ stathub.aiursoft.cn             │    201 │    0.911467 │         56 │ /api/metrics                                                                                          │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
21. │ 2025-07-27 18:37:56.374 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       52612 │ GET      │ gitlab.aiursoft.cn              │    404 │    44.58619 │         27 │ /api/v4/projects/anduin%2Fanduinos-docs/releases/permalink/latest                                     │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
22. │ 2025-07-27 18:37:56.370 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       52612 │ GET      │ gitlab.aiursoft.cn              │    200 │    36.16609 │        430 │ /api/v4/projects/anduin%2Fanduinos-docs                                                               │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
23. │ 2025-07-27 18:37:56.344 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       42668 │ POST     │ gitlab.aiursoft.cn              │    204 │    2.366945 │          0 │ /api/v4/jobs/request                                                                                  │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
24. │ 2025-07-27 18:37:55.762 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       49907 │ PROPFIND │ nextcloud.aiursoft.cn           │    207 │    47.25869 │        254 │ /remote.php/dav/files/angelwing32/                                                                    │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
25. │ 2025-07-27 18:37:55.738 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       39766 │ POST     │ stathub.aiursoft.cn             │    201 │    0.807904 │         56 │ /api/metrics                                                                                          │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
26. │ 2025-07-27 18:37:55.497 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       56234 │ HEAD     │ hub.aiursoft.cn                 │    200 │    3.154572 │          0 │ /v2/anduin/anduinos-docs/manifests/latest                                                             │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
27. │ 2025-07-27 18:37:55.319 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       37020 │ GET      │ dashboard.inventory.aiursoft.cn │    308 │    0.019892 │          0 │ /assets/js/kcfinder/upload.php                                                                        │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
28. │ 2025-07-27 18:37:54.759 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       57226 │ POST     │ stathub.aiursoft.cn             │    201 │    1.095069 │         56 │ /api/metrics                                                                                          │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
29. │ 2025-07-27 18:37:54.672 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       36668 │ POST     │ stathub.aiursoft.cn             │    201 │     0.99698 │         56 │ /api/metrics                                                                                          │
    ├─────────────────────────┼───────┼─────────────────┼─────────────────┼───────────────────────────────────────┼─────────────┼──────────┼─────────────────────────────────┼────────┼─────────────┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤
30. │ 2025-07-27 18:37:54.393 │ info  │ http.log.access │ handled request │ 222.222.222.222                       │       40810 │ POST     │ stathub.aiursoft.cn             │    201 │    1.495005 │         56 │ /api/metrics                                                                                          │
    └─────────────────────────┴───────┴─────────────────┴─────────────────┴───────────────────────────────────────┴─────────────┴──────────┴─────────────────────────────────┴────────┴─────────────┴────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────┘

30 rows in set. Elapsed: 0.004 sec. Processed 3.24 thousand rows, 407.31 KB (742.98 thousand rows/s., 93.37 MB/s.)
Peak memory usage: 1008.68 KiB.

As you can see, it's perfect.

Write Queries for the Most Common Data I Need

I can also do something else, such as ranking businesses by request count:

SELECT
    host,
    count() AS request_count
FROM logs.caddy_requests
GROUP BY host
ORDER BY request_count DESC
FORMAT Pretty;

file

I can also quickly identify crawlers that are scraping data from my system:

SELECT remote_ip, COUNT(*) AS count
FROM logs.caddy_requests
WHERE host = 'git.aiursoft.cn'
  AND uri LIKE '/PublicVault%'
  AND uri LIKE '%commit%'
GROUP BY remote_ip
ORDER BY count DESC;

file

I can also quickly see which specific IPs are being used by a particular business (host):

SELECT remote_ip, COUNT(*) AS count
FROM logs.caddy_requests
WHERE host = 'clickhouse.aiursoft.cn'
GROUP BY remote_ip
ORDER BY count DESC;

file

TOP 100 Most Popular Business Rankings, Request Counts per Host, Ranked by Unique IP Count:

SELECT
    host,
    uniqExact(remote_ip) AS unique_ip_count
FROM logs.caddy_requests
GROUP BY host
ORDER BY unique_ip_count DESC
LIMIT 100;

TOP 100 CPU-intensive business ranking, i.e., the total request processing time for each host:

SELECT
    host,
    sum(duration_ms)       AS total_duration_ms,
    count()                AS requests
FROM logs.caddy_requests
GROUP BY host
ORDER BY total_duration_ms DESC
LIMIT 100;

Top 100 hottest interfaces, i.e., the interfaces with the highest number of requests.

SELECT
    host,
    splitByChar('?', uri)[1] AS path,
    count()                   AS requests
FROM logs.caddy_requests
GROUP BY
    host,
    path
ORDER BY
    requests DESC
LIMIT 100;

Top 100 Error Endpoints / Error Code Ranking: Identify the endpoints with the highest failure concentration or the most common 4xx/5xx status codes:

SELECT
    host,
    uri,
    status,
    count() AS errors
FROM logs.caddy_requests
WHERE 1=1
  AND status >= 400
GROUP BY
    host,
    uri,
    status
ORDER BY
    errors DESC
LIMIT 100;

Top 100 Slow Interfaces, Based on Average:

SELECT
    host,
    splitByChar('?', uri)[1] AS path,
    avg(duration_ms)             AS avg_response_time_ms,
    count()                      AS requests
FROM logs.caddy_requests WHERE status != '101'
GROUP BY
    host,
    path
ORDER BY
    avg_response_time_ms DESC
LIMIT 100;

Top 100 slow interfaces, if using P95:

SELECT
    host,
    splitByChar('?', uri)[1] AS path,
    quantile(0.95)(duration_ms) AS p95_response_time_ms,
    count()                     AS requests
FROM logs.caddy_requests WHERE status != '101'
GROUP BY
    host,
    path
ORDER BY
    p95_response_time_ms DESC
LIMIT 100;

P95 / P99 response latency trends. Beyond the average, P95 (95th percentile) and P99 better reflect "tail" latency:

SELECT
 toStartOfInterval(ts, INTERVAL {rounding:UInt32} SECOND) AS t,
 quantile(0.95)(duration_ms) AS p95_latency_ms,
 quantile(0.99)(duration_ms) AS p99_latency_ms
FROM logs.caddy_requests
WHERE ts >= now() - {seconds:UInt32}
GROUP BY t
ORDER BY t
WITH FILL STEP {rounding:UInt32}

Request size P95 / P50 distribution; use quantiles to examine the distribution of response body sizes:

SELECT
 toStartOfInterval(ts, INTERVAL {rounding:UInt32} SECOND) AS t,
 quantile(0.5)(bytes_sent)  AS p50_bytes,
 quantile(0.95)(bytes_sent) AS p95_bytes
FROM logs.caddy_requests
WHERE ts >= now() - {seconds:UInt32}
GROUP BY t
ORDER BY t
WITH FILL STEP {rounding:UInt32}

Request the heatmap by "day of the week + hour" to observe traffic peaks throughout the week and across different times of day:

SELECT
 toDayOfWeek(ts)    AS weekday,  -- 1=Monday … 7=Sunday
 toHour(ts)         AS hour,
 count()            AS cnt
FROM logs.caddy_requests
WHERE ts >= now() - {seconds:UInt32}
GROUP BY weekday, hour
ORDER BY weekday, hour

User‑Agent distribution: view which callers (browsers, crawlers, scripts, etc.) are accessing your service:

SELECT
 user_agent,
 count() AS cnt
FROM logs.caddy_requests
WHERE ts >= now() - {seconds:UInt32}
GROUP BY user_agent
ORDER BY cnt DESC
LIMIT 10

These visualizations help you gain deep insights into service performance and potential bottlenecks from multiple angles (hottest endpoints, tail latency, traffic by time period, error clustering, and client types).

Now that we have successfully read Caddy's log data from files and converted it into a format usable by ClickHouse, we can use Superset or other visualization tools to analyze this data and generate various reports and charts.

Investigating Hacker's Flood Attacks

A wave of hackers is currently attacking me. I'm using the newly built database to conveniently investigate the methods behind this attack:

Got it. The time window here is in UTC, so no timezone conversion is needed. Just use the following SQL directly:

1. Count requests by business (descending, UTC 2025‑07‑27 14:35–14:45)

SELECT
    host,
    count() AS requests
FROM logs.caddy_requests
WHERE ts BETWEEN 
      toDateTime('2025-07-27 14:35:00','UTC')
  AND toDateTime('2025-07-27 14:45:00','UTC')
GROUP BY host
ORDER BY requests DESC;

2. Total traffic size at 10-second granularity (UTC 2025‑07‑27 14:35–14:45)

SELECT
    toStartOfInterval(ts, INTERVAL 10 SECOND) AS t,
    sum(bytes_sent) AS total_bytes
FROM logs.caddy_requests
WHERE ts BETWEEN 
      toDateTime('2025-07-27 14:35:00','UTC')
  AND toDateTime('2025-07-27 14:45:00','UTC')
GROUP BY t
ORDER BY t;

3. Client IP list and corresponding hosts in descending order of request count (UTC 2025‑07‑27 14:35–14:45)

SELECT
    remote_ip,
    host,
    count() AS requests
FROM logs.caddy_requests
WHERE ts BETWEEN 
      toDateTime('2025-07-27 14:35:00','UTC')
  AND toDateTime('2025-07-27 14:45:00','UTC')
GROUP BY remote_ip, host
ORDER BY requests DESC;

4. Status Code Distribution (UTC 2025‑07‑27 14:35–14:45)

SELECT
    status,
    count() AS count
FROM logs.caddy_requests
WHERE ts BETWEEN 
      toDateTime('2025-07-27 14:35:00','UTC')
  AND toDateTime('2025-07-27 14:45:00','UTC')
GROUP BY status
ORDER BY count DESC;

5. Distribution of the First 20 Character Prefixes of URIs (Descending, UTC 2025‑07‑27 14:35–14:45)

SELECT
    substring(uri, 1, 20) AS path_prefix,
    count()               AS count
FROM logs.caddy_requests
WHERE ts BETWEEN 
      toDateTime('2025-07-27 14:35:00','UTC')
  AND toDateTime('2025-07-27 14:45:00','UTC')
GROUP BY path_prefix
ORDER BY count DESC;

Build a Beautiful Dashboard

ClickHouse comes with a built-in dashboard. By organizing the SQL queries above, it is not difficult to create a very polished dashboard.

file

Grafana

With Grafana, you can also create even more beautiful dashboards.

file

Conclusion

If you need to continue with visualization, you can also use Apache Superset. However, with visualization to this extent, I have already obtained all the data I care about.

In the short term, this is more than sufficient for me to meet my needs.

Being able to visualize Caddy's traffic is very important for business development. If you don't know how the application is being used, no metric can evaluate the growth of the business. Overall, building this infrastructure is not difficult. Once it is set up, you can simply monitor the dashboards daily and enjoy the results!