Skip to content

Commit 55f1e42

Browse files
authored
Add project filter (#57)
* add project filter * prep for 1.3.3
1 parent eb1c1ce commit 55f1e42

File tree

10 files changed

+210
-13
lines changed

10 files changed

+210
-13
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# Changelog
2+
## 1.3.3 (2026-04-05)
3+
* Add Project List Filter to restrict which projects appear in dropdowns using regex patterns
4+
* Fix race condition in variable query where default project was set via unawaited promise
5+
26
## 1.3.2 (2026-03-18)
37
* Fix project dropdown search failing with "contains global restriction" error
48
* Return error responses as JSON so Grafana displays actual error messages instead of generic "Unexpected error"

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,28 @@ If you are using a non-default universe (e.g., a sovereign cloud), you can confi
7171
3. Select "Google Cloud Trace"
7272
4. Select the authentication type from the dropdown (JWT, GCE, Access Token, or OAuth Passthrough)
7373
5. Provide the required credentials for your chosen authentication method
74-
6. Click "Save & test" to test that traces can be queried from Cloud Trace.
74+
6. Optionally, configure the **Universe Domain** if you are using a non-default GCP environment
75+
7. Optionally, configure the **Project List Filter** to restrict which projects appear in the project dropdown (see [Project List Filter](#project-list-filter) below)
76+
8. Click "Save & test" to test that traces can be queried from Cloud Trace.
7577

7678
![image info](https://github.com/GoogleCloudPlatform/cloud-trace-data-source-plugin/blob/main/src/img/cloud_trace_config.png?raw=true)
7779

80+
### Project List Filter
81+
82+
If you have access to many GCP projects, you can restrict which projects appear in the project dropdown by configuring a **Project List Filter** in the data source settings.
83+
84+
Enter project IDs or regex patterns in the text area, one per line. Only projects matching at least one pattern will appear in the dropdown. Leave the field empty to show all projects (the default behavior).
85+
86+
Each line is treated as a [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions) anchored to the full project ID. For example:
87+
88+
| Pattern | Matches |
89+
| --- | --- |
90+
| `my-project-123` | Only the exact project `my-project-123` |
91+
| `team-alpha-.*` | All projects starting with `team-alpha-` |
92+
| `prod-.*-trace` | Projects like `prod-us-trace`, `prod-eu-trace`, etc. |
93+
94+
You can combine multiple patterns (one per line) to match the union of all patterns. If a pattern contains invalid regex syntax, it is treated as a literal string match.
95+
7896
## Usage
7997

8098
### Grafana Explore

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "googlecloud-trace-datasource",
3-
"version": "1.3.2",
3+
"version": "1.3.3",
44
"description": "Backend Grafana plugin that enables visualization of GCP Cloud Trace traces and spans in Grafana.",
55
"scripts": {
66
"postinstall": "rm -rf node_modules/flatted/golang",

src/CloudTraceVariableFindQuery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export default class CloudTraceVariableFindQuery {
2424
async execute(query: CloudTraceVariableQuery) {
2525
try {
2626
if (!query.projectId) {
27-
this.datasource.getDefaultProject().then(r => query.projectId = r);
27+
query.projectId = await this.datasource.getDefaultProject();
2828
}
2929
switch (query.selectedQueryType) {
3030
case TraceVariables.Projects:
@@ -39,7 +39,7 @@ export default class CloudTraceVariableFindQuery {
3939
}
4040

4141
async handleProjectsQuery() {
42-
const projects = await this.datasource.getProjects();
42+
const projects = await this.datasource.getFilteredProjects();
4343
return (projects).map((s) => ({
4444
text: s,
4545
value: s,

src/ConfigEditor.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
1818
import { ConnectionConfig, GoogleAuthType } from '@grafana/google-sdk';
19-
import { Field, Input, Label, SecretInput, Select } from '@grafana/ui';
19+
import { Field, Input, Label, SecretInput, Select, TextArea } from '@grafana/ui';
2020
import React, { PureComponent } from 'react';
2121
import { authTypes, CloudTraceOptions, DataSourceSecureJsonData } from './types';
2222

@@ -197,6 +197,25 @@ const defaultProject = (props: Props) => {
197197
}}
198198
/>
199199
</Field>
200+
<Field
201+
label="Project List Filter"
202+
description="Enter project IDs or regex patterns, one per line. Only matching projects will appear in the project dropdown. Leave empty to show all projects."
203+
>
204+
<TextArea
205+
value={options.jsonData.projectListFilter || ''}
206+
placeholder={'my-project-id\nteam-.*\nprod-.*-trace'}
207+
rows={4}
208+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
209+
onOptionsChange({
210+
...options,
211+
jsonData: {
212+
...options.jsonData,
213+
projectListFilter: e.target.value,
214+
},
215+
});
216+
}}
217+
/>
218+
</Field>
200219
</>
201220
);
202221
};

src/QueryEditor.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function CloudTraceQueryEditor({ datasource, query, range, onChange, onRu
6464

6565
const loadProjects = useCallback((inputValue: string): Promise<Array<SelectableValue<string>>> => {
6666
const thisRequestId = ++requestIdRef.current;
67-
return datasource.getProjects(inputValue || undefined).then(res => {
67+
return datasource.getFilteredProjects(inputValue || undefined).then(res => {
6868
if (thisRequestId === requestIdRef.current) {
6969
setFetchError(undefined);
7070
}
@@ -85,21 +85,36 @@ export function CloudTraceQueryEditor({ datasource, query, range, onChange, onRu
8585
useEffect(() => {
8686
const needsQueryText = query.queryText == null && defaultQuery.queryText;
8787
const needsProjectId = !query.projectId;
88+
const projectFailsFilter = Boolean(query.projectId &&
89+
!query.projectId.startsWith('$') &&
90+
datasource.filterProjects([query.projectId]).length === 0);
8891

89-
if (!needsQueryText && !needsProjectId) {
92+
if (!needsQueryText && !needsProjectId && !projectFailsFilter) {
9093
return;
9194
}
9295

93-
if (needsProjectId) {
96+
if (needsProjectId || projectFailsFilter) {
9497
datasource.getDefaultProject().then((project) => {
9598
const nextQuery = { ...query };
99+
let hasChanges = false;
100+
96101
if (needsQueryText) {
97102
nextQuery.queryText = defaultQuery.queryText;
103+
hasChanges = true;
98104
}
99-
if (project) {
105+
106+
if (needsProjectId && project && datasource.filterProjects([project]).length > 0) {
100107
nextQuery.projectId = project;
108+
hasChanges = true;
109+
} else if (projectFailsFilter) {
110+
// Explicit fallback: clear project ID if it doesn't pass the filter
111+
nextQuery.projectId = '';
112+
hasChanges = true;
113+
}
114+
115+
if (hasChanges) {
116+
onChange(nextQuery);
101117
}
102-
onChange(nextQuery);
103118
});
104119
} else if (needsQueryText) {
105120
onChange({ ...query, queryText: defaultQuery.queryText });

src/VariableQueryEditor.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ export class CloudTraceVariableQueryEditor extends PureComponent<Props, Variable
4242

4343
async componentDidMount() {
4444
await this.props.datasource.ensureGCEDefaultProject();
45-
const projectId = this.props.query.projectId || (await this.props.datasource.getDefaultProject());
46-
const projects = (await this.props.datasource.getProjects());
45+
let projectId = this.props.query.projectId || (await this.props.datasource.getDefaultProject());
46+
if (projectId && this.props.datasource.filterProjects([projectId]).length === 0) {
47+
projectId = '';
48+
}
49+
const projects = (await this.props.datasource.getFilteredProjects());
4750

4851
const state: any = {
4952
projects,

src/datasource.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,99 @@ describe('Google Cloud Trace Data Source', () => {
158158
});
159159
});
160160

161+
describe('filterProjects', () => {
162+
const allProjects = [
163+
'my-project-123',
164+
'team-alpha-prod',
165+
'team-alpha-staging',
166+
'team-beta-prod',
167+
'prod-trace-service',
168+
'other-project',
169+
];
170+
171+
it('returns all projects when no filter is configured', () => {
172+
const ds = makeDataSource();
173+
expect(ds.filterProjects(allProjects)).toEqual(allProjects);
174+
});
175+
176+
it('returns all projects when filter is empty string', () => {
177+
const ds = makeDataSource({ projectListFilter: '' });
178+
expect(ds.filterProjects(allProjects)).toEqual(allProjects);
179+
});
180+
181+
it('returns all projects when filter is only whitespace', () => {
182+
const ds = makeDataSource({ projectListFilter: ' \n \n ' });
183+
expect(ds.filterProjects(allProjects)).toEqual(allProjects);
184+
});
185+
186+
it('filters by exact literal project ID', () => {
187+
const ds = makeDataSource({ projectListFilter: 'my-project-123' });
188+
expect(ds.filterProjects(allProjects)).toEqual(['my-project-123']);
189+
});
190+
191+
it('filters using regex pattern', () => {
192+
const ds = makeDataSource({ projectListFilter: 'team-alpha-.*' });
193+
expect(ds.filterProjects(allProjects)).toEqual([
194+
'team-alpha-prod',
195+
'team-alpha-staging',
196+
]);
197+
});
198+
199+
it('supports multiple patterns (union of matches)', () => {
200+
const ds = makeDataSource({
201+
projectListFilter: 'my-project-123\nteam-beta-.*',
202+
});
203+
expect(ds.filterProjects(allProjects)).toEqual([
204+
'my-project-123',
205+
'team-beta-prod',
206+
]);
207+
});
208+
209+
it('ignores empty lines between patterns', () => {
210+
const ds = makeDataSource({
211+
projectListFilter: 'my-project-123\n\n\nother-project',
212+
});
213+
expect(ds.filterProjects(allProjects)).toEqual([
214+
'my-project-123',
215+
'other-project',
216+
]);
217+
});
218+
219+
it('anchors patterns so partial matches do not pass', () => {
220+
const ds = makeDataSource({ projectListFilter: 'team' });
221+
expect(ds.filterProjects(allProjects)).toEqual([]);
222+
});
223+
224+
it('handles invalid regex gracefully by treating as literal', () => {
225+
const ds = makeDataSource({ projectListFilter: 'invalid[regex' });
226+
// Should not throw, and should not match anything (literal "invalid[regex" not in list)
227+
expect(ds.filterProjects(allProjects)).toEqual([]);
228+
});
229+
230+
it('trims whitespace from pattern lines', () => {
231+
const ds = makeDataSource({ projectListFilter: ' my-project-123 ' });
232+
expect(ds.filterProjects(allProjects)).toEqual(['my-project-123']);
233+
});
234+
235+
it('anchors alternations correctly (fixes ^a|b$ bug)', () => {
236+
const ds = makeDataSource({ projectListFilter: 'team-alpha|team-beta' });
237+
expect(ds.filterProjects([
238+
'team-alpha',
239+
'team-beta',
240+
'team-alpha-prod',
241+
'prod-team-beta'
242+
])).toEqual([
243+
'team-alpha',
244+
'team-beta'
245+
]);
246+
});
247+
248+
it('returns empty array when no projects match', () => {
249+
const ds = makeDataSource({ projectListFilter: 'nonexistent-.*' });
250+
expect(ds.filterProjects(allProjects)).toEqual([]);
251+
});
252+
});
253+
161254
describe('applyTemplateVariables', () => {
162255
it('normalizes traceql query from "Query with traces" to traceID format', () => {
163256
const ds = makeDataSourceWithTemplateSrv();
@@ -201,7 +294,7 @@ describe('Google Cloud Trace Data Source', () => {
201294
});
202295
});
203296

204-
const makeDataSource = () => {
297+
const makeDataSource = (overrides?: { projectListFilter?: string }) => {
205298
return new DataSource({
206299
id: random(100),
207300
type: 'googlecloud-trace-datasource',
@@ -210,6 +303,7 @@ const makeDataSource = () => {
210303
uid: `${random(100)}`,
211304
jsonData: {
212305
authenticationType: GoogleAuthType.JWT,
306+
...overrides,
213307
},
214308
name: 'something',
215309
readOnly: true,

src/datasource.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { CloudTraceVariableSupport } from './variables';
2525
export class DataSource extends DataSourceWithBackend<Query, CloudTraceOptions> {
2626
authenticationType: string;
2727
annotations = {};
28+
private filterRegexes: RegExp[] | null = null;
2829

2930
constructor(
3031
private instanceSettings: DataSourceInstanceSettings<CloudTraceOptions>,
@@ -125,6 +126,48 @@ export class DataSource extends DataSourceWithBackend<Query, CloudTraceOptions>
125126
return this.getResource('projects', { query });
126127
}
127128

129+
/**
130+
* Filter a list of project IDs against the configured project list filter patterns.
131+
* Each non-empty line in `projectListFilter` is treated as a regex pattern
132+
* anchored to the full project ID (^pattern$).
133+
* If no patterns are configured, all projects pass through unchanged.
134+
*/
135+
filterProjects(projects: string[]): string[] {
136+
if (this.filterRegexes === null) {
137+
const raw = this.instanceSettings.jsonData.projectListFilter;
138+
if (!raw || !raw.trim()) {
139+
this.filterRegexes = [];
140+
} else {
141+
this.filterRegexes = raw
142+
.split('\n')
143+
.map((line: string) => line.trim())
144+
.filter((line: string) => line.length > 0)
145+
.map((p: string) => {
146+
try {
147+
return new RegExp(`^(?:${p})$`);
148+
} catch {
149+
// If invalid regex, escape special chars and treat as literal match
150+
return new RegExp(`^(?:${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})$`);
151+
}
152+
});
153+
}
154+
}
155+
156+
if (this.filterRegexes.length === 0) {
157+
return projects;
158+
}
159+
const regexes = this.filterRegexes;
160+
return projects.filter((proj: string) => regexes.some((r: RegExp) => r.test(proj)));
161+
}
162+
163+
/**
164+
* Get projects from the API and apply the configured project list filter.
165+
*/
166+
async getFilteredProjects(query?: string): Promise<string[]> {
167+
const projects = await this.getProjects(query);
168+
return this.filterProjects(projects);
169+
}
170+
128171
applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query {
129172
let normalizedQuery = { ...query };
130173

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface DataSourceOptionsExt extends DataSourceOptions {
4141
usingImpersonation?: boolean;
4242
oauthPassThru?: boolean;
4343
universeDomain?: string;
44+
projectListFilter?: string;
4445
}
4546

4647
/**

0 commit comments

Comments
 (0)