Skip to content

Commit 0295a13

Browse files
mergify[bot]gpop63
andauthored
[cisco_meraki_metrics] Add pagination support for device_health metricset (#46938) (#47149)
* add pagination logic to device_health metricset * add tests * fix imports * fix imports * improve debug logs * add nil check in getDevices * add changelog entry (cherry picked from commit 9db5eeb) Co-authored-by: Gabriel Pop <[email protected]>
1 parent d94b563 commit 0295a13

File tree

7 files changed

+419
-126
lines changed

7 files changed

+419
-126
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# REQUIRED
2+
# Kind can be one of:
3+
# - breaking-change: a change to previously-documented behavior
4+
# - deprecation: functionality that is being removed in a later release
5+
# - bug-fix: fixes a problem in a previous version
6+
# - enhancement: extends functionality but does not break or fix existing behavior
7+
# - feature: new functionality
8+
# - known-issue: problems that we are aware of in a given version
9+
# - security: impacts on the security of a product or a user’s deployment.
10+
# - upgrade: important information for someone upgrading from a prior version
11+
# - other: does not fit into any of the other categories
12+
kind: bug-fix
13+
14+
# REQUIRED for all kinds
15+
# Change summary; a 80ish characters long description of the change.
16+
summary: Add pagination support to the device health metricset in the meraki module
17+
18+
# REQUIRED for breaking-change, deprecation, known-issue
19+
# Long description; in case the summary is not enough to describe the change
20+
# this field accommodate a description without length limits.
21+
# description:
22+
23+
# REQUIRED for breaking-change, deprecation, known-issue
24+
# impact:
25+
26+
# REQUIRED for breaking-change, deprecation, known-issue
27+
# action:
28+
29+
# REQUIRED for all kinds
30+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
31+
component: "metricbeat"
32+
33+
# AUTOMATED
34+
# OPTIONAL to manually add other PR URLs
35+
# PR URL: A link the PR that added the changeset.
36+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
37+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
38+
# Please provide it if you are adding a fragment for a different PR.
39+
# pr: https://github.com/owner/repo/1234
40+
41+
# AUTOMATED
42+
# OPTIONAL to manually add other issue URLs
43+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
44+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
45+
# issue: https://github.com/owner/repo/1234

x-pack/metricbeat/module/meraki/device_health/device_health.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,19 @@ func (m *MetricSet) Fetch(reporter mb.ReporterV2) error {
6464
// First we get the list of all devices for this org (and their metadata).
6565
// Devices are uniquely identified by their serial number, which are used to
6666
// associate the metrics we collect later with the devices returned here.
67-
devices, err := getDevices(m.client, org)
67+
68+
organizationsService := &OrganizationsServiceWrapper{
69+
service: m.client.Organizations,
70+
}
71+
72+
devices, err := getDevices(organizationsService, org, m.logger)
6873
if err != nil {
6974
return fmt.Errorf("getDevices failed; %w", err)
7075
}
7176

7277
// Now we continue to populate the device data structure with health
7378
// attributes/statuses/metrics etc in the following functions...
74-
err = getDeviceStatuses(m.client, org, devices)
79+
err = getDeviceStatuses(m.client, org, devices, m.logger)
7580
if err != nil {
7681
return fmt.Errorf("getDeviceStatuses failed; %w", err)
7782
}
@@ -87,17 +92,17 @@ func (m *MetricSet) Fetch(reporter mb.ReporterV2) error {
8792
return fmt.Errorf("getDeviceChannelUtilization failed; %w", err)
8893
}
8994

90-
err = getDeviceLicenses(m.client, org, devices)
95+
err = getDeviceLicenses(m.client, org, devices, m.logger)
9196
if err != nil {
9297
return fmt.Errorf("getDeviceLicenses failed; %w", err)
9398
}
9499

95-
err = getDeviceUplinks(m.client, org, devices, collectionPeriod)
100+
err = getDeviceUplinks(m.client, org, devices, collectionPeriod, m.logger)
96101
if err != nil {
97102
return fmt.Errorf("getDeviceUplinks failed; %w", err)
98103
}
99104

100-
err = getDeviceSwitchports(m.client, org, devices, collectionPeriod)
105+
err = getDeviceSwitchports(m.client, org, devices, collectionPeriod, m.logger)
101106
if err != nil {
102107
return fmt.Errorf("getDeviceSwitchports failed; %w", err)
103108
}

x-pack/metricbeat/module/meraki/device_health/device_health_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
meraki "github.com/meraki/dashboard-api-go/v3/sdk"
1818
"github.com/stretchr/testify/assert"
1919
"github.com/stretchr/testify/require"
20+
21+
"github.com/elastic/elastic-agent-libs/logp"
2022
)
2123

2224
func TestGetDeviceChannelUtilization(t *testing.T) {
@@ -170,3 +172,99 @@ func (m *GenericErrorMockNetworkHealthService) GetOrganizationWirelessDevicesCha
170172
}
171173
return r, fmt.Errorf("mock API error")
172174
}
175+
176+
func TestGetDevices_Pagination(t *testing.T) {
177+
logger := logp.NewLogger("test")
178+
179+
tests := []struct {
180+
name string
181+
client OrganizationsClient
182+
expectedDevices int
183+
expectedCalls int
184+
wantErr bool
185+
}{
186+
{
187+
name: "single page",
188+
client: newMockOrganizationsClient(1, 2), // 1 page, 2 devices
189+
expectedDevices: 2,
190+
expectedCalls: 1,
191+
},
192+
{
193+
name: "multiple pages",
194+
client: newMockOrganizationsClient(3, 2), // 3 pages, 2 devices per page
195+
expectedDevices: 6,
196+
expectedCalls: 3,
197+
},
198+
{
199+
name: "max pages limit",
200+
client: newMockOrganizationsClient(101, 1), // 101 pages (exceeds MAX_PAGES)
201+
expectedDevices: 100, // Should stop at MAX_PAGES
202+
expectedCalls: 100,
203+
},
204+
}
205+
206+
for _, tt := range tests {
207+
t.Run(tt.name, func(t *testing.T) {
208+
devices, err := getDevices(tt.client, "org123", logger)
209+
210+
if tt.wantErr {
211+
assert.Error(t, err)
212+
return
213+
}
214+
215+
require.NoError(t, err)
216+
assert.Equal(t, tt.expectedDevices, len(devices))
217+
218+
// Verify the mock was called the expected number of times
219+
if mock, ok := tt.client.(*mockOrganizationsClient); ok {
220+
assert.Equal(t, tt.expectedCalls, mock.callCount)
221+
}
222+
})
223+
}
224+
}
225+
226+
// Mock implementation for pagination testing
227+
type mockOrganizationsClient struct {
228+
totalPages int
229+
devicesPerPage int
230+
callCount int
231+
}
232+
233+
func newMockOrganizationsClient(totalPages, devicesPerPage int) *mockOrganizationsClient {
234+
return &mockOrganizationsClient{
235+
totalPages: totalPages,
236+
devicesPerPage: devicesPerPage,
237+
}
238+
}
239+
240+
func (m *mockOrganizationsClient) GetOrganizationDevices(organizationID string, params *meraki.GetOrganizationDevicesQueryParams) (*meraki.ResponseOrganizationsGetOrganizationDevices, *resty.Response, error) {
241+
m.callCount++
242+
243+
devices := make(meraki.ResponseOrganizationsGetOrganizationDevices, 0, m.devicesPerPage)
244+
for i := 0; i < m.devicesPerPage; i++ {
245+
serial := fmt.Sprintf("SERIAL-%d-%d", m.callCount, i)
246+
devices = append(devices, meraki.ResponseItemOrganizationsGetOrganizationDevices{
247+
Serial: serial,
248+
Name: fmt.Sprintf("Device %s", serial),
249+
})
250+
}
251+
252+
resp := &resty.Response{}
253+
bodyBytes, _ := json.Marshal(devices)
254+
resp.SetBody(bodyBytes)
255+
256+
headers := http.Header{}
257+
if m.callCount < m.totalPages {
258+
nextSerial := fmt.Sprintf("SERIAL-%d-%d", m.callCount, m.devicesPerPage-1)
259+
linkHeader := fmt.Sprintf(`<https://api.meraki.com/api/v1/organizations/%s/devices?startingAfter=%s>; rel="next"`, organizationID, nextSerial)
260+
headers.Set("Link", linkHeader)
261+
}
262+
263+
resp.RawResponse = &http.Response{
264+
StatusCode: 200,
265+
Body: io.NopCloser(bytes.NewBuffer(bodyBytes)),
266+
Header: headers,
267+
}
268+
269+
return &devices, resp, nil
270+
}

x-pack/metricbeat/module/meraki/device_health/devices.go

Lines changed: 104 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,49 +38,102 @@ type Device struct {
3838
switchports []*switchport
3939
}
4040

41-
func getDevices(client *sdk.Client, organizationID string) (map[Serial]*Device, error) {
42-
val, res, err := client.Organizations.GetOrganizationDevices(organizationID, &sdk.GetOrganizationDevicesQueryParams{})
41+
type OrganizationsClient interface {
42+
GetOrganizationDevices(organizationID string, params *sdk.GetOrganizationDevicesQueryParams) (*sdk.ResponseOrganizationsGetOrganizationDevices, *resty.Response, error)
43+
}
44+
45+
var _ OrganizationsClient = (*sdk.OrganizationsService)(nil)
46+
47+
type OrganizationsServiceWrapper struct {
48+
service *sdk.OrganizationsService
49+
}
50+
51+
func (w *OrganizationsServiceWrapper) GetOrganizationDevices(organizationID string, params *sdk.GetOrganizationDevicesQueryParams) (*sdk.ResponseOrganizationsGetOrganizationDevices, *resty.Response, error) {
52+
return w.service.GetOrganizationDevices(organizationID, params)
53+
}
54+
55+
func getDevices(client OrganizationsClient, organizationID string, logger *logp.Logger) (map[Serial]*Device, error) {
56+
devices := make(map[Serial]*Device)
4357

44-
if err != nil {
58+
params := &sdk.GetOrganizationDevicesQueryParams{}
59+
setStart := func(s string) { params.StartingAfter = s }
60+
61+
doRequest := func() (*sdk.ResponseOrganizationsGetOrganizationDevices, *resty.Response, error) {
62+
logger.Debugf("calling GetOrganizationDevices with params: %+v", params)
63+
return client.GetOrganizationDevices(organizationID, params)
64+
}
65+
66+
onError := func(err error, res *resty.Response) error {
4567
if res != nil {
46-
return nil, fmt.Errorf("GetOrganizationDevices failed; [%d] %s. %w", res.StatusCode(), res.Body(), err)
68+
return fmt.Errorf("GetOrganizationDevices failed; [%d] %s. %w", res.StatusCode(), res.Body(), err)
4769
}
48-
return nil, fmt.Errorf("GetOrganizationDevices failed; %w", err)
70+
return fmt.Errorf("GetOrganizationDevices failed; %w", err)
4971
}
5072

51-
devices := make(map[Serial]*Device)
52-
for i := range *val {
53-
device := (*val)[i]
54-
devices[Serial(device.Serial)] = &Device{
55-
details: &device,
73+
onSuccess := func(val *sdk.ResponseOrganizationsGetOrganizationDevices) error {
74+
if val == nil {
75+
return errors.New("GetOrganizationDevices returned nil response")
5676
}
77+
78+
for i := range *val {
79+
device := (*val)[i]
80+
devices[Serial(device.Serial)] = &Device{
81+
details: &device,
82+
}
83+
}
84+
return nil
5785
}
5886

59-
return devices, nil
87+
err := meraki.NewPaginator(
88+
setStart,
89+
doRequest,
90+
onError,
91+
onSuccess,
92+
logger,
93+
).GetAllPages()
94+
95+
return devices, err
6096
}
6197

62-
func getDeviceStatuses(client *sdk.Client, organizationID string, devices map[Serial]*Device) error {
63-
val, res, err := client.Organizations.GetOrganizationDevicesStatuses(organizationID, &sdk.GetOrganizationDevicesStatusesQueryParams{})
98+
func getDeviceStatuses(client *sdk.Client, organizationID string, devices map[Serial]*Device, logger *logp.Logger) error {
99+
params := &sdk.GetOrganizationDevicesStatusesQueryParams{}
100+
setStart := func(s string) { params.StartingAfter = s }
64101

65-
if err != nil {
102+
doRequest := func() (*sdk.ResponseOrganizationsGetOrganizationDevicesStatuses, *resty.Response, error) {
103+
logger.Debugf("calling GetOrganizationDevicesStatuses with params: %+v", params)
104+
return client.Organizations.GetOrganizationDevicesStatuses(organizationID, params)
105+
}
106+
107+
onError := func(err error, res *resty.Response) error {
66108
if res != nil {
67109
return fmt.Errorf("GetOrganizationDevicesStatuses failed; [%d] %s. %w", res.StatusCode(), res.Body(), err)
68110
}
69111
return fmt.Errorf("GetOrganizationDevicesStatuses failed; %w", err)
70112
}
71113

72-
if val == nil {
73-
return errors.New("GetOrganizationDevicesStatuses returned nil response")
74-
}
114+
onSuccess := func(val *sdk.ResponseOrganizationsGetOrganizationDevicesStatuses) error {
115+
if val == nil {
116+
return errors.New("GetOrganizationDevicesStatuses returned nil response")
117+
}
75118

76-
for i := range *val {
77-
status := (*val)[i]
78-
if device, ok := devices[Serial(status.Serial)]; ok {
79-
device.status = &status
119+
for i := range *val {
120+
status := (*val)[i]
121+
if device, ok := devices[Serial(status.Serial)]; ok {
122+
device.status = &status
123+
}
80124
}
125+
return nil
81126
}
82127

83-
return nil
128+
err := meraki.NewPaginator(
129+
setStart,
130+
doRequest,
131+
onError,
132+
onSuccess,
133+
logger,
134+
).GetAllPages()
135+
136+
return err
84137
}
85138

86139
func getDevicePerformanceScores(logger *logp.Logger, client *sdk.Client, devices map[Serial]*Device) {
@@ -186,9 +239,16 @@ func getDeviceChannelUtilization(client DeviceService, devices map[Serial]*Devic
186239
return nil
187240
}
188241

189-
func getDeviceLicenses(client *sdk.Client, organizationID string, devices map[Serial]*Device) error {
190-
val, res, err := client.Organizations.GetOrganizationLicenses(organizationID, &sdk.GetOrganizationLicensesQueryParams{})
191-
if err != nil {
242+
func getDeviceLicenses(client *sdk.Client, organizationID string, devices map[Serial]*Device, logger *logp.Logger) error {
243+
params := &sdk.GetOrganizationLicensesQueryParams{}
244+
setStart := func(s string) { params.StartingAfter = s }
245+
246+
doRequest := func() (*sdk.ResponseOrganizationsGetOrganizationLicenses, *resty.Response, error) {
247+
logger.Debugf("calling GetOrganizationLicenses with params: %+v", params)
248+
return client.Organizations.GetOrganizationLicenses(organizationID, params)
249+
}
250+
251+
onError := func(err error, res *resty.Response) error {
192252
// Ignore 400 error for per-device licensing not supported
193253
if res != nil && res.StatusCode() == 400 && strings.Contains(string(res.Body()), "does not support per-device licensing") {
194254
return nil
@@ -204,18 +264,29 @@ func getDeviceLicenses(client *sdk.Client, organizationID string, devices map[Se
204264
return fmt.Errorf("GetOrganizationLicenses failed; %w", err)
205265
}
206266

207-
if val == nil {
208-
return errors.New("GetOrganizationLicenses returned nil response")
209-
}
267+
onSuccess := func(val *sdk.ResponseOrganizationsGetOrganizationLicenses) error {
268+
if val == nil {
269+
return errors.New("GetOrganizationLicenses returned nil response")
270+
}
210271

211-
for i := range *val {
212-
license := (*val)[i]
213-
if device, ok := devices[Serial(license.DeviceSerial)]; ok {
214-
device.license = &license
272+
for i := range *val {
273+
license := (*val)[i]
274+
if device, ok := devices[Serial(license.DeviceSerial)]; ok {
275+
device.license = &license
276+
}
215277
}
278+
return nil
216279
}
217280

218-
return nil
281+
err := meraki.NewPaginator(
282+
setStart,
283+
doRequest,
284+
onError,
285+
onSuccess,
286+
logger,
287+
).GetAllPages()
288+
289+
return err
219290
}
220291

221292
func deviceDetailsToMapstr(details *sdk.ResponseItemOrganizationsGetOrganizationDevices) mapstr.M {

0 commit comments

Comments
 (0)