Pull request 1928: 1453-stats-tests

Updates #1453.

Squashed commit of the following:

commit f08f68ef5493dad03d3eb120d886f2df1af28be6
Merge: b70b088af 54aee2272
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Aug 8 19:04:06 2023 +0300

    Merge branch 'master' into 1453-stats-tests

commit b70b088af0fdc7d6d048d688160048bad1fceb12
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Aug 3 19:32:04 2023 +0300

    stats: imp code

commit c341012ba61894c255c1868624be1cac0d26a6fa
Merge: a2ac8c34e 5eb3cd0f9
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Aug 3 13:36:24 2023 +0300

    Merge branch 'master' into 1453-stats-tests

commit a2ac8c34ee32606ca5e259c3e2a47db0dd5858de
Author: Ildar Kamalov <ik@adguard.com>
Date:   Thu Aug 3 13:25:12 2023 +0300

    client: add top upstreams and average processing time tables

commit 11118947f9bf945be0b056f8475cf3b848c6e66e
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Aug 1 17:24:57 2023 +0300

    stats: imp docs

commit 904cf81d02a1f327b9647fa7ad9e181cfabb68a4
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Jul 31 17:34:06 2023 +0300

    stats: imp code

commit 34f0c96dd5865d1470385322a88842dd0b3d996d
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Jul 31 15:43:46 2023 +0300

    all: imp docs

commit 2cb2d0d8bef3580f64bc25c414fe9b5ea6b9f997
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Jul 28 17:24:31 2023 +0300

    all: imp code

commit 5251a899fecc21e50a0ba06042f96f5b404e196a
Merge: b6c2b12d4 300821a7f
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 27 20:34:39 2023 +0300

    Merge branch 'master' into 1453-stats-tests

commit b6c2b12d4425012efd73549c3a426735f3a677cd
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 27 20:32:18 2023 +0300

    stats: imp code

commit 5546b82a78326f9cc6d8c87df5083f8fc66a0178
Merge: 8a3d6b1b4 5f8fa006c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 27 14:24:01 2023 +0300

    Merge branch 'master' into 1453-stats-tests

commit 8a3d6b1b49ce189f95adfa7406a34108e885e676
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 27 14:17:47 2023 +0300

    all: imp code

commit 2a48001e275e3cdcf70e13e1c9cebd4e502f3259
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Jul 25 18:27:20 2023 +0300

    all: imp docs

commit 3dd21890175af32a3368378f7e013383f6d040ec
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Jul 25 16:00:39 2023 +0300

    all: imp naming

commit 6124456fc3149b71f6bd58d35ecf24eb6cf40d5d
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Jul 20 16:15:56 2023 +0300

    all: add upstreams avg processing time

commit 187ad0c77a81c9fd95c24e23141355db2e83e50d
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Jul 18 16:42:19 2023 +0300

    all: add top upstreams
This commit is contained in:
Stanislav Chzhen 2023-08-09 14:33:52 +03:00
parent 54aee22720
commit c47509fabc
19 changed files with 686 additions and 140 deletions

View File

@ -25,6 +25,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
### Added
- Two new metrics showing total number of responses from each upstream DNS
server and their average processing time in the Web UI ([#1453]).
- The ability to set the port for the `pprof` debug API, see configuration
changes below.
@ -59,6 +61,7 @@ In this release, the schema version has changed from 24 to 25.
- Panic on shutting down while DNS requests are in process of filtering
([#5948]).
[#1453]: https://github.com/AdguardTeam/AdGuardHome/issues/1453
[#5948]: https://github.com/AdguardTeam/AdGuardHome/issues/5948
<!--

View File

@ -125,6 +125,8 @@
"top_clients": "Top clients",
"no_clients_found": "No clients found",
"general_statistics": "General statistics",
"top_upstreams": "Top upstreams",
"no_upstreams_data_found": "No upstreams data found",
"number_of_dns_query_days": "The number of DNS queries processed for the last {{count}} day",
"number_of_dns_query_days_plural": "The number of DNS queries processed for the last {{count}} days",
"number_of_dns_query_24_hours": "The number of DNS queries processed for the last 24 hours",
@ -134,6 +136,7 @@
"enforced_save_search": "Enforced safe search",
"number_of_dns_query_to_safe_search": "The number of DNS requests to search engines for which Safe Search was enforced",
"average_processing_time": "Average processing time",
"processing_time": "Processing time",
"average_processing_time_hint": "Average time in milliseconds on processing a DNS request",
"block_domain_use_filters_and_hosts": "Block domains using filters and hosts files",
"filters_block_toggle_hint": "You can setup blocking rules in the <a>Filters</a> settings.",
@ -158,6 +161,7 @@
"upstream_dns_configured_in_file": "Configured in {{path}}",
"test_upstream_btn": "Test upstreams",
"upstreams": "Upstreams",
"upstream": "Upstream",
"apply_btn": "Apply",
"disabled_filtering_toast": "Disabled filtering",
"enabled_filtering_toast": "Enabled filtering",

View File

@ -56,6 +56,8 @@ export const getStats = () => async (dispatch) => {
top_clients: topClientsWithInfo,
top_queried_domains: normalizeTopStats(stats.top_queried_domains),
avg_processing_time: secondsToMilliseconds(stats.avg_processing_time),
top_upstreams_responses: normalizeTopStats(stats.top_upstreams_responses),
top_upstrems_avg_time: normalizeTopStats(stats.top_upstreams_avg_time),
};
dispatch(getStatsSuccess(normalizedStats));

View File

@ -0,0 +1,79 @@
import React from 'react';
import ReactTable from 'react-table';
import PropTypes from 'prop-types';
import round from 'lodash/round';
import { withTranslation, Trans } from 'react-i18next';
import Card from '../ui/Card';
import DomainCell from './DomainCell';
const TimeCell = ({ value }) => {
if (!value) {
return '';
}
const valueInMilliseconds = round(value * 1000);
return (
<div className="logs__row o-hidden">
<span className="logs__text logs__text--full" title={valueInMilliseconds}>
{valueInMilliseconds}&nbsp;ms
</span>
</div>
);
};
TimeCell.propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
};
const UpstreamAvgTime = ({
t,
refreshButton,
topUpstreamsAvgTime,
subtitle,
}) => (
<Card
title={t('average_processing_time')}
subtitle={subtitle}
bodyType="card-table"
refresh={refreshButton}
>
<ReactTable
data={topUpstreamsAvgTime.map(({ name: domain, count }) => ({
domain,
count,
}))}
columns={[
{
Header: <Trans>upstream</Trans>,
accessor: 'domain',
Cell: DomainCell,
},
{
Header: <Trans>processing_time</Trans>,
accessor: 'count',
maxWidth: 190,
Cell: TimeCell,
},
]}
showPagination={false}
noDataText={t('no_upstreams_data_found')}
minRows={6}
defaultPageSize={100}
className="-highlight card-table-overflow--limited stats__table"
/>
</Card>
);
UpstreamAvgTime.propTypes = {
topUpstreamsAvgTime: PropTypes.array.isRequired,
refreshButton: PropTypes.node.isRequired,
subtitle: PropTypes.string.isRequired,
t: PropTypes.func.isRequired,
};
export default withTranslation()(UpstreamAvgTime);

View File

@ -0,0 +1,76 @@
import React from 'react';
import ReactTable from 'react-table';
import PropTypes from 'prop-types';
import { withTranslation, Trans } from 'react-i18next';
import Card from '../ui/Card';
import Cell from '../ui/Cell';
import DomainCell from './DomainCell';
import { getPercent } from '../../helpers/helpers';
import { STATUS_COLORS } from '../../helpers/constants';
const CountCell = (totalBlocked) => (
function cell(row) {
const { value } = row;
const percent = getPercent(totalBlocked, value);
return (
<Cell
value={value}
percent={percent}
color={STATUS_COLORS.green}
/>
);
}
);
const UpstreamResponses = ({
t,
refreshButton,
topUpstreamsResponses,
dnsQueries,
subtitle,
}) => (
<Card
title={t('top_upstreams')}
subtitle={subtitle}
bodyType="card-table"
refresh={refreshButton}
>
<ReactTable
data={topUpstreamsResponses.map(({ name: domain, count }) => ({
domain,
count,
}))}
columns={[
{
Header: <Trans>upstream</Trans>,
accessor: 'domain',
Cell: DomainCell,
},
{
Header: <Trans>requests_count</Trans>,
accessor: 'count',
maxWidth: 190,
Cell: CountCell(dnsQueries),
},
]}
showPagination={false}
noDataText={t('no_upstreams_data_found')}
minRows={6}
defaultPageSize={100}
className="-highlight card-table-overflow--limited stats__table"
/>
</Card>
);
UpstreamResponses.propTypes = {
topUpstreamsResponses: PropTypes.array.isRequired,
dnsQueries: PropTypes.number.isRequired,
refreshButton: PropTypes.node.isRequired,
subtitle: PropTypes.string.isRequired,
t: PropTypes.func.isRequired,
};
export default withTranslation()(UpstreamResponses);

View File

@ -21,6 +21,8 @@ import PageTitle from '../ui/PageTitle';
import Loading from '../ui/Loading';
import './Dashboard.css';
import Dropdown from '../ui/Dropdown';
import UpstreamResponses from './UpstreamResponses';
import UpstreamAvgTime from './UpstreamAvgTime';
const Dashboard = ({
getAccessList,
@ -234,6 +236,21 @@ const Dashboard = ({
refreshButton={refreshButton}
/>
</div>
<div className="col-lg-6">
<UpstreamResponses
subtitle={subtitle}
dnsQueries={stats.numDnsQueries}
topUpstreamsResponses={stats.topUpstreamsResponses}
refreshButton={refreshButton}
/>
</div>
<div className="col-lg-6">
<UpstreamAvgTime
subtitle={subtitle}
topUpstreamsAvgTime={stats.topUpstreamsAvgTime}
refreshButton={refreshButton}
/>
</div>
</div>}
</>;
};

View File

@ -1,13 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import LogsSearchLink from './LogsSearchLink';
import { formatNumber } from '../../helpers/helpers';
const Cell = ({
value, percent, color, search,
}) => <div className="stats__row">
value,
percent,
color,
search,
}) => (
<div className="stats__row">
<div className="stats__row-value mb-1">
<strong><LogsSearchLink search={search}>{formatNumber(value)}</LogsSearchLink></strong>
<strong>
{search ? (
<LogsSearchLink search={search}>
{formatNumber(value)}
</LogsSearchLink>
) : (
formatNumber(value)
)}
</strong>
<small className="ml-3 text-muted">{percent}%</small>
</div>
<div className="progress progress-xs">
@ -19,7 +32,8 @@ const Cell = ({
}}
/>
</div>
</div>;
</div>
);
Cell.propTypes = {
value: PropTypes.number.isRequired,

View File

@ -58,6 +58,8 @@ const stats = handleActions(
num_replaced_safebrowsing: numReplacedSafebrowsing,
num_replaced_safesearch: numReplacedSafesearch,
avg_processing_time: avgProcessingTime,
top_upstreams_responses: topUpstreamsResponses,
top_upstrems_avg_time: topUpstreamsAvgTime,
} = payload;
const newState = {
@ -77,6 +79,8 @@ const stats = handleActions(
numReplacedSafebrowsing,
numReplacedSafesearch,
avgProcessingTime,
topUpstreamsResponses,
topUpstreamsAvgTime,
};
return newState;

View File

@ -139,10 +139,14 @@ func (s *Server) updateStats(
clientIP string,
) {
pctx := ctx.proxyCtx
e := stats.Entry{
e := &stats.Entry{
Domain: aghnet.NormalizeDomain(pctx.Req.Question[0].Name),
Result: stats.RNotFiltered,
Time: uint32(elapsed / 1000),
Time: elapsed,
}
if pctx.Upstream != nil {
e.Upstream = pctx.Upstream.Address()
}
if clientID := ctx.clientID; clientID != "" {

View File

@ -41,11 +41,11 @@ type testStats struct {
// without actually implementing all methods.
stats.Interface
lastEntry stats.Entry
lastEntry *stats.Entry
}
// Update implements the [stats.Interface] interface for *testStats.
func (l *testStats) Update(e stats.Entry) {
func (l *testStats) Update(e *stats.Entry) {
if e.Domain == "" {
return
}

View File

@ -19,6 +19,9 @@ import (
// The key is either a client's address or a requested address.
type topAddrs = map[string]uint64
// topAddrsFloat is like [topAddrs] but the value is float64 number.
type topAddrsFloat = map[string]float64
// StatsResp is a response to the GET /control/stats.
type StatsResp struct {
TimeUnits string `json:"time_units"`
@ -27,6 +30,9 @@ type StatsResp struct {
TopClients []topAddrs `json:"top_clients"`
TopBlocked []topAddrs `json:"top_blocked_domains"`
TopUpstreamsResponses []topAddrs `json:"top_upstreams_responses"`
TopUpstreamsAvgTime []topAddrsFloat `json:"top_upstreams_avg_time"`
DNSQueries []uint64 `json:"dns_queries"`
BlockedFiltering []uint64 `json:"blocked_filtering"`

View File

@ -5,7 +5,6 @@ package stats
import (
"fmt"
"io"
"net"
"net/netip"
"os"
"sync"
@ -80,7 +79,7 @@ type Interface interface {
io.Closer
// Update collects the incoming statistics data.
Update(e Entry)
Update(e *Entry)
// GetTopClientIP returns at most limit IP addresses corresponding to the
// clients with the most number of requests.
@ -225,7 +224,7 @@ func (s *StatsCtx) Start() {
go s.periodicFlush()
}
// Close implements the io.Closer interface for *StatsCtx.
// Close implements the [io.Closer] interface for *StatsCtx.
func (s *StatsCtx) Close() (err error) {
defer func() { err = errors.Annotate(err, "stats: closing: %w") }()
@ -256,8 +255,9 @@ func (s *StatsCtx) Close() (err error) {
return udb.flushUnitToDB(tx, s.curr.id)
}
// Update implements the Interface interface for *StatsCtx.
func (s *StatsCtx) Update(e Entry) {
// Update implements the [Interface] interface for *StatsCtx. e must not be
// nil.
func (s *StatsCtx) Update(e *Entry) {
s.confMu.Lock()
defer s.confMu.Unlock()
@ -265,8 +265,9 @@ func (s *StatsCtx) Update(e Entry) {
return
}
if e.Result == 0 || e.Result >= resultLast || e.Domain == "" || e.Client == "" {
log.Debug("stats: malformed entry")
err := e.validate()
if err != nil {
log.Debug("stats: updating: validating entry: %s", err)
return
}
@ -280,15 +281,10 @@ func (s *StatsCtx) Update(e Entry) {
return
}
clientID := e.Client
if ip := net.ParseIP(clientID); ip != nil {
clientID = ip.String()
s.curr.add(e)
}
s.curr.add(e.Result, e.Domain, clientID, uint64(e.Time))
}
// WriteDiskConfig implements the Interface interface for *StatsCtx.
// WriteDiskConfig implements the [Interface] interface for *StatsCtx.
func (s *StatsCtx) WriteDiskConfig(dc *Config) {
s.confMu.RLock()
defer s.confMu.RUnlock()
@ -412,6 +408,12 @@ func (s *StatsCtx) flush() (cont bool, sleepFor time.Duration) {
return true, time.Second
}
return s.flushDB(id, limit, ptr)
}
// flushDB flushes the unit to the database. confMu and currMu are expected to
// be locked.
func (s *StatsCtx) flushDB(id, limit uint32, ptr *unit) (cont bool, sleepFor time.Duration) {
db := s.db.Load()
if db == nil {
return true, 0

View File

@ -50,11 +50,11 @@ func TestStats_races(t *testing.T) {
testutil.CleanupAndRequireSuccess(t, s.Close)
writeFunc := func(start, fin *sync.WaitGroup, waitCh <-chan unit, i int) {
e := Entry{
e := &Entry{
Domain: fmt.Sprintf("example-%d.org", i),
Client: fmt.Sprintf("client_%d", i),
Result: Result(i)%(resultLast-1) + 1,
Time: uint32(time.Since(startTime).Milliseconds()),
Time: time.Since(startTime),
}
start.Done()

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/stats"
"github.com/AdguardTeam/golibs/netutil"
@ -72,17 +73,20 @@ func TestStats(t *testing.T) {
t.Run("data", func(t *testing.T) {
const reqDomain = "domain"
const respUpstream = "upstream"
entries := []stats.Entry{{
entries := []*stats.Entry{{
Domain: reqDomain,
Client: cliIPStr,
Result: stats.RFiltered,
Time: 123456,
Time: time.Microsecond * 123456,
Upstream: respUpstream,
}, {
Domain: reqDomain,
Client: cliIPStr,
Result: stats.RNotFiltered,
Time: 123456,
Time: time.Microsecond * 123456,
Upstream: respUpstream,
}}
wantData := &stats.StatsResp{
@ -90,6 +94,8 @@ func TestStats(t *testing.T) {
TopQueried: []map[string]uint64{0: {reqDomain: 1}},
TopClients: []map[string]uint64{0: {cliIPStr: 2}},
TopBlocked: []map[string]uint64{0: {reqDomain: 1}},
TopUpstreamsResponses: []map[string]uint64{0: {respUpstream: 2}},
TopUpstreamsAvgTime: []map[string]float64{0: {respUpstream: 0.123456}},
DNSQueries: []uint64{
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
@ -142,6 +148,8 @@ func TestStats(t *testing.T) {
TopQueried: []map[string]uint64{},
TopClients: []map[string]uint64{},
TopBlocked: []map[string]uint64{},
TopUpstreamsResponses: []map[string]uint64{},
TopUpstreamsAvgTime: []map[string]float64{},
DNSQueries: _24zeroes[:],
BlockedFiltering: _24zeroes[:],
ReplacedSafebrowsing: _24zeroes[:],
@ -187,7 +195,7 @@ func TestLargeNumbers(t *testing.T) {
for i := 0; i < cliNumPerHour; i++ {
ip := net.IP{127, 0, byte((i & 0xff00) >> 8), byte(i & 0xff)}
e := stats.Entry{
e := &stats.Entry{
Domain: fmt.Sprintf("domain%d.hour%d", i, h),
Client: ip.String(),
Result: stats.RNotFiltered,

View File

@ -11,17 +11,19 @@ import (
"github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/stringutil"
"go.etcd.io/bbolt"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
// TODO(a.garipov): Rewrite all of this. Add proper error handling and
// inspection. Improve logging. Decrease complexity.
const (
// maxDomains is the max number of top domains to return.
maxDomains = 100
// maxClients is the max number of top clients to return.
maxClients = 100
// maxUpstreams is the max number of top upstreams to return.
maxUpstreams = 100
)
// UnitIDGenFunc is the signature of a function that generates a unique ID for
@ -63,11 +65,30 @@ type Entry struct {
// Domain is the domain name requested.
Domain string
// Upstream is the upstream DNS server.
Upstream string
// Result is the result of processing the request.
Result Result
// Time is the duration of the request processing in milliseconds.
Time uint32
// Time is the duration of the request processing.
Time time.Duration
}
// validate returs an error if entry is not valid.
func (e *Entry) validate() (err error) {
switch {
case e.Result == 0:
return errors.Error("result code is not set")
case e.Result >= resultLast:
return fmt.Errorf("unknown result code %d", e.Result)
case e.Domain == "":
return errors.Error("domain is empty")
case e.Client == "":
return errors.Error("client is empty")
default:
return nil
}
}
// unit collects the statistics data for a specific period of time.
@ -82,6 +103,13 @@ type unit struct {
// clients stores the number of requests from each client.
clients map[string]uint64
// upstreamsResponses stores the number of responses from each upstream.
upstreamsResponses map[string]uint64
// upstreamsTimeSum stores the sum of processing time in microseconds of
// responses from each upstream.
upstreamsTimeSum map[string]uint64
// nResult stores the number of requests grouped by it's result.
nResult []uint64
@ -95,7 +123,7 @@ type unit struct {
// nTotal stores the total number of requests.
nTotal uint64
// timeSum stores the sum of processing time in milliseconds of each request
// timeSum stores the sum of processing time in microseconds of each request
// written by the unit.
timeSum uint64
}
@ -106,6 +134,8 @@ func newUnit(id uint32) (u *unit) {
domains: map[string]uint64{},
blockedDomains: map[string]uint64{},
clients: map[string]uint64{},
upstreamsResponses: map[string]uint64{},
upstreamsTimeSum: map[string]uint64{},
nResult: make([]uint64, resultLast),
id: id,
}
@ -135,10 +165,17 @@ type unitDB struct {
// Clients is the number of requests from each client.
Clients []countPair
// UpstreamsResponses is the number of responses from each upstream.
UpstreamsResponses []countPair
// UpstreamsTimeSum is the sum of processing time in microseconds of
// responses from each upstream.
UpstreamsTimeSum []countPair
// NTotal is the total number of requests.
NTotal uint64
// TimeAvg is the average of processing times in milliseconds of all the
// TimeAvg is the average of processing times in microseconds of all the
// requests in the unit.
TimeAvg uint32
}
@ -223,6 +260,8 @@ func (u *unit) serialize() (udb *unitDB) {
Domains: convertMapToSlice(u.domains, maxDomains),
BlockedDomains: convertMapToSlice(u.blockedDomains, maxDomains),
Clients: convertMapToSlice(u.clients, maxClients),
UpstreamsResponses: convertMapToSlice(u.upstreamsResponses, maxUpstreams),
UpstreamsTimeSum: convertMapToSlice(u.upstreamsTimeSum, maxUpstreams),
TimeAvg: timeAvg,
}
}
@ -262,21 +301,29 @@ func (u *unit) deserialize(udb *unitDB) {
u.domains = convertSliceToMap(udb.Domains)
u.blockedDomains = convertSliceToMap(udb.BlockedDomains)
u.clients = convertSliceToMap(udb.Clients)
u.upstreamsResponses = convertSliceToMap(udb.UpstreamsResponses)
u.upstreamsTimeSum = convertSliceToMap(udb.UpstreamsTimeSum)
u.timeSum = uint64(udb.TimeAvg) * udb.NTotal
}
// add adds new data to u. It's safe for concurrent use.
func (u *unit) add(res Result, domain, cli string, dur uint64) {
u.nResult[res]++
if res == RNotFiltered {
u.domains[domain]++
func (u *unit) add(e *Entry) {
u.nResult[e.Result]++
if e.Result == RNotFiltered {
u.domains[e.Domain]++
} else {
u.blockedDomains[domain]++
u.blockedDomains[e.Domain]++
}
u.clients[cli]++
u.timeSum += dur
u.clients[e.Client]++
t := uint64(e.Time.Microseconds())
u.timeSum += t
u.nTotal++
if e.Upstream != "" {
u.upstreamsResponses[e.Upstream]++
u.upstreamsTimeSum[e.Upstream] += t
}
}
// flushUnitToDB puts udb to the database at id.
@ -393,6 +440,8 @@ func (s *StatsCtx) getData(limit uint32) (StatsResp, bool) {
TopBlocked: []topAddrs{},
TopClients: []topAddrs{},
TopQueried: []topAddrs{},
TopUpstreamsResponses: []topAddrs{},
TopUpstreamsAvgTime: []topAddrsFloat{},
BlockedFiltering: []uint64{},
DNSQueries: []uint64{},
@ -416,6 +465,18 @@ func (s *StatsCtx) getData(limit uint32) (StatsResp, bool) {
log.Fatalf("len(dnsQueries) != limit: %d %d", len(dnsQueries), limit)
}
return s.dataFromUnits(units, dnsQueries, firstID, timeUnit), true
}
// dataFromUnits collects and returns the statistics data.
func (s *StatsCtx) dataFromUnits(
units []*unitDB,
dnsQueries []uint64,
firstID uint32,
timeUnit TimeUnit,
) (resp StatsResp) {
topUpstreamsResponses, topUpstreamsAvgTime := topUpstreamsPairs(units)
data := StatsResp{
DNSQueries: dnsQueries,
BlockedFiltering: statsCollector(units, firstID, timeUnit, func(u *unitDB) (num uint64) { return u.NResult[RFiltered] }),
@ -423,6 +484,8 @@ func (s *StatsCtx) getData(limit uint32) (StatsResp, bool) {
ReplacedParental: statsCollector(units, firstID, timeUnit, func(u *unitDB) (num uint64) { return u.NResult[RParental] }),
TopQueried: topsCollector(units, maxDomains, s.ignored, func(u *unitDB) (pairs []countPair) { return u.Domains }),
TopBlocked: topsCollector(units, maxDomains, s.ignored, func(u *unitDB) (pairs []countPair) { return u.BlockedDomains }),
TopUpstreamsResponses: topUpstreamsResponses,
TopUpstreamsAvgTime: topUpstreamsAvgTime,
TopClients: topsCollector(units, maxClients, nil, topClientPairs(s)),
}
@ -430,7 +493,7 @@ func (s *StatsCtx) getData(limit uint32) (StatsResp, bool) {
sum := unitDB{
NResult: make([]uint64, resultLast),
}
timeN := 0
var timeN uint32
for _, u := range units {
sum.NTotal += u.NTotal
sum.TimeAvg += u.TimeAvg
@ -450,7 +513,7 @@ func (s *StatsCtx) getData(limit uint32) (StatsResp, bool) {
data.NumReplacedParental = sum.NResult[RParental]
if timeN != 0 {
data.AvgProcessingTime = float64(sum.TimeAvg/uint32(timeN)) / 1000000
data.AvgProcessingTime = microsecondsToSeconds(float64(sum.TimeAvg / timeN))
}
data.TimeUnits = "hours"
@ -458,7 +521,7 @@ func (s *StatsCtx) getData(limit uint32) (StatsResp, bool) {
data.TimeUnits = "days"
}
return data, true
return data
}
func topClientPairs(s *StatsCtx) (pg pairsGetter) {
@ -474,3 +537,66 @@ func topClientPairs(s *StatsCtx) (pg pairsGetter) {
return clients
}
}
// topUpstreamsPairs returns sorted lists of number of total responses and the
// average of processing time for each upstream.
func topUpstreamsPairs(
units []*unitDB,
) (topUpstreamsResponses []topAddrs, topUpstreamsAvgTime []topAddrsFloat) {
upstreamsResponses := topAddrs{}
upstreamsTimeSum := topAddrsFloat{}
for _, u := range units {
for _, cp := range u.UpstreamsResponses {
upstreamsResponses[cp.Name] += cp.Count
}
for _, cp := range u.UpstreamsTimeSum {
upstreamsTimeSum[cp.Name] += float64(cp.Count)
}
}
upstreamsAvgTime := topAddrsFloat{}
for u, n := range upstreamsResponses {
total := upstreamsTimeSum[u]
if total != 0 {
upstreamsAvgTime[u] = microsecondsToSeconds(total / float64(n))
}
}
upstreamsPairs := convertMapToSlice(upstreamsResponses, maxUpstreams)
topUpstreamsResponses = convertTopSlice(upstreamsPairs)
return topUpstreamsResponses, prepareTopUpstreamsAvgTime(upstreamsAvgTime)
}
// microsecondsToSeconds converts microseconds to seconds.
//
// NOTE: Frontend expects time duration in seconds as floating-point number
// with double precision.
func microsecondsToSeconds(n float64) (r float64) {
const micro = 1e-6
return n * micro
}
// prepareTopUpstreamsAvgTime returns sorted list of average processing times
// of the DNS requests from each upstream.
func prepareTopUpstreamsAvgTime(
upstreamsAvgTime topAddrsFloat,
) (topUpstreamsAvgTime []topAddrsFloat) {
keys := maps.Keys(upstreamsAvgTime)
slices.SortFunc(keys, func(a, b string) (sortsBefore bool) {
return upstreamsAvgTime[a] > upstreamsAvgTime[b]
})
topUpstreamsAvgTime = make([]topAddrsFloat, 0, len(upstreamsAvgTime))
for _, k := range keys {
topUpstreamsAvgTime = append(topUpstreamsAvgTime, topAddrsFloat{k: upstreamsAvgTime[k]})
}
return topUpstreamsAvgTime
}

View File

@ -0,0 +1,177 @@
package stats
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUnit_Deserialize(t *testing.T) {
testCases := []struct {
db *unitDB
name string
want unit
}{{
name: "empty",
want: unit{
domains: map[string]uint64{},
blockedDomains: map[string]uint64{},
clients: map[string]uint64{},
nResult: []uint64{0, 0, 0, 0, 0, 0},
id: 0,
nTotal: 0,
timeSum: 0,
upstreamsResponses: map[string]uint64{},
upstreamsTimeSum: map[string]uint64{},
},
db: &unitDB{
NResult: []uint64{0, 0, 0, 0, 0, 0},
Domains: []countPair{},
BlockedDomains: []countPair{},
Clients: []countPair{},
NTotal: 0,
TimeAvg: 0,
UpstreamsResponses: []countPair{},
UpstreamsTimeSum: []countPair{},
},
}, {
name: "basic",
want: unit{
domains: map[string]uint64{
"example.com": 1,
},
blockedDomains: map[string]uint64{
"example.net": 1,
},
clients: map[string]uint64{
"127.0.0.1": 2,
},
nResult: []uint64{0, 1, 1, 0, 0, 0},
id: 0,
nTotal: 2,
timeSum: 246912,
upstreamsResponses: map[string]uint64{
"1.2.3.4": 2,
},
upstreamsTimeSum: map[string]uint64{
"1.2.3.4": 246912,
},
},
db: &unitDB{
NResult: []uint64{0, 1, 1, 0, 0, 0},
Domains: []countPair{{
"example.com", 1,
}},
BlockedDomains: []countPair{{
"example.net", 1,
}},
Clients: []countPair{{
"127.0.0.1", 2,
}},
NTotal: 2,
TimeAvg: 123456,
UpstreamsResponses: []countPair{{
"1.2.3.4", 2,
}},
UpstreamsTimeSum: []countPair{{
"1.2.3.4", 246912,
}},
},
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := unit{}
got.deserialize(tc.db)
require.Equal(t, tc.want, got)
})
}
}
func TestTopUpstreamsPairs(t *testing.T) {
testCases := []struct {
db *unitDB
name string
wantResponses []topAddrs
wantAvgTime []topAddrsFloat
}{{
name: "empty",
db: &unitDB{
NResult: []uint64{0, 0, 0, 0, 0, 0},
Domains: []countPair{},
BlockedDomains: []countPair{},
Clients: []countPair{},
NTotal: 0,
TimeAvg: 0,
UpstreamsResponses: []countPair{},
UpstreamsTimeSum: []countPair{},
},
wantResponses: []topAddrs{},
wantAvgTime: []topAddrsFloat{},
}, {
name: "basic",
db: &unitDB{
NResult: []uint64{0, 0, 0, 0, 0, 0},
Domains: []countPair{},
BlockedDomains: []countPair{},
Clients: []countPair{},
NTotal: 0,
TimeAvg: 0,
UpstreamsResponses: []countPair{{
"1.2.3.4", 2,
}},
UpstreamsTimeSum: []countPair{{
"1.2.3.4", 246912,
}},
},
wantResponses: []topAddrs{{
"1.2.3.4": 2,
}},
wantAvgTime: []topAddrsFloat{{
"1.2.3.4": 0.123456,
}},
}, {
name: "sorted",
db: &unitDB{
NResult: []uint64{0, 0, 0, 0, 0, 0},
Domains: []countPair{},
BlockedDomains: []countPair{},
Clients: []countPair{},
NTotal: 0,
TimeAvg: 0,
UpstreamsResponses: []countPair{
{"3.3.3.3", 8},
{"2.2.2.2", 4},
{"4.4.4.4", 16},
{"1.1.1.1", 2},
},
UpstreamsTimeSum: []countPair{
{"3.3.3.3", 800_000_000},
{"2.2.2.2", 40_000_000},
{"4.4.4.4", 16_000_000_000},
{"1.1.1.1", 2_000_000},
},
},
wantResponses: []topAddrs{
{"4.4.4.4": 16},
{"3.3.3.3": 8},
{"2.2.2.2": 4},
{"1.1.1.1": 2},
},
wantAvgTime: []topAddrsFloat{
{"4.4.4.4": 1000},
{"3.3.3.3": 100},
{"2.2.2.2": 10},
{"1.1.1.1": 1},
},
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gotResponses, gotAvgTime := topUpstreamsPairs([]*unitDB{tc.db})
assert.Equal(t, tc.wantResponses, gotResponses)
assert.Equal(t, tc.wantAvgTime, gotAvgTime)
})
}
}

View File

@ -4,6 +4,16 @@
## v0.108.0: API changes
## v0.107.36: API changes
### The new fields `"top_upstreams_responses"` and `"top_upstreams_avg_time"` in `Stats` object
* The new field `"top_upstreams_responses"` in `GET /control/stats` method
shows the total number of responses from each upstream.
* The new field `"top_upstrems_avg_time"` in `GET /control/stats` method shows
the average processing time in seconds of requests from each upstream.
## v0.107.30: API changes
### `POST /control/version.json` and `GET /control/dhcp/interfaces` content type

View File

@ -1728,7 +1728,7 @@
'avg_processing_time':
'type': 'number'
'format': 'float'
'description': 'Average time in milliseconds on processing a DNS'
'description': 'Average time in seconds on processing a DNS request'
'example': 0.34
'top_queried_domains':
'type': 'array'
@ -1742,6 +1742,19 @@
'type': 'array'
'items':
'$ref': '#/components/schemas/TopArrayEntry'
'top_upstreams_responses':
'type': 'array'
'description': 'Total number of responses from each upstream.'
'items':
'$ref': '#/components/schemas/TopArrayEntry'
'maxItems': 100
'top_upstreams_avg_time':
'type': 'array'
'description': >
Average processing time in seconds of requests from each upstream.
'items':
'$ref': '#/components/schemas/TopArrayEntry'
'maxItems': 100
'dns_queries':
'type': 'array'
'items':
@ -1761,12 +1774,13 @@
'TopArrayEntry':
'type': 'object'
'description': >
Represent the number of hits per key (domain or client IP).
Represent the number of hits or time duration per key (url, domain, or
client IP).
'properties':
'domain_or_ip':
'type': 'integer'
'type': 'number'
'additionalProperties':
'type': 'integer'
'type': 'number'
'StatsConfig':
'type': 'object'
'description': 'Statistics configuration'

View File

@ -184,6 +184,7 @@ run_linter gocognit --over 10\
./internal/next/\
./internal/rdns/\
./internal/schedule/\
./internal/stats/\
./internal/tools/\
./internal/version/\
./internal/whois/\
@ -196,7 +197,6 @@ run_linter gocognit --over 19 ./internal/dnsforward/ ./internal/home/
run_linter gocognit --over 18 ./internal/aghtls/
run_linter gocognit --over 17 ./internal/filtering ./internal/filtering/rewrite/
run_linter gocognit --over 15 ./internal/aghos/ ./internal/dhcpd/
run_linter gocognit --over 14 ./internal/stats/
run_linter gocognit --over 12 ./internal/updater/
run_linter gocognit --over 11 ./internal/aghtest/