Azure: Add support for custom namespace and custom metrics variable queries (#99279)

* Add custom metric namespace and metric name queries

* Fix outdated type

* Support specifying custom

- Add custom support to getMetricNamespaces
- Ensure the customNamespace is specified in getMetricNames calls

* Update data source tests

* Support custom namespace/metrics variable queries

- Add tests

* Add fields to variable editor

- Update tests
- Update docs
- Update selectors

* Remove unneeded Promise.resolve

* Add comment

* Don't mutate expected path

* Lint

* Update docs/sources/datasources/azure-monitor/template-variables/index.md

Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>

* Update docs/sources/datasources/azure-monitor/template-variables/index.md

Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>

* Update docs

* Update conditionals

* Lint

---------

Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>
This commit is contained in:
Andreas Christou 2025-01-27 15:53:00 +00:00 committed by GitHub
parent c6ba0910b4
commit e01d8ad5b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 394 additions and 39 deletions

View File

@ -48,19 +48,24 @@ For an introduction to templating and template variables, refer to the [Templati
You can specify these Azure Monitor data source queries in the Variable edit view's **Query Type** field. You can specify these Azure Monitor data source queries in the Variable edit view's **Query Type** field.
| Name | Description | | Name | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------ | | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Subscriptions** | Returns subscriptions. | | **Subscriptions** | Returns subscriptions. |
| **Resource Groups** | Returns resource groups for a specified. Supports multi-value. subscription. | | **Resource Groups** | Returns resource groups for a specified. Supports multi-value. subscription. |
| **Namespaces** | Returns metric namespaces for the specified subscription and resource group. | | **Namespaces** | Returns metric namespaces for the specified subscription and resource group. |
| **Regions** | Returns regions for the specified subscription | | **Regions** | Returns regions for the specified subscription |
| **Resource Names** | Returns a list of resource names for a specified subscription, resource group and namespace. Supports multi-value. | | **Resource Names** | Returns a list of resource names for a specified subscription, resource group and namespace. Supports multi-value. |
| **Metric Names** | Returns a list of metric names for a resource. | | **Metric Names** | Returns a list of metric names for a resource. |
| **Workspaces** | Returns a list of workspaces for the specified subscription. | | **Workspaces** | Returns a list of workspaces for the specified subscription. |
| **Logs** | Use a KQL query to return values. | | **Logs** | Use a KQL query to return values. |
| **Resource Graph** | Use an ARG query to return values. | | **Custom Namespaces** | Returns metric namespaces for the specified resource. |
| **Custom Metric Names** | Returns a list of custom metric names for the specified resource. |
Any Log Analytics Kusto Query Language (KQL) query that returns a single list of values can also be used in the Query field. {{< admonition type="note" >}}
Custom metrics cannot be emitted against a subscription or resource group. Select resources only when you need to retrieve custom metric namespaces or custom metric names associated with a specific resource.
{{< /admonition >}}
You can use any Log Analytics Kusto Query Language (KQL) query that returns a single list of values in the `Query` field.
For example: For example:
| Query | List of values returned | | Query | List of values returned |

View File

@ -29,23 +29,37 @@ export interface AzureMonitorQuery extends common.DataQuery {
* Application Insights Traces sub-query properties. * Application Insights Traces sub-query properties.
*/ */
azureTraces?: AzureTracesQuery; azureTraces?: AzureTracesQuery;
/**
* Custom namespace used in template variable queries
*/
customNamespace?: string;
/** /**
* @deprecated Legacy template variable support. * @deprecated Legacy template variable support.
*/ */
grafanaTemplateVariableFn?: GrafanaTemplateVariableQuery; grafanaTemplateVariableFn?: GrafanaTemplateVariableQuery;
/**
* Namespace used in template variable queries
*/
namespace?: string; namespace?: string;
/** /**
* Used only for exemplar queries from Prometheus * Used only for exemplar queries from Prometheus
*/ */
query?: string; query?: string;
/**
* Region used in template variable queries
*/
region?: string; region?: string;
/**
* Resource used in template variable queries
*/
resource?: string; resource?: string;
/** /**
* Template variables params. These exist for backwards compatiblity with legacy template variables. * Resource group used in template variable queries
*/ */
resourceGroup?: string; resourceGroup?: string;
/** /**
* Azure subscription containing the resource(s) to be queried. * Azure subscription containing the resource(s) to be queried.
* Also used for template variable queries
*/ */
subscription?: string; subscription?: string;
/** /**
@ -65,6 +79,8 @@ export enum AzureQueryType {
AzureMonitor = 'Azure Monitor', AzureMonitor = 'Azure Monitor',
AzureResourceGraph = 'Azure Resource Graph', AzureResourceGraph = 'Azure Resource Graph',
AzureTraces = 'Azure Traces', AzureTraces = 'Azure Traces',
CustomMetricNamesQuery = 'Azure Custom Metric Names',
CustomNamespacesQuery = 'Azure Custom Namespaces',
GrafanaTemplateVariableFn = 'Grafana Template Variable Function', GrafanaTemplateVariableFn = 'Grafana Template Variable Function',
LocationsQuery = 'Azure Regions', LocationsQuery = 'Azure Regions',
LogAnalytics = 'Azure Log Analytics', LogAnalytics = 'Azure Log Analytics',

View File

@ -28,6 +28,7 @@ type AzureMonitorQuery struct {
// TODO make this required and give it a default // TODO make this required and give it a default
QueryType *string `json:"queryType,omitempty"` QueryType *string `json:"queryType,omitempty"`
// Azure subscription containing the resource(s) to be queried. // Azure subscription containing the resource(s) to be queried.
// Also used for template variable queries
Subscription *string `json:"subscription,omitempty"` Subscription *string `json:"subscription,omitempty"`
// Subscriptions to be queried via Azure Resource Graph. // Subscriptions to be queried via Azure Resource Graph.
Subscriptions []string `json:"subscriptions,omitempty"` Subscriptions []string `json:"subscriptions,omitempty"`
@ -41,11 +42,16 @@ type AzureMonitorQuery struct {
AzureTraces *AzureTracesQuery `json:"azureTraces,omitempty"` AzureTraces *AzureTracesQuery `json:"azureTraces,omitempty"`
// @deprecated Legacy template variable support. // @deprecated Legacy template variable support.
GrafanaTemplateVariableFn *GrafanaTemplateVariableQuery `json:"grafanaTemplateVariableFn,omitempty"` GrafanaTemplateVariableFn *GrafanaTemplateVariableQuery `json:"grafanaTemplateVariableFn,omitempty"`
// Template variables params. These exist for backwards compatiblity with legacy template variables. // Resource group used in template variable queries
ResourceGroup *string `json:"resourceGroup,omitempty"` ResourceGroup *string `json:"resourceGroup,omitempty"`
Namespace *string `json:"namespace,omitempty"` // Namespace used in template variable queries
Resource *string `json:"resource,omitempty"` Namespace *string `json:"namespace,omitempty"`
Region *string `json:"region,omitempty"` // Resource used in template variable queries
Resource *string `json:"resource,omitempty"`
// Region used in template variable queries
Region *string `json:"region,omitempty"`
// Custom namespace used in template variable queries
CustomNamespace *string `json:"customNamespace,omitempty"`
// For mixed data sources the selected datasource is on the query level. // For mixed data sources the selected datasource is on the query level.
// For non mixed scenarios this is undefined. // For non mixed scenarios this is undefined.
// TODO find a better way to do this ^ that's friendly to schema // TODO find a better way to do this ^ that's friendly to schema
@ -77,6 +83,8 @@ const (
AzureQueryTypeLocationsQuery AzureQueryType = "Azure Regions" AzureQueryTypeLocationsQuery AzureQueryType = "Azure Regions"
AzureQueryTypeGrafanaTemplateVariableFn AzureQueryType = "Grafana Template Variable Function" AzureQueryTypeGrafanaTemplateVariableFn AzureQueryType = "Grafana Template Variable Function"
AzureQueryTypeTraceExemplar AzureQueryType = "traceql" AzureQueryTypeTraceExemplar AzureQueryType = "traceql"
AzureQueryTypeCustomNamespacesQuery AzureQueryType = "Azure Custom Namespaces"
AzureQueryTypeCustomMetricNamesQuery AzureQueryType = "Azure Custom Metric Names"
) )
type AzureMetricQuery struct { type AzureMetricQuery struct {

View File

@ -6,7 +6,10 @@ import { multiVariable } from '../__mocks__/variables';
import AzureMonitorDatasource from '../datasource'; import AzureMonitorDatasource from '../datasource';
import { AzureAPIResponse, AzureMonitorDataSourceInstanceSettings, Location } from '../types'; import { AzureAPIResponse, AzureMonitorDataSourceInstanceSettings, Location } from '../types';
let replace = () => ''; // We want replace to just return the value as is in general/
// We declare this as a function so that we can overwrite it in each test
// without affecting the rest of the @grafana/runtime module.
let replace = (val: string) => val;
jest.mock('@grafana/runtime', () => { jest.mock('@grafana/runtime', () => {
return { return {
@ -251,8 +254,8 @@ describe('AzureMonitorDatasource', () => {
const basePath = 'azuremonitor/subscriptions/mock-subscription-id/resourceGroups/nodeapp'; const basePath = 'azuremonitor/subscriptions/mock-subscription-id/resourceGroups/nodeapp';
const expected = const expected =
basePath + basePath +
'/providers/microsoft.insights/components/resource1' + '/providers/microsoft.insights/components/resource1/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview' +
'/providers/microsoft.insights/metricNamespaces?api-version=2017-12-01-preview&region=global'; (path.includes('&region=global') ? '&region=global' : '');
expect(path).toBe(expected); expect(path).toBe(expected);
return Promise.resolve(response); return Promise.resolve(response);
}); });
@ -295,6 +298,24 @@ describe('AzureMonitorDatasource', () => {
); );
}); });
}); });
it('when custom is specified will only return custom namespaces', () => {
return ctx.ds.azureMonitorDatasource
.getMetricNamespaces(
{
resourceUri:
'/subscriptions/mock-subscription-id/resourceGroups/nodeapp/providers/microsoft.insights/components/resource1',
},
false,
undefined,
true
)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('Azure.ApplicationInsights');
expect(results[0].value).toEqual('Azure.ApplicationInsights');
});
});
}); });
describe('When performing getMetricNames', () => { describe('When performing getMetricNames', () => {

View File

@ -1,4 +1,3 @@
import { Namespace } from 'i18next';
import { find, startsWith } from 'lodash'; import { find, startsWith } from 'lodash';
import { AzureCredentials } from '@grafana/azure-sdk'; import { AzureCredentials } from '@grafana/azure-sdk';
@ -26,6 +25,7 @@ import {
Location, Location,
ResourceGroup, ResourceGroup,
Metric, Metric,
MetricNamespace,
} from '../types'; } from '../types';
import { routeNames } from '../utils/common'; import { routeNames } from '../utils/common';
import migrateQuery from '../utils/migrateQuery'; import migrateQuery from '../utils/migrateQuery';
@ -238,7 +238,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<
return (await Promise.all(promises)).flat(); return (await Promise.all(promises)).flat();
} }
getMetricNamespaces(query: GetMetricNamespacesQuery, globalRegion: boolean, region?: string) { // Note globalRegion should be false when querying custom metric namespaces
getMetricNamespaces(query: GetMetricNamespacesQuery, globalRegion: boolean, region?: string, custom?: boolean) {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(
this.resourcePath, this.resourcePath,
this.apiPreviewVersion, this.apiPreviewVersion,
@ -249,7 +250,10 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<
region region
); );
return this.getResource(url) return this.getResource(url)
.then((result: AzureAPIResponse<Namespace>) => { .then((result: AzureAPIResponse<MetricNamespace>) => {
if (custom) {
result.value = result.value.filter((namespace) => namespace.classification === 'Custom');
}
return ResponseParser.parseResponseValues( return ResponseParser.parseResponseValues(
result, result,
'properties.metricNamespaceName', 'properties.metricNamespaceName',

View File

@ -40,7 +40,16 @@ const defaultProps = {
datasource: createMockDatasource({ datasource: createMockDatasource({
getSubscriptions: jest.fn().mockResolvedValue([{ text: 'Primary Subscription', value: 'sub' }]), getSubscriptions: jest.fn().mockResolvedValue([{ text: 'Primary Subscription', value: 'sub' }]),
getResourceGroups: jest.fn().mockResolvedValue([{ text: 'rg', value: 'rg' }]), getResourceGroups: jest.fn().mockResolvedValue([{ text: 'rg', value: 'rg' }]),
getMetricNamespaces: jest.fn().mockResolvedValue([{ text: 'foo/bar', value: 'foo/bar' }]), getMetricNamespaces: jest
.fn()
.mockImplementation(
async (_subscriptionId: string, _resourceGroup?: string, _resourceUri?: string, custom?: boolean) => {
if (custom !== true) {
return [{ text: 'foo/bar', value: 'foo/bar' }];
}
return [{ text: 'foo/custom', value: 'foo/custom' }];
}
),
getResourceNames: jest.fn().mockResolvedValue([{ text: 'foobar', value: 'foobar' }]), getResourceNames: jest.fn().mockResolvedValue([{ text: 'foobar', value: 'foobar' }]),
getVariablesRaw: jest.fn().mockReturnValue([ getVariablesRaw: jest.fn().mockReturnValue([
{ label: 'query0', name: 'sub0' }, { label: 'query0', name: 'sub0' },
@ -350,5 +359,51 @@ describe('VariableEditor:', () => {
}) })
); );
}); });
it('should run the query if requesting custom metric namespaces', async () => {
const onChange = jest.fn();
const { rerender } = render(<VariableEditor {...defaultProps} onChange={onChange} />);
// wait for initial load
await waitFor(() => expect(screen.getByText('Logs')).toBeInTheDocument());
await selectAndRerender('select query type', 'Custom Namespaces', onChange, rerender);
await selectAndRerender('select subscription', 'Primary Subscription', onChange, rerender);
await selectAndRerender('select resource group', 'rg', onChange, rerender);
await selectAndRerender('select namespace', 'foo/bar', onChange, rerender);
await selectAndRerender('select resource', 'foobar', onChange, rerender);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
queryType: AzureQueryType.CustomNamespacesQuery,
subscription: 'sub',
resourceGroup: 'rg',
namespace: 'foo/bar',
resource: 'foobar',
refId: 'A',
})
);
});
it('should run the query if requesting custom metrics for a resource', async () => {
const onChange = jest.fn();
const { rerender } = render(<VariableEditor {...defaultProps} onChange={onChange} />);
// wait for initial load
await waitFor(() => expect(screen.getByText('Logs')).toBeInTheDocument());
await selectAndRerender('select query type', 'Custom Metric Names', onChange, rerender);
await selectAndRerender('select subscription', 'Primary Subscription', onChange, rerender);
await selectAndRerender('select resource group', 'rg', onChange, rerender);
await selectAndRerender('select namespace', 'foo/bar', onChange, rerender);
await selectAndRerender('select resource', 'foobar', onChange, rerender);
await selectAndRerender('select custom namespace', 'foo/custom', onChange, rerender);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
queryType: AzureQueryType.CustomMetricNamesQuery,
subscription: 'sub',
resourceGroup: 'rg',
namespace: 'foo/bar',
resource: 'foobar',
refId: 'A',
customNamespace: 'foo/custom',
})
);
});
}); });
}); });

View File

@ -3,8 +3,10 @@ import { useEffect, useState } from 'react';
import { useEffectOnce } from 'react-use'; import { useEffectOnce } from 'react-use';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { Alert, Field, Select, Space } from '@grafana/ui'; import { Alert, Field, Select, Space } from '@grafana/ui';
import UrlBuilder from '../../azure_monitor/url_builder';
import DataSource from '../../datasource'; import DataSource from '../../datasource';
import { selectors } from '../../e2e/selectors'; import { selectors } from '../../e2e/selectors';
import { migrateQuery } from '../../grafanaTemplateVariableFns'; import { migrateQuery } from '../../grafanaTemplateVariableFns';
@ -35,6 +37,8 @@ const VariableEditor = (props: Props) => {
{ label: 'Workspaces', value: AzureQueryType.WorkspacesQuery }, { label: 'Workspaces', value: AzureQueryType.WorkspacesQuery },
{ label: 'Resource Graph', value: AzureQueryType.AzureResourceGraph }, { label: 'Resource Graph', value: AzureQueryType.AzureResourceGraph },
{ label: 'Logs', value: AzureQueryType.LogAnalytics }, { label: 'Logs', value: AzureQueryType.LogAnalytics },
{ label: 'Custom Namespaces', value: AzureQueryType.CustomNamespacesQuery },
{ label: 'Custom Metric Names', value: AzureQueryType.CustomMetricNamesQuery },
]; ];
if (typeof props.query === 'object' && props.query.queryType === AzureQueryType.GrafanaTemplateVariableFn) { if (typeof props.query === 'object' && props.query.queryType === AzureQueryType.GrafanaTemplateVariableFn) {
// Add the option for the GrafanaTemplateVariableFn only if it's already in use // Add the option for the GrafanaTemplateVariableFn only if it's already in use
@ -53,10 +57,12 @@ const VariableEditor = (props: Props) => {
const [hasRegion, setHasRegion] = useState(false); const [hasRegion, setHasRegion] = useState(false);
const [requireResourceGroup, setRequireResourceGroup] = useState(false); const [requireResourceGroup, setRequireResourceGroup] = useState(false);
const [requireNamespace, setRequireNamespace] = useState(false); const [requireNamespace, setRequireNamespace] = useState(false);
const [requireCustomNamespace, setRequireCustomNamespace] = useState(false);
const [requireResource, setRequireResource] = useState(false); const [requireResource, setRequireResource] = useState(false);
const [subscriptions, setSubscriptions] = useState<SelectableValue[]>([]); const [subscriptions, setSubscriptions] = useState<SelectableValue[]>([]);
const [resourceGroups, setResourceGroups] = useState<SelectableValue[]>([]); const [resourceGroups, setResourceGroups] = useState<SelectableValue[]>([]);
const [namespaces, setNamespaces] = useState<SelectableValue[]>([]); const [namespaces, setNamespaces] = useState<SelectableValue[]>([]);
const [customNamespaces, setCustomNamespaces] = useState<SelectableValue[]>([]);
const [resources, setResources] = useState<SelectableValue[]>([]); const [resources, setResources] = useState<SelectableValue[]>([]);
const [regions, setRegions] = useState<SelectableValue[]>([]); const [regions, setRegions] = useState<SelectableValue[]>([]);
const [errorMessage, setError] = useLastError(); const [errorMessage, setError] = useLastError();
@ -77,6 +83,7 @@ const VariableEditor = (props: Props) => {
setRequireResourceGroup(false); setRequireResourceGroup(false);
setRequireNamespace(false); setRequireNamespace(false);
setRequireResource(false); setRequireResource(false);
setRequireCustomNamespace(false);
switch (queryType) { switch (queryType) {
case AzureQueryType.ResourceGroupsQuery: case AzureQueryType.ResourceGroupsQuery:
case AzureQueryType.WorkspacesQuery: case AzureQueryType.WorkspacesQuery:
@ -101,6 +108,19 @@ const VariableEditor = (props: Props) => {
case AzureQueryType.LocationsQuery: case AzureQueryType.LocationsQuery:
setRequireSubscription(true); setRequireSubscription(true);
break; break;
case AzureQueryType.CustomNamespacesQuery:
setRequireSubscription(true);
setRequireResourceGroup(true);
setRequireNamespace(true);
setRequireResource(true);
break;
case AzureQueryType.CustomMetricNamesQuery:
setRequireSubscription(true);
setRequireResourceGroup(true);
setRequireResource(true);
setRequireNamespace(true);
setRequireCustomNamespace(true);
break;
} }
}, [queryType]); }, [queryType]);
@ -117,6 +137,7 @@ const VariableEditor = (props: Props) => {
}); });
}, [datasource, queryType]); }, [datasource, queryType]);
// Always retrieve subscriptions first as they're used in most template variable queries
useEffectOnce(() => { useEffectOnce(() => {
datasource.getSubscriptions().then((subs) => { datasource.getSubscriptions().then((subs) => {
setSubscriptions(subs.map((s) => ({ label: s.text, value: s.value }))); setSubscriptions(subs.map((s) => ({ label: s.text, value: s.value })));
@ -124,6 +145,7 @@ const VariableEditor = (props: Props) => {
}); });
const subscription = typeof query === 'object' && query.subscription; const subscription = typeof query === 'object' && query.subscription;
// When subscription is set, retrieve resource groups
useEffect(() => { useEffect(() => {
if (subscription) { if (subscription) {
datasource.getResourceGroups(subscription).then((rgs) => { datasource.getResourceGroups(subscription).then((rgs) => {
@ -133,14 +155,16 @@ const VariableEditor = (props: Props) => {
}, [datasource, subscription]); }, [datasource, subscription]);
const resourceGroup = (typeof query === 'object' && query.resourceGroup) || ''; const resourceGroup = (typeof query === 'object' && query.resourceGroup) || '';
// When resource group is set, retrieve metric namespaces (aka resource types for a custom metric and custom metric namespace query)
useEffect(() => { useEffect(() => {
if (subscription) { if (subscription && resourceGroup) {
datasource.getMetricNamespaces(subscription, resourceGroup).then((rgs) => { datasource.getMetricNamespaces(subscription, resourceGroup).then((rgs) => {
setNamespaces(rgs.map((s) => ({ label: s.text, value: s.value }))); setNamespaces(rgs.map((s) => ({ label: s.text, value: s.value })));
}); });
} }
}, [datasource, subscription, resourceGroup]); }, [datasource, subscription, resourceGroup]);
// When subscription is set also retrieve locations
useEffect(() => { useEffect(() => {
if (subscription) { if (subscription) {
datasource.azureMonitorDatasource.getLocations([subscription]).then((rgs) => { datasource.azureMonitorDatasource.getLocations([subscription]).then((rgs) => {
@ -152,14 +176,31 @@ const VariableEditor = (props: Props) => {
}, [datasource, subscription, resourceGroup]); }, [datasource, subscription, resourceGroup]);
const namespace = (typeof query === 'object' && query.namespace) || ''; const namespace = (typeof query === 'object' && query.namespace) || '';
// When subscription, resource group, and namespace are all set, retrieve resource names
useEffect(() => { useEffect(() => {
if (subscription) { if (subscription && resourceGroup && namespace) {
datasource.getResourceNames(subscription, resourceGroup, namespace).then((rgs) => { datasource.getResourceNames(subscription, resourceGroup, namespace).then((rgs) => {
setResources(rgs.map((s) => ({ label: s.text, value: s.value }))); setResources(rgs.map((s) => ({ label: s.text, value: s.value })));
}); });
} }
}, [datasource, subscription, resourceGroup, namespace]); }, [datasource, subscription, resourceGroup, namespace]);
const resource = (typeof query === 'object' && query.resource) || '';
// When subscription, resource group, namespace, and resource name are all set, retrieve custom metric namespaces
useEffect(() => {
if (subscription && resourceGroup && namespace && resource) {
const resourceUri = UrlBuilder.buildResourceUri(getTemplateSrv(), {
subscription,
resourceGroup,
metricNamespace: namespace,
resourceName: resource,
});
datasource.getMetricNamespaces(subscription, resourceGroup, resourceUri, true).then((rgs) => {
setCustomNamespaces(rgs.map((s) => ({ label: s.text, value: s.value })));
});
}
}, [datasource, subscription, resourceGroup, namespace, resource]);
if (typeof query === 'string') { if (typeof query === 'string') {
// still migrating the query // still migrating the query
return null; return null;
@ -225,6 +266,13 @@ const VariableEditor = (props: Props) => {
onChange(queryChange); onChange(queryChange);
}; };
const onChangeCustomNamespace = (selectableValue: SelectableValue) => {
onChange({
...query,
customNamespace: selectableValue.value,
});
};
return ( return (
<> <>
<Field label="Query Type" data-testid={selectors.components.variableEditor.queryType.input}> <Field label="Query Type" data-testid={selectors.components.variableEditor.queryType.input}>
@ -289,7 +337,14 @@ const VariableEditor = (props: Props) => {
</Field> </Field>
)} )}
{(requireNamespace || hasNamespace) && ( {(requireNamespace || hasNamespace) && (
<Field label="Namespace" data-testid={selectors.components.variableEditor.namespace.input}> <Field
label={
queryType === AzureQueryType.CustomNamespacesQuery || queryType === AzureQueryType.CustomMetricNamesQuery
? 'Resource Type'
: 'Namespace'
}
data-testid={selectors.components.variableEditor.namespace.input}
>
<Select <Select
aria-label="select namespace" aria-label="select namespace"
onChange={onChangeNamespace} onChange={onChangeNamespace}
@ -327,6 +382,22 @@ const VariableEditor = (props: Props) => {
/> />
</Field> </Field>
)} )}
{requireCustomNamespace && (
<Field label={'Custom Namespace'} data-testid={selectors.components.variableEditor.customNamespace.input}>
<Select
aria-label="select custom namespace"
onChange={onChangeCustomNamespace}
options={
requireCustomNamespace
? customNamespaces.concat(variableOptionGroup)
: customNamespaces.concat(variableOptionGroup, removeOption)
}
width={25}
value={query.customNamespace || null}
placeholder={requireCustomNamespace ? undefined : 'Optional'}
/>
</Field>
)}
{query.queryType === AzureQueryType.AzureResourceGraph && ( {query.queryType === AzureQueryType.AzureResourceGraph && (
<> <>
<ArgQueryEditor <ArgQueryEditor

View File

@ -27,6 +27,7 @@ composableKinds: DataQuery: {
schema: { schema: {
#AzureMonitorQuery: common.DataQuery & { #AzureMonitorQuery: common.DataQuery & {
// Azure subscription containing the resource(s) to be queried. // Azure subscription containing the resource(s) to be queried.
// Also used for template variable queries
subscription?: string subscription?: string
// Subscriptions to be queried via Azure Resource Graph. // Subscriptions to be queried via Azure Resource Graph.
@ -43,11 +44,16 @@ composableKinds: DataQuery: {
// @deprecated Legacy template variable support. // @deprecated Legacy template variable support.
grafanaTemplateVariableFn?: #GrafanaTemplateVariableQuery grafanaTemplateVariableFn?: #GrafanaTemplateVariableQuery
// Template variables params. These exist for backwards compatiblity with legacy template variables. // Resource group used in template variable queries
resourceGroup?: string resourceGroup?: string
// Namespace used in template variable queries
namespace?: string namespace?: string
// Resource used in template variable queries
resource?: string resource?: string
// Region used in template variable queries
region?: string region?: string
// Custom namespace used in template variable queries
customNamespace?: string
// Azure Monitor query type. // Azure Monitor query type.
// queryType: #AzureQueryType // queryType: #AzureQueryType
@ -56,7 +62,7 @@ composableKinds: DataQuery: {
} @cuetsy(kind="interface") @grafana(TSVeneer="type") } @cuetsy(kind="interface") @grafana(TSVeneer="type")
// Defines the supported queryTypes. GrafanaTemplateVariableFn is deprecated // Defines the supported queryTypes. GrafanaTemplateVariableFn is deprecated
#AzureQueryType: "Azure Monitor" | "Azure Log Analytics" | "Azure Resource Graph" | "Azure Traces" | "Azure Subscriptions" | "Azure Resource Groups" | "Azure Namespaces" | "Azure Resource Names" | "Azure Metric Names" | "Azure Workspaces" | "Azure Regions" | "Grafana Template Variable Function" | "traceql" @cuetsy(kind="enum", memberNames="AzureMonitor|LogAnalytics|AzureResourceGraph|AzureTraces|SubscriptionsQuery|ResourceGroupsQuery|NamespacesQuery|ResourceNamesQuery|MetricNamesQuery|WorkspacesQuery|LocationsQuery|GrafanaTemplateVariableFn|TraceExemplar") #AzureQueryType: "Azure Monitor" | "Azure Log Analytics" | "Azure Resource Graph" | "Azure Traces" | "Azure Subscriptions" | "Azure Resource Groups" | "Azure Namespaces" | "Azure Resource Names" | "Azure Metric Names" | "Azure Workspaces" | "Azure Regions" | "Grafana Template Variable Function" | "traceql" | "Azure Custom Namespaces" | "Azure Custom Metric Names" @cuetsy(kind="enum", memberNames="AzureMonitor|LogAnalytics|AzureResourceGraph|AzureTraces|SubscriptionsQuery|ResourceGroupsQuery|NamespacesQuery|ResourceNamesQuery|MetricNamesQuery|WorkspacesQuery|LocationsQuery|GrafanaTemplateVariableFn|TraceExemplar|CustomNamespacesQuery|CustomMetricNamesQuery")
#AzureMetricQuery: { #AzureMetricQuery: {
// Array of resource URIs to be queried. // Array of resource URIs to be queried.

View File

@ -27,23 +27,37 @@ export interface AzureMonitorQuery extends common.DataQuery {
* Application Insights Traces sub-query properties. * Application Insights Traces sub-query properties.
*/ */
azureTraces?: AzureTracesQuery; azureTraces?: AzureTracesQuery;
/**
* Custom namespace used in template variable queries
*/
customNamespace?: string;
/** /**
* @deprecated Legacy template variable support. * @deprecated Legacy template variable support.
*/ */
grafanaTemplateVariableFn?: GrafanaTemplateVariableQuery; grafanaTemplateVariableFn?: GrafanaTemplateVariableQuery;
/**
* Namespace used in template variable queries
*/
namespace?: string; namespace?: string;
/** /**
* Used only for exemplar queries from Prometheus * Used only for exemplar queries from Prometheus
*/ */
query?: string; query?: string;
/**
* Region used in template variable queries
*/
region?: string; region?: string;
/**
* Resource used in template variable queries
*/
resource?: string; resource?: string;
/** /**
* Template variables params. These exist for backwards compatiblity with legacy template variables. * Resource group used in template variable queries
*/ */
resourceGroup?: string; resourceGroup?: string;
/** /**
* Azure subscription containing the resource(s) to be queried. * Azure subscription containing the resource(s) to be queried.
* Also used for template variable queries
*/ */
subscription?: string; subscription?: string;
/** /**
@ -63,6 +77,8 @@ export enum AzureQueryType {
AzureMonitor = 'Azure Monitor', AzureMonitor = 'Azure Monitor',
AzureResourceGraph = 'Azure Resource Graph', AzureResourceGraph = 'Azure Resource Graph',
AzureTraces = 'Azure Traces', AzureTraces = 'Azure Traces',
CustomMetricNamesQuery = 'Azure Custom Metric Names',
CustomNamespacesQuery = 'Azure Custom Namespaces',
GrafanaTemplateVariableFn = 'Grafana Template Variable Function', GrafanaTemplateVariableFn = 'Grafana Template Variable Function',
LocationsQuery = 'Azure Regions', LocationsQuery = 'Azure Regions',
LogAnalytics = 'Azure Log Analytics', LogAnalytics = 'Azure Log Analytics',

View File

@ -168,24 +168,41 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
return this.azureMonitorDatasource.getResourceGroups(this.templateSrv.replace(subscriptionId)); return this.azureMonitorDatasource.getResourceGroups(this.templateSrv.replace(subscriptionId));
} }
getMetricNamespaces(subscriptionId: string, resourceGroup?: string) { getMetricNamespaces(subscriptionId: string, resourceGroup?: string, resourceUri?: string, custom?: boolean) {
let url = `/subscriptions/${subscriptionId}`; let url = `/subscriptions/${subscriptionId}`;
if (resourceGroup) { if (resourceGroup) {
url += `/resourceGroups/${resourceGroup}`; url += `/resourceGroups/${resourceGroup}`;
} }
return this.azureMonitorDatasource.getMetricNamespaces({ resourceUri: url }, true); if (resourceUri) {
url = resourceUri;
}
return this.azureMonitorDatasource.getMetricNamespaces(
{ resourceUri: url },
// If custom namespaces are being queried we do not issue the query against the global region
// as resources have a specific region
custom ? false : true,
undefined,
custom
);
} }
getResourceNames(subscriptionId: string, resourceGroup?: string, metricNamespace?: string, region?: string) { getResourceNames(subscriptionId: string, resourceGroup?: string, metricNamespace?: string, region?: string) {
return this.azureMonitorDatasource.getResourceNames({ subscriptionId, resourceGroup, metricNamespace, region }); return this.azureMonitorDatasource.getResourceNames({ subscriptionId, resourceGroup, metricNamespace, region });
} }
getMetricNames(subscriptionId: string, resourceGroup: string, metricNamespace: string, resourceName: string) { getMetricNames(
subscriptionId: string,
resourceGroup: string,
metricNamespace: string,
resourceName: string,
customNamespace?: string
) {
return this.azureMonitorDatasource.getMetricNames({ return this.azureMonitorDatasource.getMetricNames({
subscription: subscriptionId, subscription: subscriptionId,
resourceGroup, resourceGroup,
metricNamespace, metricNamespace,
resourceName, resourceName,
customNamespace,
}); });
} }

View File

@ -113,6 +113,9 @@ export const components = {
region: { region: {
input: 'data-testid region', input: 'data-testid region',
}, },
customNamespace: {
input: 'data-testid custom-namespace',
},
}, },
}; };

View File

@ -343,12 +343,8 @@ export interface ResourceGroup {
type: string; type: string;
} }
export interface Namespace { export interface MetricNamespace {
classification: { classification: 'Custom' | 'Platform' | 'Qos';
Custom: string;
Platform: string;
Qos: string;
};
id: string; id: string;
name: string; name: string;
properties: { metricNamespaceName: string }; properties: { metricNamespaceName: string };

View File

@ -543,4 +543,88 @@ describe('VariableSupport', () => {
expect(result.data[0].fields[0].values).toEqual(expectedResults); expect(result.data[0].fields[0].values).toEqual(expectedResults);
}); });
}); });
it('can fetch custom namespaces', async () => {
const expectedResults = ['test-custom/namespace'];
const variableSupport = new VariableSupport(
createMockDatasource({
getMetricNamespaces: jest.fn().mockResolvedValueOnce(expectedResults),
})
);
const mockRequest = {
targets: [
{
refId: 'A',
queryType: AzureQueryType.CustomNamespacesQuery,
subscription: 'sub',
resourceGroup: 'rg',
namespace: 'ns',
resource: 'rn',
} as AzureMonitorQuery,
],
} as DataQueryRequest<AzureMonitorQuery>;
const result = await lastValueFrom(variableSupport.query(mockRequest));
expect(result.data[0].fields[0].values).toEqual(expectedResults);
});
it('returns no data if calling custom namespaces but the subscription is a template variable with no value', async () => {
const variableSupport = new VariableSupport(createMockDatasource());
const mockRequest = {
targets: [
{
refId: 'A',
queryType: AzureQueryType.CustomNamespacesQuery,
subscription: '$sub',
resourceGroup: 'rg',
namespace: 'ns',
resource: 'rn',
} as AzureMonitorQuery,
],
} as DataQueryRequest<AzureMonitorQuery>;
const result = await lastValueFrom(variableSupport.query(mockRequest));
expect(result.data).toEqual([]);
});
it('can fetch custom metric names', async () => {
const expectedResults = ['test-custom-metric'];
const variableSupport = new VariableSupport(
createMockDatasource({
getMetricNames: jest.fn().mockResolvedValueOnce(expectedResults),
})
);
const mockRequest = {
targets: [
{
refId: 'A',
queryType: AzureQueryType.CustomMetricNamesQuery,
subscription: 'sub',
resourceGroup: 'rg',
namespace: 'ns',
resource: 'rn',
customNamespace: 'test-custom/namespace',
} as AzureMonitorQuery,
],
} as DataQueryRequest<AzureMonitorQuery>;
const result = await lastValueFrom(variableSupport.query(mockRequest));
expect(result.data[0].fields[0].values).toEqual(expectedResults);
});
it('returns no data if calling custom metric names but the subscription is a template variable with no value', async () => {
const variableSupport = new VariableSupport(createMockDatasource());
const mockRequest = {
targets: [
{
refId: 'A',
queryType: AzureQueryType.CustomMetricNamesQuery,
subscription: '$sub',
resourceGroup: 'rg',
namespace: 'ns',
resource: 'rn',
customNamespace: 'test-custom/namespace',
} as AzureMonitorQuery,
],
} as DataQueryRequest<AzureMonitorQuery>;
const result = await lastValueFrom(variableSupport.query(mockRequest));
expect(result.data).toEqual([]);
});
}); });

View File

@ -9,6 +9,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import UrlBuilder from './azure_monitor/url_builder';
import VariableEditor from './components/VariableEditor/VariableEditor'; import VariableEditor from './components/VariableEditor/VariableEditor';
import DataSource from './datasource'; import DataSource from './datasource';
import { migrateQuery } from './grafanaTemplateVariableFns'; import { migrateQuery } from './grafanaTemplateVariableFns';
@ -119,6 +120,58 @@ export class VariableSupport extends CustomVariableSupport<DataSource, AzureMoni
data: res?.length ? [toDataFrame(res)] : [], data: res?.length ? [toDataFrame(res)] : [],
}; };
} }
case AzureQueryType.CustomNamespacesQuery:
if (
queryObj.subscription &&
queryObj.resourceGroup &&
queryObj.namespace &&
queryObj.resource &&
this.hasValue(queryObj.subscription, queryObj.resourceGroup, queryObj.namespace, queryObj.resource)
) {
const resourceUri = UrlBuilder.buildResourceUri(this.templateSrv, {
subscription: queryObj.subscription,
resourceGroup: queryObj.resourceGroup,
metricNamespace: queryObj.namespace,
resourceName: queryObj.resource,
});
const res = await this.datasource.getMetricNamespaces(
queryObj.subscription,
queryObj.resourceGroup,
resourceUri,
true
);
return {
data: res?.length ? [toDataFrame(res)] : [],
};
}
return { data: [] };
case AzureQueryType.CustomMetricNamesQuery:
if (
queryObj.subscription &&
queryObj.resourceGroup &&
queryObj.namespace &&
queryObj.resource &&
queryObj.customNamespace &&
this.hasValue(
queryObj.subscription,
queryObj.resourceGroup,
queryObj.namespace,
queryObj.resource,
queryObj.customNamespace
)
) {
const rgs = await this.datasource.getMetricNames(
queryObj.subscription,
queryObj.resourceGroup,
queryObj.namespace,
queryObj.resource,
queryObj.customNamespace
);
return {
data: rgs?.length ? [toDataFrame(rgs)] : [],
};
}
return { data: [] };
default: default:
request.targets[0] = queryObj; request.targets[0] = queryObj;
const queryResp = await lastValueFrom(this.datasource.query(request)); const queryResp = await lastValueFrom(this.datasource.query(request));