mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -202,4 +202,5 @@ export interface FeatureToggles {
|
||||
backgroundPluginInstaller?: boolean;
|
||||
dataplaneAggregator?: boolean;
|
||||
adhocFilterOneOf?: boolean;
|
||||
lokiSendDashboardPanelNames?: boolean;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(', ');
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -742,4 +742,8 @@ const (
|
||||
// FlagAdhocFilterOneOf
|
||||
// Exposes a new 'one of' 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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user