---
title: "AdGuard Home and the query-log shipper"
description: "AdGuard Home on the router kept only a few hours of DNS history in RAM, so I wrote a small shipper that polls the API from my home server and keeps 90 days in Loki."
url: "https://michael.zazon.us/notes/adguard-home-and-the-query-log-shipper"
published: "2026-06-02T12:00:00-04:00"
kind: "build-log"
subject: "homelab"
tags: ["dns","observability"]
series: "Home networking"
part: 4
---
I was doing some troubleshooting (Apple TV and some apps not working correctly) and I realized the activity logs rotate out very quickly. Rightfully so, the Flint 2's RAM is limited and we don't want it hogging up the resources there. I needed more than a few hours of DNS resolution history to tune the allowlist, and what I needed wasn't there.

After [the router rebuild](https://michael.zazon.us/notes/openwrt-to-the-rescue), I set up [AdGuard Home](https://github.com/AdguardTeam/AdGuardHome) as the first stop for all DNS queries in the house. It uses Cloudflare and Quad9 as upstreams via DNS over HTTPS, and filters with [Hagezi](https://github.com/hagezi/dns-blocklists)'s Multi Pro and Threat Intelligence Feeds, plus the Hagezi referral allowlist. At the time, Multi Pro alone was about 226,000 rules. I used Ansible to template the router's AdGuard config and pin its version. I also set up a replica AdGuard Home on my home server, using [adguardhome-sync](https://github.com/bakito/adguardhome-sync) to copy the filters and client settings from the router every 15 minutes.

## A few hours of memory

The short log history was a choice I made previously. The router has 1 GB of RAM, and the default file-based query log was using about 230 MB of tmpfs (which is RAM on OpenWrt). I solved that by disabling file logging and setting a 60,000-entry in-memory buffer, which used about 50 MB of RAM and at the time held about three to four hours' worth of queries at the rate I was seeing.

That trade-off came back to bite me one night when a streaming app broke on the Apple TV. Disabling AdGuard protection fixed it, so the blocklist was to blame. But when I checked the query log for the app's domains, the window during which it had failed was already out of the buffer. So I ended up creating a per-client filtering bypass for Apple TVs instead of per-domain allowlisting, because Multi Pro is aggressive and I couldn't predict which app would trip it up next. All other devices are still filtered, and I still log Apple TV queries.

A few days before, when researching backup DNS, I had discovered that no DNS setup can truly unify query logs across instances. Each AdGuard has its own log, so the router and the replica each show a partial picture. My notes read "Loki and Grafana?"

## No extra services on the router

The obvious idea is to run a log agent on the router. But when I double-checked the router's memory situation, I realized that what looked like a shortage was actually just normal page cache usage, leaving about 436 MB of memory actually available. That still made most log agents a poor fit: Grafana Alloy typically uses 300 MB to 1 GiB of memory and [has no OpenWrt build](https://github.com/grafana/alloy/issues/2460), and while Promtail is lighter (about 100 MB), it can spike and [doesn't have a maintained OpenWrt package](https://github.com/grafana/loki/issues/4749). Even though something like Vector or rsyslog would fit, polling the AdGuard API from a machine that's already running everything else doesn't add anything to the router.

I wanted to get the query logs off, and I didn't want any extra services running on the router's hardware, so I decided to build a lightweight container service running on my main server that reads the API and grabs the query logs. If the service dies, I do alert on it, but it doesn't impact the DNS service itself.

## Building the shipper

The shipper is 164 lines of Python, using only the standard library. Every 60 seconds, it calls the query log endpoint of [AdGuard's HTTP API](https://github.com/AdguardTeam/AdGuardHome/tree/master/openapi) with Basic auth. It never modifies any AdGuard configuration, only reads. The log is returned in newest-first order, paged, so it keeps a timestamp cursor in a file and walks back through older pages on each cycle until it reaches the cursor. It pushes new entries to [Loki](https://grafana.com/oss/loki/) as JSON lines, nudging duplicate timestamps forward to keep them strictly ascending.

The first run is intentionally quiet: the cursor starts at the newest entry, so nothing gets shipped. No backfill happens for what's already in the buffer. I run one container per AdGuard instance (router, replica) as a git-driven stack on my home server. Each writes a heartbeat file, and the healthcheck fails if the file is older than three polls (180 seconds). The AdGuard admin login is injected at deploy time and never lands in git.

[Diagram: Router and replica each feed a shipper that pushes query logs to Loki for Grafana.](https://michael.zazon.us/notes/adguard-home-and-the-query-log-shipper)

## Two labels and 90 days

I only label Loki with job and host. Everything else (client, domain, upstream, matching rule, cached or not, duration) is in the log line itself, and queries extract them with Loki's LogQL json parser. Keeping the label set small means fewer Loki streams. A high-cardinality label like client or domain would create a new stream for every value.

Loki's default retention is 30 days. I added a [per-stream retention rule](https://grafana.com/docs/loki/latest/operations/storage/retention/) to keep the AdGuard query log for 2160 hours (90 days), which Loki's compactor enforces. I chose 90 days because I want a real DNS audit history: if I need to look into a problem, I want to be able to go back weeks, not hours.

## The dashboard (and a regex bug)

I provision the Grafana dashboard from git. It shows query totals and the block rate at the top, followed by traffic by AdGuard host, top clients, and most-queried domains. At the bottom is a raw log panel for looking at individual queries.

[Image: The AdGuard query-log dashboard in Grafana over 24 hours: 336,441 total queries, 39,220 blocked, an 11.7% block rate and 65 distinct clients, with query rate by host and allowed versus blocked charts below](https://michael.zazon.us/notes/adguard-home-and-the-query-log-shipper)

The block rate was showing 99.4%, which would mean almost nothing was resolving. Turned out my filter for blocked queries matched the reason field against `Filtered.*` unanchored.. so it also caught `NotFilteredNotFound`, which is AdGuard's reason for a normal allowed query. Anchoring it to `^Filtered.*` fixed that, bringing the block rate down to about 8.4%. The router's AdGuard instance is doing about 585 queries a minute, and the replica is idle.

I'm excited to extend this to leverage the data with AI to do blocklist and allowlist tuning, do per-device analysis, alert on DNS anomalies, and detect rogue devices (see the recent [LG TV outcry](https://notebookcheck.net/LG-smart-TVs-caught-logging-audio-with-screen-off-and-snooping-on-local-devices.1391214.0.html) about their TVs spying on users from inside their own networks and harvesting private data, which [LG denies](https://www.notebookcheck.net/LG-denies-misusing-216-000-000-smart-TVs-for-user-surveillance.1394568.0.html)).