Loki: Add dashboard and panel names as headers (#92096)

* feat(nameHeaders): add feature flag

* add safe parsing of headers

* use headers in loki datasource

* Loki: add option to pass headers to Loki

* Loki: add datasource tests for dashboard names

* cleanup

* DataSourceWithBackend: add test

* rename to `sanitizeHeader`

* Loki: add condition when to add headers

* Loki: add e2e tests

* Loki: change test name
This commit is contained in:
Sven Grossmann
2024-08-22 21:30:43 +02:00
committed by GitHub
parent a7b57be04f
commit ec857e1de9
15 changed files with 307 additions and 7 deletions

View File

@@ -193,6 +193,7 @@ Experimental features might be changed or removed without prior notice.
| `backgroundPluginInstaller` | Enable background plugin installer |
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
| `adhocFilterOneOf` | Exposes a new 'one of' operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter. |
| `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying |
## Development feature toggles

View File

@@ -558,6 +558,7 @@ export interface DataQueryRequest<TQuery extends DataQuery = DataQuery> {
panelId?: number;
panelPluginId?: string;
dashboardUID?: string;
headers?: Record<string, string>;
/** Filters to dynamically apply to all queries */
filters?: AdHocVariableFilter[];

View File

@@ -202,4 +202,5 @@ export interface FeatureToggles {
backgroundPluginInstaller?: boolean;
dataplaneAggregator?: boolean;
adhocFilterOneOf?: boolean;
lokiSendDashboardPanelNames?: boolean;
}

View File

@@ -140,6 +140,85 @@ describe('DataSourceWithBackend', () => {
`);
});
test('correctly passes datasource headers', () => {
const { mock, ds } = createMockDatasource();
ds.query({
maxDataPoints: 10,
intervalMs: 5000,
targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }],
dashboardUID: 'dashA',
panelId: 123,
filters: [{ key: 'key1', operator: '=', value: 'val1' }],
range: getDefaultTimeRange(),
queryGroupId: 'abc',
interval: '5s',
scopedVars: {},
timezone: '',
requestId: 'request-123',
startTime: 0,
app: '',
headers: {
'X-Test-Header': 'test',
},
});
const args = mock.calls[0][0];
expect(mock.calls.length).toBe(1);
expect(args).toMatchInlineSnapshot(`
{
"data": {
"from": "1697133600000",
"queries": [
{
"applyTemplateVariablesCalled": true,
"datasource": {
"type": "dummy",
"uid": "abc",
},
"datasourceId": 1234,
"filters": [
{
"key": "key1",
"operator": "=",
"value": "val1",
},
],
"intervalMs": 5000,
"maxDataPoints": 10,
"queryCachingTTL": undefined,
"refId": "A",
},
{
"datasource": {
"type": "sample",
"uid": "<mockuid>",
},
"datasourceId": undefined,
"intervalMs": 5000,
"maxDataPoints": 10,
"queryCachingTTL": undefined,
"refId": "B",
},
],
"to": "1697155200000",
},
"headers": {
"X-Dashboard-Uid": "dashA",
"X-Datasource-Uid": "abc, <mockuid>",
"X-Panel-Id": "123",
"X-Plugin-Id": "dummy, sample",
"X-Query-Group-Id": "abc",
"X-Test-Header": "test",
},
"hideFromInspector": false,
"method": "POST",
"requestId": "request-123",
"url": "/api/ds/query?ds_type=dummy&requestId=request-123",
}
`);
});
test('correctly creates expression queries', () => {
const { mock, ds } = createMockDatasource();
ds.query({

View File

@@ -201,7 +201,7 @@ class DataSourceWithBackend<
});
}
const headers: Record<string, string> = {};
const headers: Record<string, string> = request.headers ?? {};
headers[PluginRequestHeaders.PluginID] = Array.from(pluginIDs).join(', ');
headers[PluginRequestHeaders.DatasourceUID] = Array.from(dsUIDs).join(', ');

View File

@@ -1393,6 +1393,12 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaDashboardsSquad,
},
{
Name: "lokiSendDashboardPanelNames",
Description: "Send dashboard and panel names to Loki when querying",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityLogsSquad,
},
}
)

View File

@@ -183,3 +183,4 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa
backgroundPluginInstaller,experimental,@grafana/plugins-platform-backend,false,true,false
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
adhocFilterOneOf,experimental,@grafana/dashboards-squad,false,false,false
lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
183 backgroundPluginInstaller experimental @grafana/plugins-platform-backend false true false
184 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
185 adhocFilterOneOf experimental @grafana/dashboards-squad false false false
186 lokiSendDashboardPanelNames experimental @grafana/observability-logs false false false

View File

@@ -742,4 +742,8 @@ const (
// FlagAdhocFilterOneOf
// Exposes a new &#39;one of&#39; operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter.
FlagAdhocFilterOneOf = "adhocFilterOneOf"
// FlagLokiSendDashboardPanelNames
// Send dashboard and panel names to Loki when querying
FlagLokiSendDashboardPanelNames = "lokiSendDashboardPanelNames"
)

View File

@@ -1704,6 +1704,21 @@
"codeowner": "@grafana/observability-logs"
}
},
{
"metadata": {
"name": "lokiSendDashboardPanelNames",
"resourceVersion": "1724089497989",
"creationTimestamp": "2024-08-19T17:44:18Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-08-19 17:44:57.989565 +0000 UTC"
}
},
"spec": {
"description": "Send dashboard and panel names to Loki when querying",
"stage": "experimental",
"codeowner": "@grafana/observability-logs"
}
},
{
"metadata": {
"name": "lokiStructuredMetadata",

View File

@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -30,7 +31,8 @@ func TestIntegrationLoki(t *testing.T) {
t.Skip("skipping integration test")
}
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{featuremgmt.FlagLokiSendDashboardPanelNames},
})
grafanaListeningAddr, testEnv := testinfra.StartGrafanaEnv(t, dir, path)
@@ -106,4 +108,87 @@ func TestIntegrationLoki(t *testing.T) {
require.Equal(t, "basicAuthUser", username)
require.Equal(t, "basicAuthPassword", pwd)
})
t.Run("should forward `X-Dashboard-Title` header but no `X-Panel-Title`", func(t *testing.T) {
query := simplejson.NewFromAny(map[string]interface{}{
"datasource": map[string]interface{}{
"uid": uid,
},
"expr": "{job=\"grafana\"}",
})
buf1 := &bytes.Buffer{}
err = json.NewEncoder(buf1).Encode(dtos.MetricRequest{
From: "now-1h",
To: "now",
Queries: []*simplejson.Json{query},
})
require.NoError(t, err)
u := fmt.Sprintf("http://admin:admin@%s/api/ds/query", grafanaListeningAddr)
req, err := http.NewRequest("POST", u, buf1)
if err != nil {
require.NoError(t, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Dashboard-Title", "My Dashboard Title")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NotNil(t, outgoingRequest)
require.Equal(t, "My Dashboard Title", outgoingRequest.Header.Get("X-Dashboard-Title"))
require.Equal(t, "", outgoingRequest.Header.Get("X-Panel-Title"))
username, pwd, ok := outgoingRequest.BasicAuth()
require.True(t, ok)
require.Equal(t, "basicAuthUser", username)
require.Equal(t, "basicAuthPassword", pwd)
})
t.Run("should forward `X-Dashboard-Title` and `X-Panel-Title` header", func(t *testing.T) {
query := simplejson.NewFromAny(map[string]interface{}{
"datasource": map[string]interface{}{
"uid": uid,
},
"expr": "{job=\"grafana\"}",
})
buf1 := &bytes.Buffer{}
err = json.NewEncoder(buf1).Encode(dtos.MetricRequest{
From: "now-1h",
To: "now",
Queries: []*simplejson.Json{query},
})
require.NoError(t, err)
u := fmt.Sprintf("http://admin:admin@%s/api/ds/query", grafanaListeningAddr)
req, err := http.NewRequest("POST", u, buf1)
if err != nil {
require.NoError(t, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Dashboard-Title", "My Dashboard Title")
req.Header.Set("X-Panel-Title", "My Panel Title")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.NotNil(t, outgoingRequest)
require.Equal(t, "My Dashboard Title", outgoingRequest.Header.Get("X-Dashboard-Title"))
require.Equal(t, "My Panel Title", outgoingRequest.Header.Get("X-Panel-Title"))
username, pwd, ok := outgoingRequest.BasicAuth()
require.True(t, ok)
require.Equal(t, "basicAuthUser", username)
require.Equal(t, "basicAuthPassword", pwd)
})
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/featuremgmt"
ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/tsdb/loki/kinds/dataquery"
@@ -49,13 +50,13 @@ func ProvideService(httpClientProvider *httpclient.Provider, tracer tracing.Trac
var (
legendFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
)
// Used in logging to mark a stage
var (
stagePrepareRequest = "prepareRequest"
stageDatabaseRequest = "databaseRequest"
stageParseResponse = "parseResponse"
dashboardTitleHeader = "X-Dashboard-Title"
panelTitleHeader = "X-Panel-Title"
)
type datasourceInfo struct {
@@ -163,9 +164,30 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
logsDataplane: isFeatureEnabled(ctx, featuremgmt.FlagLokiLogsDataplane),
}
if isFeatureEnabled(ctx, featuremgmt.FlagLokiSendDashboardPanelNames) {
s.applyHeaders(ctx, req)
}
return queryData(ctx, req, dsInfo, responseOpts, s.tracer, logger, isFeatureEnabled(ctx, featuremgmt.FlagLokiRunQueriesInParallel), isFeatureEnabled(ctx, featuremgmt.FlagLokiStructuredMetadata))
}
func (s *Service) applyHeaders(ctx context.Context, req backend.ForwardHTTPHeaders) {
reqCtx := contexthandler.FromContext(ctx)
if req == nil || reqCtx == nil || reqCtx.Req == nil {
return
}
var hList = []string{dashboardTitleHeader, panelTitleHeader}
for _, hName := range hList {
hVal := reqCtx.Req.Header.Get(hName)
if hVal == "" {
continue
}
req.SetHTTPHeader(hName, hVal)
}
}
func queryData(ctx context.Context, req *backend.QueryDataRequest, dsInfo *datasourceInfo, responseOpts ResponseOpts, tracer tracing.Tracer, plog log.Logger, runInParallel bool, requestStructuredMetadata bool) (*backend.QueryDataResponse, error) {
result := backend.NewQueryDataResponse()

View File

@@ -52,7 +52,6 @@ describe('parseInitFromOptions', () => {
describe('parseHeaders', () => {
it.each`
options | expected
${undefined} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ propKey: 'some prop value' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ method: 'GET' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ method: 'POST' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
@@ -70,6 +69,8 @@ describe('parseHeaders', () => {
${{ method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ headers: { Accept: 'text/plain' } }} | ${{ map: { accept: 'text/plain' } }}
${{ headers: { Auth: 'Basic asdasdasd' } }} | ${{ map: { accept: 'application/json, text/plain, */*', auth: 'Basic asdasdasd' } }}
${{ headers: { Key: '🚀' } }} | ${{ map: { key: '%F0%9F%9A%80', accept: 'application/json, text/plain, */*' } }}
${{ headers: { '🚀': 'value' } }} | ${{ map: { '%f0%9f%9a%80': 'value', accept: 'application/json, text/plain, */*' } }}
`("when called with options: '$options' then the result should be '$expected'", ({ options, expected }) => {
expect(parseHeaders(options)).toEqual(expected);
});

View File

@@ -57,9 +57,22 @@ const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put');
const patchHeaderParser: HeaderParser = parseHeaderByMethodFactory('patch');
const headerParsers = [postHeaderParser, putHeaderParser, patchHeaderParser, defaultHeaderParser];
const unsafeCharacters = /[^\u0000-\u00ff]/g;
/**
* Header values can only contain ISO-8859-1 characters. If a header key or value contains characters outside of this, we will encode the whole value.
* Since `encodeURI` also encodes spaces, we won't encode if the value doesn't contain any unsafe characters.
*/
function sanitizeHeader(v: string) {
return unsafeCharacters.test(v) ? encodeURI(v) : v;
}
export const parseHeaders = (options: BackendSrvRequest) => {
const headers = options?.headers ? new Headers(options.headers) : new Headers();
const safeHeaders: Record<string, string> = {};
for (let [key, value] of Object.entries(options.headers ?? {})) {
safeHeaders[sanitizeHeader(key)] = sanitizeHeader(value);
}
const headers = new Headers(safeHeaders);
const parsers = headerParsers.filter((parser) => parser.canParse(options));
const combinedHeaders = parsers.reduce((prev, parser) => {
return parser.parse(prev);

View File

@@ -31,6 +31,7 @@ import {
setBackendSrv,
TemplateSrv,
} from '@grafana/runtime';
import { DashboardSrv, setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { LokiVariableSupport } from './LokiVariableSupport';
import { createLokiDatasource } from './__mocks__/datasource';
@@ -1701,6 +1702,46 @@ describe('LokiDatasource', () => {
});
});
describe('query', () => {
let featureToggleVal = config.featureToggles.lokiSendDashboardPanelNames;
beforeEach(() => {
setDashboardSrv({
getCurrent: () => ({
title: 'dashboard_title',
panels: [{ title: 'panel_title', id: 0 }],
}),
} as unknown as DashboardSrv);
const fetchMock = jest.fn().mockReturnValue(of({ data: testLogsResponse }));
setBackendSrv({ ...origBackendSrv, fetch: fetchMock });
config.featureToggles.lokiSendDashboardPanelNames = true;
});
afterEach(() => {
config.featureToggles.lokiSendDashboardPanelNames = featureToggleVal;
});
it('adds dashboard headers', async () => {
const ds = createLokiDatasource(templateSrvStub);
jest.spyOn(ds, 'runQuery');
const query: DataQueryRequest<LokiQuery> = {
...baseRequestOptions,
panelId: 0,
targets: [{ expr: '{a="b"}', refId: 'A' }],
app: CoreApp.Dashboard,
};
await expect(ds.query(query)).toEmitValuesWith(() => {
expect(ds.runQuery).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
'X-Dashboard-Title': 'dashboard_title',
'X-Panel-Title': 'panel_title',
}),
})
);
});
});
});
describe('getQueryStats', () => {
let ds: LokiDatasource;
let query: LokiQuery;

View File

@@ -46,6 +46,7 @@ import {
import { Duration } from '@grafana/lezer-logql';
import { BackendSrvRequest, config, DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import LanguageProvider from './LanguageProvider';
import { LiveStreams, LokiLiveTarget } from './LiveStreams';
@@ -289,6 +290,33 @@ export class LokiDatasource
return { ...logsSampleRequest, targets };
}
private getQueryHeaders(request: DataQueryRequest<LokiQuery>): Record<string, string> {
const headers: Record<string, string> = {};
if (!config.featureToggles.lokiSendDashboardPanelNames) {
return headers;
}
// only add headers if we are in the context of a dashboard
if (
[CoreApp.Dashboard.toString(), CoreApp.PanelEditor.toString(), CoreApp.PanelViewer.toString()].includes(
request.app
) === false
) {
return headers;
}
const dashboard = getDashboardSrv().getCurrent();
const dashboardTitle = dashboard?.title;
const panelTitle = dashboard?.panels.find((p) => p.id === request?.panelId)?.title;
if (dashboardTitle) {
headers['X-Dashboard-Title'] = dashboardTitle;
}
if (panelTitle) {
headers['X-Panel-Title'] = panelTitle;
}
return headers;
}
/**
* Required by DataSourceApi. It executes queries based on the provided DataQueryRequest.
* @returns An Observable of DataQueryResponse containing the query results.
@@ -303,6 +331,8 @@ export class LokiDatasource
targets: queries,
};
fixedRequest.headers = this.getQueryHeaders(request);
const streamQueries = fixedRequest.targets.filter((q) => q.queryType === LokiQueryType.Stream);
if (
config.featureToggles.lokiExperimentalStreaming &&