fix(adguard-exporter): add files directly instead of as submodule
Some checks failed
Build and push adguard-exporter image / build-and-push-image (push) Failing after 11m20s
@ -1 +0,0 @@
|
||||
Subproject commit 1113358cac51354eaf1280d940a50e344a4e045b
|
||||
19
adguard-exporter/.env.example
Normal file
@ -0,0 +1,19 @@
|
||||
# Contoh konfigurasi, silakan sesuaikan
|
||||
#Fill up your adguard url, if didn't works try docker ips (172.x.x.x) (required)
|
||||
ADGUARD_HOST=http://172.x.x.x:20271
|
||||
|
||||
#Your AdGuard username and password for authentication that you setup during the init (required)
|
||||
ADGUARD_USER=
|
||||
ADGUARD_PASS=
|
||||
|
||||
#Your exporter port (optional, default 9617)
|
||||
EXPORTER_PORT=9200
|
||||
|
||||
#Scrape Interval by prometheus (optional, default 15s)
|
||||
SCRAPE_INTERVAL=15s
|
||||
|
||||
#Fill up your LogLevel you want it to, (DEBUG,WARN,ERROR,INFO)
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
#GEOIP_DB File download => wget https://raw.githubusercontent.com/P3TERX/GeoLite.mmdb/download/GeoLite2-City.mmdb
|
||||
GEOIP_DB=/path/to/your/GeoLite2-City.mmdb
|
||||
29
adguard-exporter/.github/renovate.json
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"labels": [
|
||||
"renovate::dependencies",
|
||||
"{{#if category}}renovate::{{category}}{{/if}}",
|
||||
"{{#if updateType}}renovate::{{updateType}}{{/if}}",
|
||||
"{{#if datasource}}renovate::{{datasource}}{{/if}}",
|
||||
"{{#if manager}}renovate::{{manager}}{{/if}}",
|
||||
"{{#if vulnerabilitySeverity}}renovate::{{vulnerabilitySeverity}}{{/if}}",
|
||||
"renovate::{{#if isVulnerabilityAlert}}vulnerability{{else}}not-vulnerability{{/if}}"
|
||||
],
|
||||
"pinDigests": false,
|
||||
"enabled": true,
|
||||
"separateMajorMinor": true,
|
||||
"automerge": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": [
|
||||
"gomod"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
44
adguard-exporter/.github/workflows/go-ci.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Go CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests & Linter
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
# - name: Cache Go modules
|
||||
# uses: actions/cache@v3
|
||||
# with:
|
||||
# path: |
|
||||
# ~/.cache/go-build
|
||||
# ~/go/pkg/mod
|
||||
# key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-go-
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod tidy
|
||||
|
||||
- name: Run unit tests
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Run linter (golangci-lint)
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=3m
|
||||
32
adguard-exporter/.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.26
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u znand-dev --password-stdin
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
13
adguard-exporter/.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# Binaries
|
||||
adguardexporter
|
||||
dist/
|
||||
# Env file
|
||||
.env
|
||||
|
||||
# OS / Editor files
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
#db
|
||||
GeoLite2-City.mmdb
|
||||
geolite2-db/GeoLite2-City.mmdb
|
||||
79
adguard-exporter/.goreleaser.yml
Normal file
@ -0,0 +1,79 @@
|
||||
project_name: adguard-exporter
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- id: universal
|
||||
main: main.go
|
||||
binary: adguard-exporter
|
||||
env: [CGO_ENABLED=0]
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
dockers:
|
||||
- id: docker-amd64
|
||||
ids: [universal]
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
image_templates:
|
||||
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-amd64
|
||||
- ghcr.io/znand-dev/adguardexporter:latest-amd64
|
||||
|
||||
- id: docker-arm64
|
||||
ids: [universal]
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
image_templates:
|
||||
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-arm64v8
|
||||
- ghcr.io/znand-dev/adguardexporter:latest-arm64v8
|
||||
|
||||
- id: docker-arm
|
||||
ids: [universal]
|
||||
goos: linux
|
||||
goarch: arm
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v6"
|
||||
image_templates:
|
||||
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-armv6
|
||||
- ghcr.io/znand-dev/adguardexporter:latest-armv6
|
||||
|
||||
docker_manifests:
|
||||
- name_template: ghcr.io/znand-dev/adguardexporter:{{ .Tag }}
|
||||
image_templates:
|
||||
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-amd64
|
||||
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-arm64v8
|
||||
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-armv6
|
||||
|
||||
- name_template: ghcr.io/znand-dev/adguardexporter:latest
|
||||
image_templates:
|
||||
- ghcr.io/znand-dev/adguardexporter:latest-amd64
|
||||
- ghcr.io/znand-dev/adguardexporter:latest-arm64v8
|
||||
- ghcr.io/znand-dev/adguardexporter:latest-armv6
|
||||
20
adguard-exporter/CHANGELOG.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v1.1.0 - 2025-06-18
|
||||
### ✨ Added
|
||||
- `adguard_replaced_parental`: total queries replaced due to parental control
|
||||
- `adguard_safe_search`: total queries forced into Safe Search (YouTube, Google, Bing)
|
||||
- `adguard_safe_browsing`: total Safe Browsing protections triggered
|
||||
- `adguard_top_upstreams`: top DNS upstreams based on query count
|
||||
- `adguard_top_clients`: top client IPs querying AdGuard
|
||||
- `adguard_top_queried_domains`: most frequently queried domains
|
||||
- `adguard_upstream_avg_response_time_seconds`: average DNS upstream response time
|
||||
|
||||
### 🛠 Fixed
|
||||
- Fixed: missing `upstream` and `client` labels due to empty maps or parsing errors
|
||||
- Fixed: more reliable parsing of nested JSON from `/control/stats` and `/control/status`
|
||||
- Fixed: exporter now exposes partial metrics even if part of the JSON fails
|
||||
|
||||
### 🔄 Refactored
|
||||
- Cleaned up metric and label names to follow Prometheus naming conventions
|
||||
- Fixed Dockerfile lint warning: unified casing of `FROM ... AS` keyword for best practices
|
||||
31
adguard-exporter/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -ldflags="-s -w" -o adguard-exporter .
|
||||
|
||||
FROM alpine:3.24 AS certs
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
FROM scratch
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=build /app/adguard-exporter /adguard-exporter
|
||||
COPY --from=build /app/GeoLite2-City.mmdb /GeoLite2-City.mmdb
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
EXPOSE 9200
|
||||
|
||||
ENTRYPOINT ["/adguard-exporter"]
|
||||
21
adguard-exporter/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Januar Ismunandar
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
307
adguard-exporter/README.md
Normal file
@ -0,0 +1,307 @@
|
||||
# AdGuard Exporter for Prometheus
|
||||
|
||||
A lightweight Prometheus exporter written in Go that exposes detailed metrics from your AdGuard Home instance — including DNS statistics, blocked domains, upstreams, and client data.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Category | Capabilities |
|
||||
|----------|--------------|
|
||||
| API Integration | • Authenticated access to AdGuard Home API<br>• Supports `/control/status`, `/control/stats`, and `/control/querylog` endpoints |
|
||||
| DNS Analytics | • Total DNS queries metrics<br>• Blocked DNS queries tracking<br>• Upstream DNS statistics<br>• Per-client query statistics<br>• Query reason statistics<br>• DNS upstream latency histograms |
|
||||
| Query Log Processing | • Real-time query log analysis<br>• Query deduplication to prevent metric inflation<br>• Domain and query reason tracking |
|
||||
| GeoIP Enrichment | • MaxMind GeoLite2 IP geolocation lookup<br>• DNS client geographic visualization (Grafana Geomap)<br>• DNS threat map for blocked queries<br>• GeoIP caching for improved performance |
|
||||
| Traffic Analytics | • DNS queries grouped by ISP organization<br>• Country-level DNS traffic insights<br>• ISP-based DNS traffic monitoring |
|
||||
| Exporter Monitoring | • Deduplication cache metrics<br>• GeoIP cache usage monitoring<br>• Deduplication hit counters<br>• Exporter scrape performance metrics |
|
||||
| Deployment | • Customizable scrape interval<br>• Lightweight single binary deployment<br>• Docker-friendly deployment |
|
||||
---
|
||||
|
||||
## Built With
|
||||
|
||||
- [Go (Golang)](https://golang.org/)
|
||||
- [Prometheus Client Library](https://github.com/prometheus/client_golang)
|
||||
- [AdGuard Home](https://github.com/AdguardTeam/AdGuardHome)
|
||||
|
||||
---
|
||||
## Prerequisites
|
||||
|
||||
Before running this exporter, make sure:
|
||||
|
||||
- AdGuard Home is up and running
|
||||
- You have a valid AdGuard username & password (without special character (!,@,`,* etc.))
|
||||
- Prometheus is configured to scrape this exporter
|
||||
- Docker installed (or alternatively Go 1.20+ for building from source)
|
||||
- Port `9617` is available on your system
|
||||
|
||||
To make sure the endpoint is valid, you can check the endpoints via curl:
|
||||
|
||||
```
|
||||
curl -v -u <yourusername>:<yourpassword> <ADGUARD_URL>:<PORT>/control/stats
|
||||
curl -v -u <yourusername>:<yourpassword> <ADGUARD_URL>:<PORT>/control/status
|
||||
curl -v -u <yourusername>:<yourpassword> <ADGUARD_URL>:<PORT>/control/querylog
|
||||
```
|
||||
After that, you should got the output like this:
|
||||
|
||||
```
|
||||
* Trying 172.31.20.12:80...
|
||||
* Connected to 172.31.20.12 (172.31.20.12) port 80 (#0)
|
||||
* Server auth using Basic with user 'admin'
|
||||
> GET /control/stats HTTP/1.1
|
||||
> Host: 172.31.20.12
|
||||
> Authorization: Basic Xxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
> User-Agent: curl/7.88.1
|
||||
> Accept: */*
|
||||
>
|
||||
< HTTP/1.1 200 OK
|
||||
< Access-Control-Allow-Origin: http://172.31.20.12
|
||||
< Content-Type: application/json
|
||||
< Server: AdGuardHome/v0.107.62
|
||||
< Vary: Origin
|
||||
< Vary: Accept-Encoding
|
||||
< Date: Tue, 08 Jul 2025 16:45:56 GMT
|
||||
< Transfer-Encoding: chunked
|
||||
<
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
|------------------|---------------------------- | -------------------|-------------------
|
||||
| `ADGUARD_HOST`| AdGuard Home base URL | ✅ | `http://192.168.1.1:3000,` |
|
||||
| `ADGUARD_USER`| AdGuard Home username | ✅ | `admin` |
|
||||
| `ADGUARD_PASS`| AdGuard Home password | ✅ | `secretpassword` |
|
||||
| `EXPORTER_PORT` | Port to expose metrics (default: 9617) | ❌ | `9200` |
|
||||
| `SCRAPE_INTERVAL` | How often to scrape (default: 15s) | ❌ | `30s` |
|
||||
| `LOG_LEVEL` | Log Level to analyze, INFO, WARN, DEBUG | ❌ | `DEBUG`,`WARN`,`INFO` |
|
||||
|
||||
---
|
||||
|
||||
### Quick Start
|
||||
|
||||
## Run via Docker Compose
|
||||
|
||||
### 1. Create docker-compose.yml in your root dir
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
adguard-exporter:
|
||||
image: ghcr.io/znandev/adguardexporter:latest
|
||||
container_name: adguard_exporter
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9617:9617"
|
||||
volumes:
|
||||
- ./<path/to/your>/GeoLite2-City.mmdb:/GeoLite2-City.mmdb
|
||||
environment:
|
||||
- ADGUARD_HOST=<YOUR_ADGUARD_URL>
|
||||
- ADGUARD_USER=<YOUR_ADGUARD_USERNAME>
|
||||
- ADGUARD_PASS=<YOUR_ADGUARD_PASSWORD>
|
||||
- EXPORTER_PORT=9617
|
||||
- SCRAPE_INTERVAL=15s
|
||||
- LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
### 2. Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metrics Endpoint
|
||||
|
||||
Once running, your exporter will be available at:
|
||||
|
||||
```
|
||||
http://<host>:9617/metrics
|
||||
```
|
||||
|
||||
Ready to scrape by Prometheus!
|
||||
|
||||
---
|
||||
|
||||
## Example Prometheus Job
|
||||
|
||||
```yaml
|
||||
- job_name: 'adguard-exporter'
|
||||
scrape_interval: 15s
|
||||
static_configs:
|
||||
- targets: ['adguard-exporter:9617']
|
||||
```
|
||||
---
|
||||
## Available Prometheus Metrics
|
||||
|
||||
This exporter exposes the following metrics from AdGuard Home.
|
||||
|
||||
---
|
||||
|
||||
### Core Metrics
|
||||
|
||||
* `adguard_dns_queries_total`
|
||||
Total DNS queries processed by AdGuard Home.
|
||||
|
||||
* `adguard_blocked_filtering_total`
|
||||
Total DNS queries blocked by filtering rules.
|
||||
|
||||
* `adguard_replaced_parental`
|
||||
Queries replaced by parental filtering.
|
||||
|
||||
* `adguard_avg_processing_time`
|
||||
Average DNS query processing time in milliseconds.
|
||||
|
||||
---
|
||||
|
||||
### AdGuard Status
|
||||
|
||||
* `adguard_protection_enabled`
|
||||
Whether DNS filtering protection is enabled (1 = enabled, 0 = disabled).
|
||||
|
||||
* `adguard_running`
|
||||
Whether the AdGuard Home DNS service is running.
|
||||
|
||||
* `adguard_dhcp_available`
|
||||
Indicates if DHCP functionality is available.
|
||||
|
||||
* `adguard_protection_disabled_duration_seconds`
|
||||
Time since DNS protection was disabled.
|
||||
|
||||
---
|
||||
|
||||
### Top Queries
|
||||
|
||||
Metrics with labels:
|
||||
|
||||
* `adguard_top_queried_domain_total{domain="example.com"}`
|
||||
* `adguard_top_blocked_domain_total{domain="ads.example.com"}`
|
||||
* `adguard_top_client_total{client="192.168.1.2"}`
|
||||
* `adguard_top_upstream_total{upstream="8.8.8.8"}`
|
||||
* `adguard_upstream_avg_response_time_seconds{upstream="8.8.8.8"}`
|
||||
|
||||
---
|
||||
|
||||
### Query Log Metrics
|
||||
|
||||
Metrics generated from AdGuard query log analysis.
|
||||
|
||||
* `adguard_query_domain_total{domain="example.com"}`
|
||||
* `adguard_query_reason_total{reason="FilteredBlackList"}`
|
||||
* `adguard_query_type_total{type="A"}`
|
||||
* `adguard_query_upstream_total{upstream="8.8.8.8"}`
|
||||
* `adguard_query_client_reason_total{client="192.168.1.10",reason="FilteredBlackList"}`
|
||||
|
||||
---
|
||||
|
||||
### Upstream Latency Metrics
|
||||
|
||||
Histogram metrics that track DNS upstream response time distribution.
|
||||
|
||||
* `adguard_upstream_latency_seconds`
|
||||
|
||||
This metric allows latency percentile calculations such as **p95 or p99 upstream DNS latency**.
|
||||
|
||||
Generated metrics:
|
||||
|
||||
* `adguard_upstream_latency_seconds_bucket`
|
||||
* `adguard_upstream_latency_seconds_sum`
|
||||
* `adguard_upstream_latency_seconds_count`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
adguard_upstream_latency_seconds_bucket{upstream="1.1.1.1",le="0.005"} 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GeoIP Metrics
|
||||
|
||||
#### `adguard_client_geo_queries`
|
||||
|
||||
Total DNS queries per client enriched with geographic information.
|
||||
|
||||
Labels:
|
||||
|
||||
* `client` – Client IP address
|
||||
* `country` – ISO country code
|
||||
* `lat` – Latitude
|
||||
* `lon` – Longitude
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
adguard_client_geo_queries{client="118.99.94.204",country="ID",lat="-2.969000",lon="104.744200"} 1000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `adguard_blocked_geo_queries`
|
||||
|
||||
Counts DNS queries that were blocked by filtering rules and enriches them with geographic information.
|
||||
|
||||
Labels:
|
||||
|
||||
* `client`
|
||||
* `country`
|
||||
* `lat`
|
||||
* `lon`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
adguard_blocked_geo_queries{client="118.99.94.204",country="ID",lat="-2.969000",lon="104.744200"} 306
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Exporter Health Metrics
|
||||
|
||||
Metrics used to monitor the exporter itself.
|
||||
|
||||
* `adguard_exporter_up` — Indicates if the exporter successfully scraped the AdGuard API.
|
||||
* `adguard_exporter_scrape_duration_seconds` — Time taken for the exporter to scrape AdGuard API data.
|
||||
* `adguard_exporter_scrape_errors_total` — Total number of errors encountered during scraping.
|
||||
* `adguard_exporter_query_cache_size` — Number of cached DNS query entries used for deduplication.
|
||||
* `adguard_exporter_geo_cache_size` — Number of cached GeoIP lookup entries.
|
||||
* `adguard_exporter_dedup_hits_total` — Total number of duplicate DNS queries skipped by the exporter.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
Grafana Dashboard Preview
|
||||
|
||||
[Live preview and monitoring, click here](https://stats.znand.my.id/public-dashboards/a0b05deb37464a17b01cbc69eb65ac5a?orgId=1&refresh=5s)
|
||||
|
||||
[Deploy yours here](https://grafana.com/grafana/dashboards/23579-adguard-metrics-statistics/)
|
||||
— new dashboard.json for grafana will be uploaded soon
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 💬 Feedback
|
||||
|
||||
Pull Requests, Issues, and Suggestions are always welcome!
|
||||
|
||||
**Made with ❤️ by [znanddev](https://github.com/znand-dev)**
|
||||
21
adguard-exporter/docker-compose.yml
Normal file
@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
adguard-exporter:
|
||||
image: ghcr.io/znand-dev/adguardexporter:latest
|
||||
container_name: adguard_exporter
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "9617:9617"
|
||||
|
||||
volumes:
|
||||
- ./<path/to/your>/GeoLite2-City.mmdb:/GeoLite2-City.mmdb # your geolite2 db file
|
||||
|
||||
environment:
|
||||
- ADGUARD_HOST=<ADGUARD_URL> # try docker ips if didn't works http://172.xx.x.x for example
|
||||
- ADGUARD_USER=<ADGUARD_USERNAME> # required
|
||||
- ADGUARD_PASS=<ADGUARD_PASSWORD> # required
|
||||
- EXPORTER_PORT=9200 # optional, default 9617
|
||||
- SCRAPE_INTERVAL=15s # optional
|
||||
- LOG_LEVEL=DEBUG # optional
|
||||
8
adguard-exporter/geolite2-db/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
Download GeoLite2 City database from MaxMind:
|
||||
|
||||
https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||
|
||||
Place the file here:
|
||||
|
||||
geoip/GeoLite2-City.mmdb
|
||||
|
||||
25
adguard-exporter/go.mod
Normal file
@ -0,0 +1,25 @@
|
||||
module github.com/znand-dev/adguardexporter
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/oschwald/geoip2-golang v1.13.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
52
adguard-exporter/go.sum
Normal file
@ -0,0 +1,52 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
1540
adguard-exporter/grafana/dashboard+node.json
Normal file
3470
adguard-exporter/grafana/dashboard.json
Normal file
BIN
adguard-exporter/grafana/dashboard01.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
adguard-exporter/grafana/dashboard02.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
adguard-exporter/grafana/dashboard03.png
Normal file
|
After Width: | Height: | Size: 795 KiB |
BIN
adguard-exporter/grafana/dashboard04.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
adguard-exporter/grafana/dashboard06.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
adguard-exporter/grafana/dashboard1.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
adguard-exporter/grafana/dashboard2.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
adguard-exporter/grafana/dashboard3.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
adguard-exporter/grafana/dashboard4.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
860
adguard-exporter/main.go
Normal file
@ -0,0 +1,860 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
/*
|
||||
📦 AdGuard Exporter for Prometheus
|
||||
----------------------------------
|
||||
Author : @znand-dev
|
||||
License : MIT
|
||||
Repo : https://github.com/znand-dev/adguardexporter
|
||||
|
||||
This Go application fetches stats from AdGuard Home via API endpoints
|
||||
and exposes them as Prometheus metrics at `/metrics`.
|
||||
|
||||
Required ENV variables:
|
||||
- ADGUARD_HOST : AdGuard Home base URL (e.g. http://192.168.1.1:3000)
|
||||
- ADGUARD_USER : API username (your adguard user)
|
||||
- ADGUARD_PASS : API password (your adguard pass)
|
||||
- EXPORTER_PORT : Port to expose metrics (default: 9617)
|
||||
- SCRAPE_INTERVAL : Interval (in seconds) to fetch new stats (default: 15)
|
||||
- LOG_LEVEL : Logging level (options: DEBUG, INFO, WARN, ERROR — default: INFO)
|
||||
- GEOIP_DB : GeoLite2-City.mmdb
|
||||
*/
|
||||
|
||||
var logLevelMap = map[string]int{"ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4}
|
||||
var currentLogLevel = 3
|
||||
|
||||
func initLogger() {
|
||||
level := os.Getenv("LOG_LEVEL")
|
||||
if level == "" {
|
||||
level = "INFO"
|
||||
}
|
||||
if val, ok := logLevelMap[level]; ok {
|
||||
currentLogLevel = val
|
||||
}
|
||||
}
|
||||
|
||||
func logX(level string, format string, args ...interface{}) {
|
||||
if logLevelMap[level] <= currentLogLevel {
|
||||
log.Printf("[%s] %s", level, fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
//function hash query
|
||||
func buildQueryKey(client, domain, upstream, reason, elapsed string) string {
|
||||
return client + "|" + domain + "|" + upstream + "|" + reason + "|" + elapsed
|
||||
}
|
||||
|
||||
type GeoCache struct {
|
||||
Country string
|
||||
Lat float64
|
||||
Lon float64
|
||||
TS int64
|
||||
}
|
||||
|
||||
var (
|
||||
geoDB *geoip2.Reader
|
||||
geoCache = map[string]GeoCache{}
|
||||
geoMutex sync.RWMutex
|
||||
// cache query hash
|
||||
querySeen = map[string]int64{}
|
||||
queryMutex sync.Mutex
|
||||
queryTTL = int64(300) // 5 menit
|
||||
geoTTL = int64(86400) // 24 jam
|
||||
dedupHits int64
|
||||
)
|
||||
|
||||
type AdGuardStats struct {
|
||||
NumDNSQueries float64 `json:"num_dns_queries"`
|
||||
NumBlockedFiltering float64 `json:"num_blocked_filtering"`
|
||||
NumReplacedParental float64 `json:"num_replaced_parental"`
|
||||
AvgProcessingTime float64 `json:"avg_processing_time"`
|
||||
TopQueriedDomains []map[string]float64 `json:"top_queried_domains"`
|
||||
TopBlockedDomains []map[string]float64 `json:"top_blocked_domains"`
|
||||
TopClients []map[string]float64 `json:"top_clients"`
|
||||
TopUpstream []map[string]float64 `json:"top_upstreams_responses"`
|
||||
TopUpstreamTime []map[string]float64 `json:"top_upstreams_avg_time"`
|
||||
}
|
||||
|
||||
type AdGuardStatus struct {
|
||||
Version string `json:"version"`
|
||||
Language string `json:"language"`
|
||||
DNSAddresses []string `json:"dns_addresses"`
|
||||
DNSPort int `json:"dns_port"`
|
||||
HTTPPort int `json:"http_port"`
|
||||
ProtectionDisabledDuration int `json:"protection_disabled_duration"`
|
||||
ProtectionEnabled bool `json:"protection_enabled"`
|
||||
DHCPAvailable bool `json:"dhcp_available"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
type AdGuardQueryLog struct {
|
||||
Data []struct {
|
||||
Question struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
} `json:"question"`
|
||||
Answer []interface{} `json:"answer"`
|
||||
Reason string `json:"reason"`
|
||||
Client string `json:"client"`
|
||||
Elapsed string `json:"elapsedMs"`
|
||||
Upstream string `json:"upstream"`
|
||||
|
||||
ClientInfo struct {
|
||||
Whois struct {
|
||||
Country string `json:"country"`
|
||||
OrgName string `json:"orgname"`
|
||||
} `json:"whois"`
|
||||
} `json:"client_info"`
|
||||
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
dnsQueries = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_dns_queries_total", Help: "Total DNS queries received",
|
||||
})
|
||||
|
||||
blockedFiltering = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_blocked_filtering_total", Help: "Total DNS queries blocked",
|
||||
})
|
||||
|
||||
replacedParental = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_replaced_parental", Help: "Total parental replaced queries",
|
||||
})
|
||||
|
||||
avgProcessingTime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_avg_processing_time", Help: "Avg DNS processing time",
|
||||
})
|
||||
|
||||
statusProtectionEnabled = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_protection_enabled", Help: "Protection enabled (1/0)",
|
||||
})
|
||||
|
||||
statusRunning = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_running", Help: "AdGuard running",
|
||||
})
|
||||
|
||||
statusDHCPAvailable = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_dhcp_available", Help: "DHCP available",
|
||||
})
|
||||
|
||||
statusDisabledDuration = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "adguard_protection_disabled_duration_seconds",
|
||||
Help: "Time since protection disabled",
|
||||
})
|
||||
|
||||
versionInfo = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_version_info",
|
||||
Help: "AdGuard version",
|
||||
},
|
||||
[]string{"version"},
|
||||
)
|
||||
|
||||
topQueriedDomains = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_top_queried_domain_total",
|
||||
Help: "Top queried domains",
|
||||
},
|
||||
[]string{"domain"},
|
||||
)
|
||||
|
||||
topBlockedDomains = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_top_blocked_domain_total",
|
||||
Help: "Top blocked domains",
|
||||
},
|
||||
[]string{"domain"},
|
||||
)
|
||||
|
||||
topClients = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_top_client_total",
|
||||
Help: "Top client IPs",
|
||||
},
|
||||
[]string{"client"},
|
||||
)
|
||||
|
||||
topUpstreams = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_top_upstream_total",
|
||||
Help: "Top upstream servers",
|
||||
},
|
||||
[]string{"upstream"},
|
||||
)
|
||||
|
||||
topUpstreamTime = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_upstream_avg_response_time_seconds",
|
||||
Help: "Avg response time per upstream",
|
||||
},
|
||||
[]string{"upstream"},
|
||||
)
|
||||
|
||||
queryCountByReason = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_query_reason_total",
|
||||
Help: "Queries by reason",
|
||||
},
|
||||
[]string{"reason"},
|
||||
)
|
||||
|
||||
queryCountByType = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_query_type_total",
|
||||
Help: "Queries by DNS type",
|
||||
},
|
||||
[]string{"type"},
|
||||
)
|
||||
|
||||
queryHistogramByClient = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "adguard_query_elapsed_ms",
|
||||
Help: "Query duration by client",
|
||||
Buckets: prometheus.LinearBuckets(1, 5, 10),
|
||||
},
|
||||
[]string{"client"},
|
||||
)
|
||||
|
||||
queryCountByUpstream = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_query_upstream_total",
|
||||
Help: "Queries per upstream",
|
||||
},
|
||||
[]string{"upstream"},
|
||||
)
|
||||
|
||||
queryCountByDomain = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_query_domain_total",
|
||||
Help: "Queries per domain",
|
||||
},
|
||||
[]string{"domain"},
|
||||
)
|
||||
|
||||
queryCountClientReason = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_query_client_reason_total",
|
||||
Help: "Queries per client per reason",
|
||||
},
|
||||
[]string{"client", "reason"},
|
||||
)
|
||||
|
||||
clientGeoQueries = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_client_geo_queries",
|
||||
Help: "Queries per client with geographic info",
|
||||
},
|
||||
[]string{"client", "country", "lat", "lon"},
|
||||
)
|
||||
|
||||
blockedGeoQueries = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_blocked_geo_queries",
|
||||
Help: "Blocked DNS queries per client with geographic info",
|
||||
},
|
||||
[]string{"client", "country", "lat", "lon"},
|
||||
)
|
||||
|
||||
upstreamLatencyHistogram = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "adguard_upstream_latency_seconds",
|
||||
Help: "Latency distribution per upstream DNS server",
|
||||
Buckets: prometheus.ExponentialBuckets(
|
||||
0.001, // 1ms
|
||||
2,
|
||||
10,
|
||||
),
|
||||
},
|
||||
[]string{"upstream"},
|
||||
)
|
||||
|
||||
exporterUp = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_exporter_up",
|
||||
Help: "Exporter scrape success",
|
||||
},
|
||||
)
|
||||
|
||||
exporterScrapeDuration = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_exporter_scrape_duration_seconds",
|
||||
Help: "Exporter scrape duration",
|
||||
},
|
||||
)
|
||||
|
||||
exporterErrors = prometheus.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_exporter_scrape_errors_total",
|
||||
Help: "Total exporter errors",
|
||||
},
|
||||
)
|
||||
|
||||
exporterQueryCacheSize = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_exporter_query_cache_size",
|
||||
Help: "Current number of entries in query deduplication cache",
|
||||
},
|
||||
)
|
||||
|
||||
exporterGeoCacheSize = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "adguard_exporter_geo_cache_size",
|
||||
Help: "Current number of cached GeoIP entries",
|
||||
},
|
||||
)
|
||||
|
||||
exporterDedupHits = prometheus.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_exporter_dedup_hits_total",
|
||||
Help: "Total number of duplicate queries skipped by deduplication",
|
||||
},
|
||||
)
|
||||
|
||||
queryCountByISP = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "adguard_query_isp_total",
|
||||
Help: "DNS queries grouped by ISP organization",
|
||||
},
|
||||
[]string{"isp", "country"},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = godotenv.Load()
|
||||
initLogger()
|
||||
|
||||
prometheus.MustRegister(
|
||||
dnsQueries,
|
||||
blockedFiltering,
|
||||
replacedParental,
|
||||
avgProcessingTime,
|
||||
statusProtectionEnabled,
|
||||
statusRunning,
|
||||
statusDHCPAvailable,
|
||||
statusDisabledDuration,
|
||||
versionInfo,
|
||||
topQueriedDomains,
|
||||
topBlockedDomains,
|
||||
topClients,
|
||||
topUpstreams,
|
||||
topUpstreamTime,
|
||||
queryCountByReason,
|
||||
queryCountByType,
|
||||
queryHistogramByClient,
|
||||
queryCountByUpstream,
|
||||
queryCountByDomain,
|
||||
queryCountClientReason,
|
||||
clientGeoQueries,
|
||||
blockedGeoQueries,
|
||||
exporterUp,
|
||||
exporterScrapeDuration,
|
||||
exporterErrors,
|
||||
upstreamLatencyHistogram,
|
||||
exporterQueryCacheSize,
|
||||
exporterGeoCacheSize,
|
||||
exporterDedupHits,
|
||||
queryCountByISP,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveGeo(ipStr string) (GeoCache, bool) {
|
||||
if geoDB == nil {
|
||||
return GeoCache{}, false
|
||||
}
|
||||
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() {
|
||||
return GeoCache{}, false
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
geoMutex.RLock()
|
||||
if val, ok := geoCache[ipStr]; ok {
|
||||
|
||||
// cache valid
|
||||
if now-val.TS < geoTTL {
|
||||
geoMutex.RUnlock()
|
||||
return val, true
|
||||
}
|
||||
|
||||
}
|
||||
geoMutex.RUnlock()
|
||||
|
||||
record, err := geoDB.City(ip)
|
||||
if err != nil {
|
||||
return GeoCache{}, false
|
||||
}
|
||||
|
||||
cache := GeoCache{
|
||||
Country: record.Country.IsoCode,
|
||||
Lat: record.Location.Latitude,
|
||||
Lon: record.Location.Longitude,
|
||||
TS: now,
|
||||
}
|
||||
|
||||
geoMutex.Lock()
|
||||
geoCache[ipStr] = cache
|
||||
geoMutex.Unlock()
|
||||
|
||||
return cache, true
|
||||
}
|
||||
|
||||
func fetchQueryLog() (*AdGuardQueryLog, error) {
|
||||
|
||||
host := os.Getenv("ADGUARD_HOST")
|
||||
user := os.Getenv("ADGUARD_USER")
|
||||
pass := os.Getenv("ADGUARD_PASS")
|
||||
|
||||
url := host + "/control/querylog"
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.SetBasicAuth(user, pass)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logX("WARN", "failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logData AdGuardQueryLog
|
||||
|
||||
err = json.Unmarshal(body, &logData)
|
||||
|
||||
return &logData, err
|
||||
}
|
||||
|
||||
func fetchStats() (*AdGuardStats, error) {
|
||||
|
||||
host := os.Getenv("ADGUARD_HOST")
|
||||
user := os.Getenv("ADGUARD_USER")
|
||||
pass := os.Getenv("ADGUARD_PASS")
|
||||
|
||||
url := host + "/control/stats"
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.SetBasicAuth(user, pass)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logX("WARN", "failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stats AdGuardStats
|
||||
|
||||
err = json.Unmarshal(body, &stats)
|
||||
|
||||
return &stats, err
|
||||
}
|
||||
|
||||
func fetchStatus() (*AdGuardStatus, error) {
|
||||
|
||||
host := os.Getenv("ADGUARD_HOST")
|
||||
user := os.Getenv("ADGUARD_USER")
|
||||
pass := os.Getenv("ADGUARD_PASS")
|
||||
|
||||
url := host + "/control/status"
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.SetBasicAuth(user, pass)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logX("WARN", "failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var status AdGuardStatus
|
||||
|
||||
err = json.Unmarshal(body, &status)
|
||||
|
||||
return &status, err
|
||||
}
|
||||
|
||||
func updateStatsMetrics() {
|
||||
|
||||
stats, err := fetchStats()
|
||||
|
||||
if err != nil {
|
||||
logX("ERROR", "Failed fetch stats: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
dnsQueries.Set(stats.NumDNSQueries)
|
||||
blockedFiltering.Set(stats.NumBlockedFiltering)
|
||||
replacedParental.Set(stats.NumReplacedParental)
|
||||
avgProcessingTime.Set(stats.AvgProcessingTime)
|
||||
|
||||
// reset gauge vectors
|
||||
topQueriedDomains.Reset()
|
||||
topBlockedDomains.Reset()
|
||||
topClients.Reset()
|
||||
topUpstreams.Reset()
|
||||
topUpstreamTime.Reset()
|
||||
|
||||
// top queried domains
|
||||
for _, d := range stats.TopQueriedDomains {
|
||||
for domain, count := range d {
|
||||
topQueriedDomains.WithLabelValues(domain).Set(count)
|
||||
}
|
||||
}
|
||||
|
||||
// top blocked domains
|
||||
for _, d := range stats.TopBlockedDomains {
|
||||
for domain, count := range d {
|
||||
topBlockedDomains.WithLabelValues(domain).Set(count)
|
||||
}
|
||||
}
|
||||
|
||||
// top clients
|
||||
for _, c := range stats.TopClients {
|
||||
for client, count := range c {
|
||||
topClients.WithLabelValues(client).Set(count)
|
||||
}
|
||||
}
|
||||
|
||||
// upstream responses
|
||||
for _, u := range stats.TopUpstream {
|
||||
for upstream, count := range u {
|
||||
topUpstreams.WithLabelValues(upstream).Set(count)
|
||||
}
|
||||
}
|
||||
|
||||
// upstream avg response time
|
||||
for _, u := range stats.TopUpstreamTime {
|
||||
for upstream, time := range u {
|
||||
topUpstreamTime.WithLabelValues(upstream).Set(time)
|
||||
}
|
||||
}
|
||||
|
||||
logX(
|
||||
"DEBUG",
|
||||
"Fetched stats: queries=%.0f blocked=%.0f replaced=%.0f avgTime=%.2fms topDomains=%d",
|
||||
stats.NumDNSQueries,
|
||||
stats.NumBlockedFiltering,
|
||||
stats.NumReplacedParental,
|
||||
stats.AvgProcessingTime,
|
||||
len(stats.TopQueriedDomains),
|
||||
)
|
||||
}
|
||||
|
||||
func updateStatusMetrics() {
|
||||
|
||||
status, err := fetchStatus()
|
||||
|
||||
if err != nil {
|
||||
logX("ERROR", "Failed fetch status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if status.Running {
|
||||
statusRunning.Set(1)
|
||||
} else {
|
||||
statusRunning.Set(0)
|
||||
}
|
||||
|
||||
if status.ProtectionEnabled {
|
||||
statusProtectionEnabled.Set(1)
|
||||
} else {
|
||||
statusProtectionEnabled.Set(0)
|
||||
}
|
||||
|
||||
if status.DHCPAvailable {
|
||||
statusDHCPAvailable.Set(1)
|
||||
} else {
|
||||
statusDHCPAvailable.Set(0)
|
||||
}
|
||||
|
||||
statusDisabledDuration.Set(float64(status.ProtectionDisabledDuration))
|
||||
|
||||
versionInfo.WithLabelValues(status.Version).Set(1)
|
||||
|
||||
logX(
|
||||
"DEBUG",
|
||||
"Fetched status: running=%v protection=%v DHCP=%v version=%s",
|
||||
status.Running,
|
||||
status.ProtectionEnabled,
|
||||
status.DHCPAvailable,
|
||||
status.Version,
|
||||
)
|
||||
}
|
||||
|
||||
func updateQueryLogMetrics() {
|
||||
|
||||
scanned := 0
|
||||
processed := 0
|
||||
skipped := 0
|
||||
geoResolved := 0
|
||||
privateClients := 0
|
||||
publicClients := 0
|
||||
|
||||
logData, err := fetchQueryLog()
|
||||
|
||||
if err != nil {
|
||||
logX("ERROR", "Failed querylog: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, q := range logData.Data {
|
||||
|
||||
scanned++
|
||||
|
||||
ip := net.ParseIP(q.Client)
|
||||
|
||||
if ip != nil {
|
||||
if ip.IsPrivate() || ip.IsLoopback() {
|
||||
privateClients++
|
||||
} else {
|
||||
publicClients++
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// dedup logic
|
||||
key := buildQueryKey(
|
||||
q.Client,
|
||||
q.Question.Name,
|
||||
q.Upstream,
|
||||
q.Reason,
|
||||
q.Elapsed,
|
||||
)
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
queryMutex.Lock()
|
||||
|
||||
if ts, exists := querySeen[key]; exists {
|
||||
if now-ts < queryTTL {
|
||||
queryMutex.Unlock()
|
||||
skipped++
|
||||
exporterDedupHits.Inc()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
querySeen[key] = now
|
||||
queryMutex.Unlock()
|
||||
|
||||
processed++
|
||||
|
||||
queryCountByReason.WithLabelValues(q.Reason).Inc()
|
||||
queryCountByType.WithLabelValues(q.Question.Type).Inc()
|
||||
|
||||
elapsedMs, err := strconv.ParseFloat(q.Elapsed, 64)
|
||||
|
||||
if err == nil {
|
||||
|
||||
queryHistogramByClient.WithLabelValues(q.Client).Observe(elapsedMs)
|
||||
|
||||
if q.Upstream != "" {
|
||||
upstreamLatencyHistogram.WithLabelValues(q.Upstream).Observe(elapsedMs / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
queryCountByUpstream.WithLabelValues(q.Upstream).Inc()
|
||||
queryCountByDomain.WithLabelValues(q.Question.Name).Inc()
|
||||
queryCountClientReason.WithLabelValues(q.Client, q.Reason).Inc()
|
||||
|
||||
geo, ok := resolveGeo(q.Client)
|
||||
|
||||
if ok {
|
||||
|
||||
geoResolved++
|
||||
|
||||
clientGeoQueries.WithLabelValues(
|
||||
q.Client,
|
||||
geo.Country,
|
||||
fmt.Sprintf("%f", geo.Lat),
|
||||
fmt.Sprintf("%f", geo.Lon),
|
||||
).Inc()
|
||||
|
||||
if q.Reason == "FilteredBlackList" ||
|
||||
q.Reason == "FilteredSafeBrowsing" ||
|
||||
q.Reason == "FilteredParental" {
|
||||
|
||||
blockedGeoQueries.WithLabelValues(
|
||||
q.Client,
|
||||
geo.Country,
|
||||
fmt.Sprintf("%f", geo.Lat),
|
||||
fmt.Sprintf("%f", geo.Lon),
|
||||
).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
isp := q.ClientInfo.Whois.OrgName
|
||||
country := q.ClientInfo.Whois.Country
|
||||
|
||||
if isp != "" {
|
||||
queryCountByISP.WithLabelValues(isp, country).Inc()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
logX("DEBUG",
|
||||
"Querylog: scanned=%d new=%d skipped=%d geoip=%d dedupHits=%d",
|
||||
scanned,
|
||||
processed,
|
||||
skipped,
|
||||
geoResolved,
|
||||
dedupHits,
|
||||
)
|
||||
}
|
||||
|
||||
//cleanup memory
|
||||
func cleanupQueryCache() {
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
queryMutex.Lock()
|
||||
|
||||
for k, v := range querySeen {
|
||||
|
||||
if now-v > queryTTL {
|
||||
delete(querySeen, k)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
queryMutex.Unlock()
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
dbPath := os.Getenv("GEOIP_DB")
|
||||
|
||||
if dbPath == "" {
|
||||
dbPath = "GeoLite2-City.mmdb"
|
||||
}
|
||||
|
||||
db, err := geoip2.Open(dbPath)
|
||||
|
||||
if err != nil {
|
||||
logX("WARN", "GeoIP DB not found (%v) — geo metrics disabled", err)
|
||||
} else {
|
||||
geoDB = db
|
||||
logX("INFO", "GeoIP database loaded")
|
||||
}
|
||||
|
||||
scrapeIntervalStr := os.Getenv("SCRAPE_INTERVAL")
|
||||
port := os.Getenv("EXPORTER_PORT")
|
||||
|
||||
if port == "" {
|
||||
port = "9617"
|
||||
}
|
||||
|
||||
interval, err := strconv.Atoi(scrapeIntervalStr)
|
||||
|
||||
if err != nil || interval < 1 {
|
||||
interval = 15
|
||||
}
|
||||
|
||||
logX("INFO", "Scrape interval set to %ds", interval)
|
||||
|
||||
go func() {
|
||||
|
||||
for {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
updateStatsMetrics()
|
||||
updateStatusMetrics()
|
||||
updateQueryLogMetrics()
|
||||
|
||||
cleanupQueryCache()
|
||||
|
||||
duration := time.Since(start).Seconds()
|
||||
|
||||
exporterScrapeDuration.Set(duration)
|
||||
exporterUp.Set(1)
|
||||
|
||||
exporterQueryCacheSize.Set(float64(len(querySeen)))
|
||||
exporterGeoCacheSize.Set(float64(len(geoCache)))
|
||||
|
||||
logX(
|
||||
"DEBUG",
|
||||
"Exporter caches: query_cache_entries=%d geo_cache_entries=%d dedup_hits_total=%d",
|
||||
len(querySeen),
|
||||
len(geoCache),
|
||||
dedupHits,
|
||||
)
|
||||
|
||||
logX("DEBUG", "Scrape finished in %.3fs", duration)
|
||||
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
|
||||
}
|
||||
|
||||
}()
|
||||
|
||||
http.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
logX("INFO", "Starting exporter at :%s ..", port)
|
||||
|
||||
err = http.ListenAndServe(":"+port, nil)
|
||||
|
||||
if err != nil {
|
||||
logX("ERROR", "Server failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
}
|
||||