Skip to content

Commit 56ab6e3

Browse files
authored
CloudWatch Logs: Limit CloudWatch logs queries to use logGroupIdentifiers only for monitoring accounts (#273)
1 parent 19809fc commit 56ab6e3

File tree

6 files changed

+240
-32
lines changed

6 files changed

+240
-32
lines changed

pkg/cloudwatch/cloudwatch.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"slices"
9+
"sync"
910
"time"
1011

1112
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
@@ -55,9 +56,10 @@ type DataSource struct {
5556
ProxyOpts *proxy.Options
5657
AWSConfigProvider awsauth.ConfigProvider
5758

58-
logger log.Logger
59-
tagValueCache *cache.Cache
60-
resourceHandler backend.CallResourceHandler
59+
logger log.Logger
60+
tagValueCache *cache.Cache
61+
resourceHandler backend.CallResourceHandler
62+
monitoringAccountCache sync.Map
6163
}
6264

6365
func (ds *DataSource) newAWSConfig(ctx context.Context, region string) (aws.Config, error) {
@@ -274,6 +276,33 @@ func (ds *DataSource) getRGTAClient(ctx context.Context, region string) (resourc
274276
return NewRGTAClient(cfg), nil
275277
}
276278

279+
func (ds *DataSource) isMonitoringAccount(ctx context.Context, region string) (bool, error) {
280+
if value, ok := ds.monitoringAccountCache.Load(region); ok {
281+
cached := value.(bool)
282+
return cached, nil
283+
}
284+
285+
client, err := ds.GetAccountsService(ctx, region)
286+
if err != nil {
287+
return false, err
288+
}
289+
290+
accounts, err := client.GetAccountsForCurrentUserOrRole(ctx)
291+
if err != nil {
292+
return false, err
293+
}
294+
295+
for _, account := range accounts {
296+
if account.Value.IsMonitoringAccount {
297+
ds.monitoringAccountCache.Store(region, true)
298+
return true, nil
299+
}
300+
}
301+
302+
ds.monitoringAccountCache.Store(region, false)
303+
return false, nil
304+
}
305+
277306
var terminatedStates = []cloudwatchlogstypes.QueryStatus{
278307
cloudwatchlogstypes.QueryStatusComplete,
279308
cloudwatchlogstypes.QueryStatusCancelled,

pkg/cloudwatch/log_actions.go

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -213,17 +213,48 @@ func (ds *DataSource) executeStartQuery(ctx context.Context, logsClient models.C
213213

214214
// log group identifiers can be left out if the query is an SQL query
215215
if *logsQuery.QueryLanguage != dataquery.LogsQueryLanguageSQL {
216-
if len(logsQuery.LogGroups) > 0 && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) {
217-
var logGroupIdentifiers []string
218-
for _, lg := range logsQuery.LogGroups {
219-
arn := lg.Arn
220-
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
221-
logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*"))
216+
useLogGroupIdentifiers := false
217+
logGroupsFromQuery := len(logsQuery.LogGroups) > 0
218+
if logGroupsFromQuery && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) {
219+
region := logsQuery.Region
220+
if region == "" || region == defaultRegion {
221+
region = ds.Settings.Region
222+
}
223+
if region != "" {
224+
isMonitoringAccount, err := ds.isMonitoringAccount(ctx, region)
225+
if err != nil {
226+
ds.logger.FromContext(ctx).Debug("failed to determine monitoring account status", "err", err)
227+
} else if isMonitoringAccount {
228+
// monitoring accounts require querying by log group identifiers because log group names are not unique across accounts.
229+
var logGroupIdentifiers []string
230+
for _, lg := range logsQuery.LogGroups {
231+
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
232+
arn := strings.TrimSuffix(lg.Arn, "*")
233+
logGroupIdentifiers = append(logGroupIdentifiers, arn)
234+
}
235+
startQueryInput.LogGroupIdentifiers = logGroupIdentifiers
236+
useLogGroupIdentifiers = true
237+
}
238+
}
239+
}
240+
241+
if !useLogGroupIdentifiers {
242+
// even though logsQuery.LogGroupNames is deprecated, we still need to support it for backwards compatibility and alert queries
243+
startQueryInput.LogGroupNames = append([]string(nil), logsQuery.LogGroupNames...)
244+
if len(startQueryInput.LogGroupNames) == 0 && logGroupsFromQuery {
245+
// deduplicate log group names because we only deduplicate log groups by their ARNs instead of their names when the query is created
246+
seenLogGroupNames := make(map[string]struct{}, len(logsQuery.LogGroups))
247+
for _, lg := range logsQuery.LogGroups {
248+
if lg.Name == "" {
249+
continue
250+
}
251+
if _, exists := seenLogGroupNames[lg.Name]; exists {
252+
continue
253+
}
254+
seenLogGroupNames[lg.Name] = struct{}{}
255+
startQueryInput.LogGroupNames = append(startQueryInput.LogGroupNames, lg.Name)
256+
}
222257
}
223-
startQueryInput.LogGroupIdentifiers = logGroupIdentifiers
224-
} else {
225-
// even though log group names are being phased out, we still need to support them for backwards compatibility and alert queries
226-
startQueryInput.LogGroupNames = logsQuery.LogGroupNames
227258
}
228259
}
229260

pkg/cloudwatch/log_actions_test.go

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,9 @@ func Test_executeStartQuery(t *testing.T) {
445445

446446
t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled", func(t *testing.T) {
447447
cli = fakeCWLogsClient{}
448-
ds := newTestDatasource()
448+
ds := newTestDatasource(func(ds *DataSource) {
449+
ds.monitoringAccountCache.Store("us-east-1", true)
450+
})
449451

450452
_, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{
451453
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
@@ -459,7 +461,8 @@ func Test_executeStartQuery(t *testing.T) {
459461
"limit": 12,
460462
"queryLanguage": "CWLI",
461463
"queryString":"fields @message",
462-
"logGroups":[{"arn": "fakeARN"}]
464+
"logGroups":[{"arn": "fakeARN"}],
465+
"region": "us-east-1"
463466
}`),
464467
},
465468
},
@@ -480,7 +483,9 @@ func Test_executeStartQuery(t *testing.T) {
480483

481484
t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled and strips out trailing *", func(t *testing.T) {
482485
cli = fakeCWLogsClient{}
483-
ds := newTestDatasource()
486+
ds := newTestDatasource(func(ds *DataSource) {
487+
ds.monitoringAccountCache.Store("us-east-1", true)
488+
})
484489

485490
_, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{
486491
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
@@ -493,7 +498,8 @@ func Test_executeStartQuery(t *testing.T) {
493498
"subtype": "StartQuery",
494499
"limit": 12,
495500
"queryString":"fields @message",
496-
"logGroups":[{"arn": "*fake**ARN*"}]
501+
"logGroups":[{"arn": "*fake**ARN*"}],
502+
"region": "us-east-1"
497503
}`),
498504
},
499505
},
@@ -512,6 +518,44 @@ func Test_executeStartQuery(t *testing.T) {
512518
}, cli.calls.startQuery)
513519
})
514520

521+
t.Run("queries by LogGroupNames on StartQueryInput when queried region is not a monitoring account region for the data source", func(t *testing.T) {
522+
cli = fakeCWLogsClient{}
523+
ds := newTestDatasource(func(ds *DataSource) {
524+
// note that the query's region is set to us-east-2, but the data source is only a monitoring account in us-east-1 so it should query by LogGroupNames
525+
ds.monitoringAccountCache.Store("us-east-1", true)
526+
})
527+
528+
_, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{
529+
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
530+
Queries: []backend.DataQuery{
531+
{
532+
RefID: "A",
533+
TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)},
534+
JSON: json.RawMessage(`{
535+
"type": "logAction",
536+
"subtype": "StartQuery",
537+
"limit": 12,
538+
"queryString":"fields @message",
539+
"logGroups":[{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group","name":"/log-group"}],
540+
"region": "us-east-2"
541+
}`),
542+
},
543+
},
544+
})
545+
546+
assert.NoError(t, err)
547+
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
548+
{
549+
StartTime: aws.Int64(0),
550+
EndTime: aws.Int64(1),
551+
Limit: aws.Int32(12),
552+
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
553+
LogGroupNames: []string{"/log-group"},
554+
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
555+
},
556+
}, cli.calls.startQuery)
557+
})
558+
515559
t.Run("uses LogGroupNames if the cross account feature flag is not enabled, and log group names is present", func(t *testing.T) {
516560
cli = fakeCWLogsClient{}
517561
ds := newTestDatasource()
@@ -545,6 +589,42 @@ func Test_executeStartQuery(t *testing.T) {
545589
}, cli.calls.startQuery)
546590
})
547591

592+
t.Run("deduplicates log group names when derived from logGroups", func(t *testing.T) {
593+
cli = fakeCWLogsClient{}
594+
ds := newTestDatasource()
595+
596+
_, err := ds.QueryData(context.Background(), &backend.QueryDataRequest{
597+
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
598+
Queries: []backend.DataQuery{
599+
{
600+
RefID: "A",
601+
TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)},
602+
JSON: json.RawMessage(`{
603+
"type": "logAction",
604+
"subtype": "StartQuery",
605+
"limit": 12,
606+
"queryString":"fields @message",
607+
"logGroups":[
608+
{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group1","name":"/log-group"},
609+
{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group2","name":"/log-group"}
610+
]
611+
}`),
612+
},
613+
},
614+
})
615+
assert.NoError(t, err)
616+
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
617+
{
618+
StartTime: aws.Int64(0),
619+
EndTime: aws.Int64(1),
620+
Limit: aws.Int32(12),
621+
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
622+
LogGroupNames: []string{"/log-group"},
623+
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
624+
},
625+
}, cli.calls.startQuery)
626+
})
627+
548628
t.Run("ignores logGroups if feature flag is disabled even if logGroupNames is not present", func(t *testing.T) {
549629
cli = fakeCWLogsClient{}
550630
ds := newTestDatasource()
@@ -600,12 +680,12 @@ func Test_executeStartQuery(t *testing.T) {
600680
assert.NoError(t, err)
601681
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
602682
{
603-
StartTime: aws.Int64(0),
604-
EndTime: aws.Int64(1),
605-
Limit: aws.Int32(12),
606-
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
607-
LogGroupIdentifiers: []string{"*fake**ARN"},
608-
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
683+
StartTime: aws.Int64(0),
684+
EndTime: aws.Int64(1),
685+
Limit: aws.Int32(12),
686+
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
687+
LogGroupNames: []string{"/log-group"},
688+
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
609689
},
610690
}, cli.calls.startQuery)
611691
})

src/datasource.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ describe('datasource', () => {
270270
expect(queryMock.mock.calls[0][0].targets[0]).toMatchObject({
271271
queryString: 'fields templatedField',
272272
logGroups: [
273-
{ name: 'templatedGroup-arn-1', arn: 'templatedGroup-arn-1' },
274-
{ name: 'templatedGroup-arn-2', arn: 'templatedGroup-arn-2' },
273+
{ name: 'templatedGroup-1', arn: 'templatedGroup-arn-1' },
274+
{ name: 'templatedGroup-2', arn: 'templatedGroup-arn-2' },
275275
],
276276
logGroupNames: ['/some/group'],
277277
region: 'templatedRegion',
@@ -401,8 +401,9 @@ describe('datasource', () => {
401401

402402
expect(templateService.replace).toHaveBeenNthCalledWith(1, '$regionVar', {});
403403
expect(templateService.replace).toHaveBeenNthCalledWith(2, '$groups', {}, 'pipe');
404-
expect(templateService.replace).toHaveBeenNthCalledWith(3, '$expressionVar', {}, undefined);
405-
expect(templateService.replace).toHaveBeenCalledTimes(3);
404+
expect(templateService.replace).toHaveBeenNthCalledWith(3, '$groups', {}, 'text');
405+
expect(templateService.replace).toHaveBeenNthCalledWith(4, '$expressionVar', {}, undefined);
406+
expect(templateService.replace).toHaveBeenCalledTimes(4);
406407
});
407408

408409
it('should replace correct variables in CloudWatchMetricsQuery', () => {

src/query-runner/CloudWatchLogsQueryRunner.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
LogRowModel,
1111
} from '@grafana/data';
1212

13-
import { regionVariable } from '../__mocks__/CloudWatchDataSource';
13+
import { logGroupNamesVariable, regionVariable } from '../__mocks__/CloudWatchDataSource';
1414
import { setupMockedLogsQueryRunner } from '../__mocks__/LogsQueryRunner';
1515
import { LogsRequestMock } from '../__mocks__/Request';
1616
import { validLogsQuery } from '../__mocks__/queries';
@@ -28,6 +28,63 @@ describe('CloudWatchLogsQueryRunner', () => {
2828
jest.clearAllMocks();
2929
});
3030

31+
describe('interpolateLogsQueryVariables', () => {
32+
it('returns logGroups with arn and name values sourced from the log group template variable', () => {
33+
const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] });
34+
35+
const query: CloudWatchLogsQuery = {
36+
...validLogsQuery,
37+
logGroups: [{ arn: '$groups', name: '$groups' }],
38+
};
39+
40+
const { logGroups } = runner.interpolateLogsQueryVariables(query, {});
41+
42+
expect(logGroups).toEqual([
43+
{ arn: 'templatedGroup-arn-1', name: 'templatedGroup-1' },
44+
{ arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' },
45+
]);
46+
});
47+
48+
it('filters out duplicate log group arns when query already includes an expanded value', () => {
49+
const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] });
50+
51+
const query: CloudWatchLogsQuery = {
52+
...validLogsQuery,
53+
logGroups: [
54+
{ arn: 'templatedGroup-arn-1', name: 'existing-group-name' },
55+
{ arn: '$groups', name: '$groups' },
56+
],
57+
};
58+
59+
const { logGroups } = runner.interpolateLogsQueryVariables(query, {});
60+
61+
expect(logGroups).toEqual([
62+
{ arn: 'templatedGroup-arn-1', name: 'existing-group-name' },
63+
{ arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' },
64+
]);
65+
});
66+
67+
it('keeps log groups with duplicate names as long as arns are unique', () => {
68+
const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] });
69+
70+
const query: CloudWatchLogsQuery = {
71+
...validLogsQuery,
72+
logGroups: [
73+
{ arn: 'arn-1', name: 'templatedGroup-1' },
74+
{ arn: '$groups', name: '$groups' },
75+
],
76+
};
77+
78+
const { logGroups } = runner.interpolateLogsQueryVariables(query, {});
79+
80+
expect(logGroups).toEqual([
81+
{ arn: 'arn-1', name: 'templatedGroup-1' },
82+
{ arn: 'templatedGroup-arn-1', name: 'templatedGroup-1' },
83+
{ arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' },
84+
]);
85+
});
86+
});
87+
3188
describe('getLogRowContext', () => {
3289
it('replaces parameters correctly in the query', async () => {
3390
const { runner, queryMock } = setupMockedLogsQueryRunner({ variables: [regionVariable] });

src/query-runner/CloudWatchLogsQueryRunner.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { set, uniq } from 'lodash';
1+
import { set, uniq, uniqBy } from 'lodash';
22
import {
33
concatMap,
44
finalize,
@@ -254,9 +254,19 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
254254
(query.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.arn),
255255
scopedVars
256256
);
257+
const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
258+
this.templateSrv,
259+
(query.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.name),
260+
scopedVars,
261+
'text'
262+
);
263+
const interpolatedLogGroups = interpolatedLogGroupArns.map((arn, index) => ({
264+
arn,
265+
name: interpolatedLogGroupNames[index] ?? arn,
266+
}));
257267

258268
// need to support legacy format variables too
259-
const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
269+
const interpolatedLegacyLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
260270
this.templateSrv,
261271
// eslint-disable-next-line @typescript-eslint/no-deprecated
262272
query.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [],
@@ -266,8 +276,8 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
266276

267277
// if a log group template variable expands to log group that has already been selected in the log group picker, we need to remove duplicates.
268278
// Otherwise the StartLogQuery API will return a permission error
269-
const logGroups = uniq(interpolatedLogGroupArns).map((arn) => ({ arn, name: arn }));
270-
const logGroupNames = uniq(interpolatedLogGroupNames);
279+
const logGroups = uniqBy(interpolatedLogGroups, 'arn');
280+
const logGroupNames = uniq(interpolatedLegacyLogGroupNames);
271281

272282
const logsSQLCustomerFormatter = (value: unknown, model: Partial<CustomFormatterVariable>) => {
273283
if (

0 commit comments

Comments
 (0)