Skip to content

Commit 9dd78c7

Browse files
committed
Merge branch 'master' into AGDNS-3568-imp-fswatcher
2 parents b0d84ea + b7c9719 commit 9dd78c7

File tree

7 files changed

+226
-45
lines changed

7 files changed

+226
-45
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
2020

2121
### Added
2222

23+
- New query parameter `recent` in `GET /control/stats/` defines statistics lookback period in millieseconds. See `openapi/openapi.yaml` for details.
24+
2325
- New field `"ignored_enabled"` in `GetStatsConfigResponse` or `GetQueryLogConfigResponse`. See `openapi/openapi.yaml` for details.
2426

2527
### Changed

internal/stats/http.go

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,25 @@ package stats
44

55
import (
66
"encoding/json"
7+
"fmt"
78
"net/http"
9+
"strconv"
810
"time"
911

1012
"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
1113
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
1214
"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
1315
"github.com/AdguardTeam/golibs/timeutil"
16+
"github.com/AdguardTeam/golibs/validate"
1417
)
1518

19+
// queryKeyRecent is the key of the query parameter that contains the lookback
20+
// interval for statistics.
21+
const queryKeyRecent = "recent"
22+
23+
// millisecondsInHour contains number of milliseconds in one hour.
24+
const millisecondsInHour = int64(time.Hour / time.Millisecond)
25+
1626
// topAddrs is an alias for the types of the TopFoo fields of statsResponse.
1727
// The key is either a client's address or a requested address.
1828
type topAddrs = map[string]uint64
@@ -53,17 +63,25 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
5363
ctx := r.Context()
5464
l := s.logger
5565

56-
var (
57-
resp *StatsResp
58-
ok bool
59-
)
66+
var limit time.Duration
6067
func() {
6168
s.confMu.RLock()
6269
defer s.confMu.RUnlock()
6370

64-
resp, ok = s.getData(uint32(s.limit.Hours()))
71+
limit = s.limit
6572
}()
6673

74+
recent := r.URL.Query().Get(queryKeyRecent)
75+
76+
limit, err := parseRecent(recent, limit)
77+
if err != nil {
78+
aghhttp.ErrorAndLog(ctx, l, r, w, http.StatusBadRequest, "%s", err)
79+
80+
return
81+
}
82+
83+
resp, ok := s.getData(uint32(limit.Hours()))
84+
6785
l.DebugContext(ctx, "prepared data", "elapsed", time.Since(start))
6886

6987
if !ok {
@@ -78,6 +96,31 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
7896
aghhttp.WriteJSONResponseOK(ctx, l, w, r, resp)
7997
}
8098

99+
// parseRecent parses and validates the value of the recent URL parameter. If
100+
// the parameter is empty, the original limit is returned.
101+
func parseRecent(recent string, limit time.Duration) (parsedLimit time.Duration, err error) {
102+
if recent == "" {
103+
return limit, nil
104+
}
105+
106+
recentMs, err := strconv.ParseInt(recent, 10, 64)
107+
if err != nil {
108+
return 0, fmt.Errorf("%s: parsing interval: %s", queryKeyRecent, err)
109+
}
110+
111+
err = validate.InRange(queryKeyRecent, recentMs, millisecondsInHour, limit.Milliseconds())
112+
if err != nil {
113+
// Don't wrap the error since it's already informative enough as is.
114+
return 0, err
115+
}
116+
117+
if recentMs%millisecondsInHour != 0 {
118+
return 0, fmt.Errorf("%s: must be a multiple of 1 hour", queryKeyRecent)
119+
}
120+
121+
return time.Duration(recentMs) * time.Millisecond, nil
122+
}
123+
81124
// configResp is the response to the GET /control/stats_info.
82125
type configResp struct {
83126
IntervalDays uint32 `json:"interval"`

internal/stats/http_internal_test.go

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,33 @@ package stats
33
import (
44
"bytes"
55
"encoding/json"
6+
"fmt"
67
"net/http"
78
"net/http/httptest"
8-
"path/filepath"
99
"testing"
1010
"time"
1111

12-
"github.com/AdguardTeam/AdGuardHome/internal/agh"
1312
"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
14-
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
15-
"github.com/AdguardTeam/golibs/logutil/slogutil"
13+
"github.com/AdguardTeam/golibs/netutil"
1614
"github.com/AdguardTeam/golibs/testutil"
1715
"github.com/AdguardTeam/golibs/timeutil"
1816
"github.com/stretchr/testify/assert"
1917
"github.com/stretchr/testify/require"
2018
)
2119

20+
// Common domain values for tests.
21+
const (
22+
TestDomain1 = "example.com"
23+
TestDomain2 = "example.org"
24+
)
25+
2226
func TestHandleStatsConfig(t *testing.T) {
2327
const (
2428
smallIvl = 1 * time.Minute
2529
minIvl = 1 * time.Hour
2630
maxIvl = 365 * timeutil.Day
2731
)
2832

29-
conf := Config{
30-
Logger: slogutil.NewDiscardLogger(),
31-
UnitID: func() (id uint32) { return 0 },
32-
ConfigModifier: agh.EmptyConfigModifier{},
33-
ShouldCountClient: func([]string) bool { return true },
34-
HTTPReg: aghhttp.EmptyRegistrar{},
35-
Filename: filepath.Join(t.TempDir(), "stats.db"),
36-
Limit: time.Hour * 24,
37-
Enabled: true,
38-
}
39-
4033
testCases := []struct {
4134
name string
4235
wantErr string
@@ -98,8 +91,7 @@ func TestHandleStatsConfig(t *testing.T) {
9891

9992
for _, tc := range testCases {
10093
t.Run(tc.name, func(t *testing.T) {
101-
s, err := New(conf)
102-
require.NoError(t, err)
94+
s := newTestStatsCtx(t, Config{Enabled: true})
10395

10496
s.Start()
10597
testutil.CleanupAndRequireSuccess(t, s.Close)
@@ -138,3 +130,112 @@ func TestHandleStatsConfig(t *testing.T) {
138130
})
139131
}
140132
}
133+
134+
// populateTestData is a helper that creates test entries in db. s must not be
135+
// nil.
136+
func populateTestData(tb testing.TB, s *StatsCtx) {
137+
tb.Helper()
138+
139+
oldUnitID := newUnitID() - 1
140+
oldUnit := &unitDB{
141+
NResult: make([]uint64, resultLast),
142+
Domains: []countPair{{Name: TestDomain1, Count: 1}},
143+
NTotal: 1,
144+
}
145+
146+
db := s.db.Load()
147+
tx, err := db.Begin(true)
148+
require.NoError(tb, err)
149+
150+
err = s.flushUnitToDB(oldUnit, tx, uint32(oldUnitID))
151+
require.NoError(tb, err)
152+
153+
err = finishTxn(tx, true)
154+
require.NoError(tb, err)
155+
156+
s.Update(&Entry{
157+
Client: netutil.IPv4Localhost().String(),
158+
Domain: TestDomain2,
159+
ProcessingTime: 3 * time.Minute,
160+
Result: RNotFiltered,
161+
})
162+
}
163+
164+
func TestStatsCtx_handleStats(t *testing.T) {
165+
testCases := []struct {
166+
name string
167+
wantErr string
168+
wantTopQueriedDomains []topAddrs
169+
wantDNSQueries uint64
170+
wantCode int
171+
recent int64
172+
}{{
173+
name: "short_interval",
174+
wantErr: "recent: out of range: must be no less than 3600000, got 240000\n",
175+
wantCode: http.StatusBadRequest,
176+
recent: 4 * time.Minute.Milliseconds(),
177+
}, {
178+
name: "long_interval",
179+
wantErr: "recent: out of range: must be no greater than 86400000, got 259200000\n",
180+
wantCode: http.StatusBadRequest,
181+
recent: 72 * time.Hour.Milliseconds(),
182+
}, {
183+
name: "interval_is_not_multiple_of_hour",
184+
wantErr: "recent: must be a multiple of 1 hour\n",
185+
wantCode: http.StatusBadRequest,
186+
recent: time.Hour.Milliseconds() + 1,
187+
}, {
188+
name: "no_interval",
189+
wantCode: http.StatusOK,
190+
wantDNSQueries: 2,
191+
wantTopQueriedDomains: []topAddrs{{
192+
TestDomain1: 1,
193+
}, {
194+
TestDomain2: 1,
195+
}},
196+
}, {
197+
name: "valid_interval",
198+
wantCode: http.StatusOK,
199+
wantDNSQueries: 1,
200+
wantTopQueriedDomains: []topAddrs{{
201+
TestDomain2: 1,
202+
}},
203+
recent: time.Hour.Milliseconds(),
204+
}}
205+
206+
s := newTestStatsCtx(t, Config{
207+
Enabled: true,
208+
})
209+
210+
s.Start()
211+
defer testutil.CleanupAndRequireSuccess(t, s.Close)
212+
213+
populateTestData(t, s)
214+
for _, tc := range testCases {
215+
t.Run(tc.name, func(t *testing.T) {
216+
url := "/control/stats"
217+
if tc.recent != 0 {
218+
url += fmt.Sprintf("?recent=%d", tc.recent)
219+
}
220+
221+
req := httptest.NewRequest(http.MethodGet, url, nil)
222+
rw := httptest.NewRecorder()
223+
224+
s.handleStats(rw, req)
225+
require.Equal(t, tc.wantCode, rw.Code)
226+
227+
if rw.Code != http.StatusOK {
228+
require.Equal(t, tc.wantErr, rw.Body.String())
229+
230+
return
231+
}
232+
233+
ans := StatsResp{}
234+
err := json.Unmarshal(rw.Body.Bytes(), &ans)
235+
require.NoError(t, err)
236+
237+
assert.Equal(t, tc.wantDNSQueries, ans.NumDNSQueries)
238+
assert.ElementsMatch(t, tc.wantTopQueriedDomains, ans.TopQueried)
239+
})
240+
}
241+
}

internal/stats/stats.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ type Config struct {
7272
Ignored *aghnet.IgnoreEngine
7373

7474
// Filename is the name of the database file.
75+
//
76+
// TODO(f.setrakov): Move the work with DB into a separate entity with
77+
// interface.
7578
Filename string
7679

7780
// Limit is an upper limit for collecting statistics.

internal/stats/stats_internal_test.go

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package stats
22

33
import (
4+
"cmp"
45
"fmt"
56
"path/filepath"
67
"sync"
78
"sync/atomic"
89
"testing"
910
"time"
1011

12+
"github.com/AdguardTeam/AdGuardHome/internal/agh"
1113
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
1214
"github.com/AdguardTeam/golibs/logutil/slogutil"
1315
"github.com/AdguardTeam/golibs/testutil"
@@ -16,20 +18,39 @@ import (
1618
"github.com/stretchr/testify/require"
1719
)
1820

21+
// testLogger is the common logger for tests.
22+
var testLogger = slogutil.NewDiscardLogger()
23+
24+
// newTestStatsCtx returns StatsCtx initialised with given values. All empty
25+
// values from c will be replaced with defaults.
26+
func newTestStatsCtx(tb testing.TB, c Config) (s *StatsCtx) {
27+
c.Logger = cmp.Or(c.Logger, testLogger)
28+
c.ConfigModifier = cmp.Or[agh.ConfigModifier](c.ConfigModifier, agh.EmptyConfigModifier{})
29+
c.HTTPReg = cmp.Or[aghhttp.Registrar](c.HTTPReg, aghhttp.EmptyRegistrar{})
30+
c.Filename = cmp.Or(c.Filename, filepath.Join(tb.TempDir(), "./stats.db"))
31+
c.Limit = cmp.Or(c.Limit, timeutil.Day)
32+
if c.ShouldCountClient == nil {
33+
c.ShouldCountClient = func([]string) bool { return true }
34+
}
35+
36+
if c.UnitID == nil {
37+
c.UnitID = newUnitID
38+
}
39+
40+
var err error
41+
s, err = New(c)
42+
require.NoError(tb, err)
43+
44+
return s
45+
}
46+
1947
func TestStats_races(t *testing.T) {
2048
var r uint32
2149
idGen := func() (id uint32) { return atomic.LoadUint32(&r) }
22-
conf := Config{
23-
Logger: slogutil.NewDiscardLogger(),
24-
ShouldCountClient: func([]string) bool { return true },
25-
HTTPReg: aghhttp.EmptyRegistrar{},
26-
UnitID: idGen,
27-
Filename: filepath.Join(t.TempDir(), "./stats.db"),
28-
Limit: timeutil.Day,
29-
}
30-
31-
s, err := New(conf)
32-
require.NoError(t, err)
50+
s := newTestStatsCtx(t, Config{
51+
UnitID: idGen,
52+
Enabled: true,
53+
})
3354

3455
s.Start()
3556
startTime := time.Now()
@@ -97,13 +118,10 @@ func TestStatsCtx_FillCollectedStats_daily(t *testing.T) {
97118
timeUnits = "days"
98119
)
99120

100-
s, err := New(Config{
101-
Logger: slogutil.NewDiscardLogger(),
102-
ShouldCountClient: func([]string) bool { return true },
103-
Filename: filepath.Join(t.TempDir(), "./stats.db"),
104-
Limit: time.Hour,
121+
s := newTestStatsCtx(t, Config{
122+
Limit: time.Hour,
123+
Enabled: true,
105124
})
106-
require.NoError(t, err)
107125

108126
testutil.CleanupAndRequireSuccess(t, s.Close)
109127

@@ -155,13 +173,10 @@ func TestStatsCtx_FillCollectedStats_daily(t *testing.T) {
155173
func TestStatsCtx_DataFromUnits_month(t *testing.T) {
156174
const hoursInMonth = 720
157175

158-
s, err := New(Config{
159-
Logger: slogutil.NewDiscardLogger(),
160-
ShouldCountClient: func([]string) bool { return true },
161-
Filename: filepath.Join(t.TempDir(), "./stats.db"),
162-
Limit: time.Hour,
176+
s := newTestStatsCtx(t, Config{
177+
Limit: time.Hour,
178+
Enabled: true,
163179
})
164-
require.NoError(t, err)
165180

166181
testutil.CleanupAndRequireSuccess(t, s.Close)
167182

openapi/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
## v0.107.72: API changes
66

7+
## New `recent` query parameter in 'GET /control/stats/'
8+
9+
- New query parameter `recent` defines the statistics lookback period in millieseconds.
10+
711
### New `ignored_enabled` field in `GetStatsConfigResponse` and `GetQueryLogConfigResponse`
812

913
- The new field `ignored_enabled` indicates whether the host names in the ignored array should be ignored. This field has been added for the following endpoints:

0 commit comments

Comments
 (0)