Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Changelog
## 1.3.3 (2026-04-05)
* Add Project List Filter to restrict which projects appear in dropdowns using regex patterns
* Fix race condition in variable query where default project was set via unawaited promise

## 1.3.2 (2026-03-18)
* Fix project dropdown search failing with "contains global restriction" error
* Return error responses as JSON so Grafana displays actual error messages instead of generic "Unexpected error"
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,28 @@ If you are using a non-default universe (e.g., a sovereign cloud), you can confi
3. Select "Google Cloud Trace"
4. Select the authentication type from the dropdown (JWT, GCE, Access Token, or OAuth Passthrough)
5. Provide the required credentials for your chosen authentication method
6. Click "Save & test" to test that traces can be queried from Cloud Trace.
6. Optionally, configure the **Universe Domain** if you are using a non-default GCP environment
7. Optionally, configure the **Project List Filter** to restrict which projects appear in the project dropdown (see [Project List Filter](#project-list-filter) below)
8. Click "Save & test" to test that traces can be queried from Cloud Trace.

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

### Project List Filter

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.

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).

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:

| Pattern | Matches |
| --- | --- |
| `my-project-123` | Only the exact project `my-project-123` |
| `team-alpha-.*` | All projects starting with `team-alpha-` |
| `prod-.*-trace` | Projects like `prod-us-trace`, `prod-eu-trace`, etc. |

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.

## Usage

### Grafana Explore
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "googlecloud-trace-datasource",
"version": "1.3.2",
"version": "1.3.3",
"description": "Backend Grafana plugin that enables visualization of GCP Cloud Trace traces and spans in Grafana.",
"scripts": {
"postinstall": "rm -rf node_modules/flatted/golang",
Expand Down
4 changes: 2 additions & 2 deletions src/CloudTraceVariableFindQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default class CloudTraceVariableFindQuery {
async execute(query: CloudTraceVariableQuery) {
try {
if (!query.projectId) {
this.datasource.getDefaultProject().then(r => query.projectId = r);
query.projectId = await this.datasource.getDefaultProject();
}
switch (query.selectedQueryType) {
case TraceVariables.Projects:
Expand All @@ -39,7 +39,7 @@ export default class CloudTraceVariableFindQuery {
}

async handleProjectsQuery() {
const projects = await this.datasource.getProjects();
const projects = await this.datasource.getFilteredProjects();
return (projects).map((s) => ({
text: s,
value: s,
Expand Down
21 changes: 20 additions & 1 deletion src/ConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

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

Expand Down Expand Up @@ -197,6 +197,25 @@ const defaultProject = (props: Props) => {
}}
/>
</Field>
<Field
label="Project List Filter"
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."
>
<TextArea
value={options.jsonData.projectListFilter || ''}
placeholder={'my-project-id\nteam-.*\nprod-.*-trace'}
rows={4}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
projectListFilter: e.target.value,
},
});
}}
/>
</Field>
</>
);
};
25 changes: 20 additions & 5 deletions src/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function CloudTraceQueryEditor({ datasource, query, range, onChange, onRu

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

if (!needsQueryText && !needsProjectId) {
if (!needsQueryText && !needsProjectId && !projectFailsFilter) {
return;
}

if (needsProjectId) {
if (needsProjectId || projectFailsFilter) {
datasource.getDefaultProject().then((project) => {
const nextQuery = { ...query };
let hasChanges = false;

if (needsQueryText) {
nextQuery.queryText = defaultQuery.queryText;
hasChanges = true;
}
if (project) {

if (needsProjectId && project && datasource.filterProjects([project]).length > 0) {
nextQuery.projectId = project;
hasChanges = true;
} else if (projectFailsFilter) {
// Explicit fallback: clear project ID if it doesn't pass the filter
nextQuery.projectId = '';
hasChanges = true;
}

if (hasChanges) {
onChange(nextQuery);
}
onChange(nextQuery);
});
} else if (needsQueryText) {
onChange({ ...query, queryText: defaultQuery.queryText });
Expand Down
7 changes: 5 additions & 2 deletions src/VariableQueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ export class CloudTraceVariableQueryEditor extends PureComponent<Props, Variable

async componentDidMount() {
await this.props.datasource.ensureGCEDefaultProject();
const projectId = this.props.query.projectId || (await this.props.datasource.getDefaultProject());
const projects = (await this.props.datasource.getProjects());
let projectId = this.props.query.projectId || (await this.props.datasource.getDefaultProject());
if (projectId && this.props.datasource.filterProjects([projectId]).length === 0) {
projectId = '';
}
const projects = (await this.props.datasource.getFilteredProjects());

const state: any = {
projects,
Expand Down
96 changes: 95 additions & 1 deletion src/datasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,99 @@ describe('Google Cloud Trace Data Source', () => {
});
});

describe('filterProjects', () => {
const allProjects = [
'my-project-123',
'team-alpha-prod',
'team-alpha-staging',
'team-beta-prod',
'prod-trace-service',
'other-project',
];

it('returns all projects when no filter is configured', () => {
const ds = makeDataSource();
expect(ds.filterProjects(allProjects)).toEqual(allProjects);
});

it('returns all projects when filter is empty string', () => {
const ds = makeDataSource({ projectListFilter: '' });
expect(ds.filterProjects(allProjects)).toEqual(allProjects);
});

it('returns all projects when filter is only whitespace', () => {
const ds = makeDataSource({ projectListFilter: ' \n \n ' });
expect(ds.filterProjects(allProjects)).toEqual(allProjects);
});

it('filters by exact literal project ID', () => {
const ds = makeDataSource({ projectListFilter: 'my-project-123' });
expect(ds.filterProjects(allProjects)).toEqual(['my-project-123']);
});

it('filters using regex pattern', () => {
const ds = makeDataSource({ projectListFilter: 'team-alpha-.*' });
expect(ds.filterProjects(allProjects)).toEqual([
'team-alpha-prod',
'team-alpha-staging',
]);
});

it('supports multiple patterns (union of matches)', () => {
const ds = makeDataSource({
projectListFilter: 'my-project-123\nteam-beta-.*',
});
expect(ds.filterProjects(allProjects)).toEqual([
'my-project-123',
'team-beta-prod',
]);
});

it('ignores empty lines between patterns', () => {
const ds = makeDataSource({
projectListFilter: 'my-project-123\n\n\nother-project',
});
expect(ds.filterProjects(allProjects)).toEqual([
'my-project-123',
'other-project',
]);
});

it('anchors patterns so partial matches do not pass', () => {
const ds = makeDataSource({ projectListFilter: 'team' });
expect(ds.filterProjects(allProjects)).toEqual([]);
});

it('handles invalid regex gracefully by treating as literal', () => {
const ds = makeDataSource({ projectListFilter: 'invalid[regex' });
// Should not throw, and should not match anything (literal "invalid[regex" not in list)
expect(ds.filterProjects(allProjects)).toEqual([]);
});

it('trims whitespace from pattern lines', () => {
const ds = makeDataSource({ projectListFilter: ' my-project-123 ' });
expect(ds.filterProjects(allProjects)).toEqual(['my-project-123']);
});

it('anchors alternations correctly (fixes ^a|b$ bug)', () => {
const ds = makeDataSource({ projectListFilter: 'team-alpha|team-beta' });
expect(ds.filterProjects([
'team-alpha',
'team-beta',
'team-alpha-prod',
'prod-team-beta'
])).toEqual([
'team-alpha',
'team-beta'
]);
});

it('returns empty array when no projects match', () => {
const ds = makeDataSource({ projectListFilter: 'nonexistent-.*' });
expect(ds.filterProjects(allProjects)).toEqual([]);
});
});

describe('applyTemplateVariables', () => {
it('normalizes traceql query from "Query with traces" to traceID format', () => {
const ds = makeDataSourceWithTemplateSrv();
Expand Down Expand Up @@ -201,7 +294,7 @@ describe('Google Cloud Trace Data Source', () => {
});
});

const makeDataSource = () => {
const makeDataSource = (overrides?: { projectListFilter?: string }) => {
return new DataSource({
id: random(100),
type: 'googlecloud-trace-datasource',
Expand All @@ -210,6 +303,7 @@ const makeDataSource = () => {
uid: `${random(100)}`,
jsonData: {
authenticationType: GoogleAuthType.JWT,
...overrides,
},
name: 'something',
readOnly: true,
Expand Down
43 changes: 43 additions & 0 deletions src/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { CloudTraceVariableSupport } from './variables';
export class DataSource extends DataSourceWithBackend<Query, CloudTraceOptions> {
authenticationType: string;
annotations = {};
private filterRegexes: RegExp[] | null = null;

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

/**
* Filter a list of project IDs against the configured project list filter patterns.
* Each non-empty line in `projectListFilter` is treated as a regex pattern
* anchored to the full project ID (^pattern$).
* If no patterns are configured, all projects pass through unchanged.
*/
filterProjects(projects: string[]): string[] {
if (this.filterRegexes === null) {
const raw = this.instanceSettings.jsonData.projectListFilter;
if (!raw || !raw.trim()) {
this.filterRegexes = [];
} else {
this.filterRegexes = raw
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0)
.map((p: string) => {
try {
return new RegExp(`^(?:${p})$`);
} catch {
// If invalid regex, escape special chars and treat as literal match
return new RegExp(`^(?:${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})$`);
}
});
}
}

if (this.filterRegexes.length === 0) {
return projects;
}
const regexes = this.filterRegexes;
return projects.filter((proj: string) => regexes.some((r: RegExp) => r.test(proj)));
}

/**
* Get projects from the API and apply the configured project list filter.
*/
async getFilteredProjects(query?: string): Promise<string[]> {
const projects = await this.getProjects(query);
return this.filterProjects(projects);
}

applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query {
let normalizedQuery = { ...query };

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface DataSourceOptionsExt extends DataSourceOptions {
usingImpersonation?: boolean;
oauthPassThru?: boolean;
universeDomain?: string;
projectListFilter?: string;
}

/**
Expand Down
Loading