Skip to content

Commit 5aba8e8

Browse files
committed
all: impl stats api intervals
1 parent 81fcaf3 commit 5aba8e8

6 files changed

Lines changed: 182 additions & 3 deletions

File tree

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
#### Configuration changes

internal/stats/http.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ package stats
55
import (
66
"encoding/json"
77
"net/http"
8+
"strconv"
89
"time"
910

1011
"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
1112
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
1213
"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
1314
"github.com/AdguardTeam/golibs/timeutil"
15+
"github.com/AdguardTeam/golibs/validate"
1416
)
1517

18+
// queryKeyRecent is the key of the query parameter that contains the lookback
19+
// interval for statistics.
20+
const queryKeyRecent = "recent"
21+
1622
// topAddrs is an alias for the types of the TopFoo fields of statsResponse.
1723
// The key is either a client's address or a requested address.
1824
type topAddrs = map[string]uint64
@@ -53,6 +59,32 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
5359
ctx := r.Context()
5460
l := s.logger
5561

62+
limit := s.limit
63+
64+
recent := r.URL.Query().Get(queryKeyRecent)
65+
if recent != "" {
66+
recentMs, err := strconv.ParseInt(recent, 10, 64)
67+
if err != nil {
68+
aghhttp.ErrorAndLog(ctx, l, r, w, http.StatusBadRequest, "parsing interval: %s", err)
69+
70+
return
71+
}
72+
73+
err = validate.InRange(
74+
queryKeyRecent,
75+
recentMs,
76+
time.Hour.Milliseconds(),
77+
limit.Milliseconds(),
78+
)
79+
if err != nil {
80+
aghhttp.ErrorAndLog(ctx, l, r, w, http.StatusBadRequest, "%s", err)
81+
82+
return
83+
}
84+
85+
limit = time.Duration(recentMs) * time.Millisecond
86+
}
87+
5688
var (
5789
resp *StatsResp
5890
ok bool
@@ -61,7 +93,7 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
6193
s.confMu.RLock()
6294
defer s.confMu.RUnlock()
6395

64-
resp, ok = s.getData(uint32(s.limit.Hours()))
96+
resp, ok = s.getData(uint32(limit.Hours()))
6597
}()
6698

6799
l.DebugContext(ctx, "prepared data", "elapsed", time.Since(start))

internal/stats/http_internal_test.go

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package stats
33
import (
44
"bytes"
55
"encoding/json"
6+
"fmt"
67
"net/http"
78
"net/http/httptest"
89
"path/filepath"
@@ -13,12 +14,22 @@ import (
1314
"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
1415
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
1516
"github.com/AdguardTeam/golibs/logutil/slogutil"
17+
"github.com/AdguardTeam/golibs/netutil"
1618
"github.com/AdguardTeam/golibs/testutil"
1719
"github.com/AdguardTeam/golibs/timeutil"
1820
"github.com/stretchr/testify/assert"
1921
"github.com/stretchr/testify/require"
2022
)
2123

24+
// testLogger is the common logger for tests.
25+
var testLogger = slogutil.NewDiscardLogger()
26+
27+
// Common domain values for tests.
28+
const (
29+
TestDomain1 = "example.com"
30+
TestDomain2 = "example.org"
31+
)
32+
2233
func TestHandleStatsConfig(t *testing.T) {
2334
const (
2435
smallIvl = 1 * time.Minute
@@ -27,13 +38,13 @@ func TestHandleStatsConfig(t *testing.T) {
2738
)
2839

2940
conf := Config{
30-
Logger: slogutil.NewDiscardLogger(),
41+
Logger: testLogger,
3142
UnitID: func() (id uint32) { return 0 },
3243
ConfigModifier: agh.EmptyConfigModifier{},
3344
ShouldCountClient: func([]string) bool { return true },
3445
HTTPReg: aghhttp.EmptyRegistrar{},
3546
Filename: filepath.Join(t.TempDir(), "stats.db"),
36-
Limit: time.Hour * 24,
47+
Limit: 24 * time.Hour,
3748
Enabled: true,
3849
}
3950

@@ -138,3 +149,117 @@ func TestHandleStatsConfig(t *testing.T) {
138149
})
139150
}
140151
}
152+
153+
// populateTestData is a helper that creates test entries in db. s must not be
154+
// nil.
155+
func populateTestData(tb testing.TB, s *StatsCtx) {
156+
tb.Helper()
157+
158+
oldUnitID := newUnitID() - 1
159+
oldUnit := &unitDB{
160+
NResult: make([]uint64, resultLast),
161+
Domains: []countPair{{Name: TestDomain1, Count: 1}},
162+
NTotal: 1,
163+
}
164+
165+
db := s.db.Load()
166+
tx, err := db.Begin(true)
167+
require.NoError(tb, err)
168+
169+
err = s.flushUnitToDB(oldUnit, tx, uint32(oldUnitID))
170+
require.NoError(tb, err)
171+
172+
err = finishTxn(tx, true)
173+
require.NoError(tb, err)
174+
175+
s.Update(&Entry{
176+
Client: netutil.IPv4Localhost().String(),
177+
Domain: TestDomain2,
178+
ProcessingTime: 3 * time.Minute,
179+
Result: RNotFiltered,
180+
})
181+
}
182+
183+
func TestStatsCtx_handleStats(t *testing.T) {
184+
conf := Config{
185+
Logger: testLogger,
186+
UnitID: newUnitID,
187+
ConfigModifier: agh.EmptyConfigModifier{},
188+
ShouldCountClient: func([]string) bool { return true },
189+
HTTPReg: aghhttp.EmptyRegistrar{},
190+
Filename: filepath.Join(t.TempDir(), "stats.db"),
191+
Limit: 24 * time.Hour,
192+
Enabled: true,
193+
}
194+
195+
testCases := []struct {
196+
name string
197+
wantErr string
198+
wantTopQueriedDomains []topAddrs
199+
wantDNSQueries uint64
200+
wantCode int
201+
recent int64
202+
}{{
203+
name: "short_interval",
204+
wantErr: "recent: out of range: must be no less than 3600000, got 240000\n",
205+
wantCode: http.StatusBadRequest,
206+
recent: 4 * time.Minute.Milliseconds(),
207+
}, {
208+
name: "long_interval",
209+
wantErr: "recent: out of range: must be no greater than 86400000, got 259200000\n",
210+
wantCode: http.StatusBadRequest,
211+
recent: 72 * time.Hour.Milliseconds(),
212+
}, {
213+
name: "no_interval",
214+
wantCode: http.StatusOK,
215+
wantDNSQueries: 2,
216+
wantTopQueriedDomains: []topAddrs{{
217+
TestDomain1: 1,
218+
}, {
219+
TestDomain2: 1,
220+
}},
221+
}, {
222+
name: "valid_interval",
223+
wantCode: http.StatusOK,
224+
wantDNSQueries: 1,
225+
wantTopQueriedDomains: []topAddrs{{
226+
TestDomain2: 1,
227+
}},
228+
recent: time.Hour.Milliseconds(),
229+
}}
230+
231+
s, err := New(conf)
232+
require.NoError(t, err)
233+
234+
s.Start()
235+
defer testutil.CleanupAndRequireSuccess(t, s.Close)
236+
237+
populateTestData(t, s)
238+
for _, tc := range testCases {
239+
t.Run(tc.name, func(t *testing.T) {
240+
url := "/control/stats"
241+
if tc.recent != 0 {
242+
url += fmt.Sprintf("?recent=%d", tc.recent)
243+
}
244+
245+
req := httptest.NewRequest(http.MethodGet, url, nil)
246+
rw := httptest.NewRecorder()
247+
248+
s.handleStats(rw, req)
249+
require.Equal(t, tc.wantCode, rw.Code)
250+
251+
if rw.Code != http.StatusOK {
252+
require.Equal(t, tc.wantErr, rw.Body.String())
253+
254+
return
255+
}
256+
257+
ans := StatsResp{}
258+
err = json.Unmarshal(rw.Body.Bytes(), &ans)
259+
require.NoError(t, err)
260+
261+
assert.Equal(t, tc.wantDNSQueries, ans.NumDNSQueries)
262+
assert.ElementsMatch(t, tc.wantTopQueriedDomains, ans.TopQueried)
263+
})
264+
}
265+
}

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.

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:

openapi/openapi.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,26 @@
323323
- 'stats'
324324
'operationId': 'stats'
325325
'summary': 'Get DNS server statistics'
326+
'parameters':
327+
- 'name': 'recent'
328+
'in': 'query'
329+
'description': |
330+
The lookback period for statistics in milliseconds. The interval must
331+
be a multiple of one hour and must not be greater than the value of
332+
`statistics.interval`.
333+
'required': false
334+
'example': 604800000
335+
'schema':
336+
'type': 'integer'
326337
'responses':
327338
'200':
328339
'description': 'Returns statistics data'
329340
'content':
330341
'application/json':
331342
'schema':
332343
'$ref': '#/components/schemas/Stats'
344+
'400':
345+
'description': 'Invalid value of parameter `recent`'
333346
'/stats_reset':
334347
'post':
335348
'tags':

0 commit comments

Comments
 (0)