Tempo: Migrates tags and tag values to datasource backend CallResource requests (#110511)

* Move tags and tag values request to datasource backend

* Remove outdated test

* Fix tests

* lint

* Refactor to use handlers for CallResource request

* lint

* Fix nit
This commit is contained in:
Andre Pereira
2025-09-09 17:31:32 +01:00
committed by GitHub
parent fc0db985c6
commit d26c6c112a
12 changed files with 268 additions and 311 deletions
@@ -235,7 +235,7 @@ func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient
case Prometheus:
svc = prometheus.ProvideService(httpClientProvider)
case Tempo:
svc = tempo.ProvideService(httpClientProvider)
svc = tempo.ProvideService(httpClientProvider, tracer)
case PostgreSQL:
svc = postgres.ProvideService(cfg, features)
case MySQL:
+2 -2
View File
@@ -389,7 +389,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
lokiService := loki.ProvideService(httpclientProvider, tracer)
opentsdbService := opentsdb.ProvideService(httpclientProvider)
prometheusService := prometheus.ProvideService(httpclientProvider)
tempoService := tempo.ProvideService(httpclientProvider)
tempoService := tempo.ProvideService(httpclientProvider, tracer)
testdatasourceService := testdatasource.ProvideService()
postgresService := postgres.ProvideService(cfg, featureToggles)
mysqlService := mysql.ProvideService()
@@ -976,7 +976,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
lokiService := loki.ProvideService(httpclientProvider, tracer)
opentsdbService := opentsdb.ProvideService(httpclientProvider)
prometheusService := prometheus.ProvideService(httpclientProvider)
tempoService := tempo.ProvideService(httpclientProvider)
tempoService := tempo.ProvideService(httpclientProvider, tracer)
testdatasourceService := testdatasource.ProvideService()
postgresService := postgres.ProvideService(cfg, featureToggles)
mysqlService := mysql.ProvideService()
@@ -159,7 +159,7 @@ func TestIntegrationPluginManager(t *testing.T) {
lk := loki.ProvideService(hcp, tracer)
otsdb := opentsdb.ProvideService(hcp)
pr := prometheus.ProvideService(hcp)
tmpo := tempo.ProvideService(hcp)
tmpo := tempo.ProvideService(hcp, tracer)
td := testdatasource.ProvideService()
pg := postgres.ProvideService(cfg, features)
my := mysql.ProvideService()
+10 -3
View File
@@ -3,6 +3,8 @@ package main
import (
"context"
"go.opentelemetry.io/otel/trace/noop"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
@@ -11,8 +13,9 @@ import (
)
var (
_ backend.QueryDataHandler = (*Datasource)(nil)
_ backend.StreamHandler = (*Datasource)(nil)
_ backend.QueryDataHandler = (*Datasource)(nil)
_ backend.StreamHandler = (*Datasource)(nil)
_ backend.CallResourceHandler = (*Datasource)(nil)
)
type Datasource struct {
@@ -21,7 +24,7 @@ type Datasource struct {
func NewDatasource(c context.Context, b backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return &Datasource{
Service: tempo.ProvideService(httpclient.NewProvider()),
Service: tempo.ProvideService(httpclient.NewProvider(), noop.NewTracerProvider().Tracer("tempo")),
}, nil
}
@@ -40,3 +43,7 @@ func (d *Datasource) PublishStream(ctx context.Context, req *backend.PublishStre
func (d *Datasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
return d.Service.RunStream(ctx, req, sender)
}
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
return d.Service.CallResource(ctx, req, sender)
}
+143 -4
View File
@@ -3,22 +3,38 @@ package tempo
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"runtime"
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
"github.com/grafana/tempo/pkg/tempopb"
)
var (
_ backend.QueryDataHandler = (*Service)(nil)
_ backend.CallResourceHandler = (*Service)(nil)
)
type Service struct {
im instancemgmt.InstanceManager
logger log.Logger
im instancemgmt.InstanceManager
logger log.Logger
tracer trace.Tracer
resourceHandler backend.CallResourceHandler
}
type DatasourceInfo struct {
@@ -27,11 +43,20 @@ type DatasourceInfo struct {
URL string
}
func ProvideService(httpClientProvider *httpclient.Provider) *Service {
return &Service{
func ProvideService(httpClientProvider *httpclient.Provider, tracer trace.Tracer) *Service {
s := &Service{
im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)),
logger: backend.NewLoggerWith("logger", "tsdb.tempo"),
tracer: tracer,
}
// Set up resource routes using httpadapter
mux := http.NewServeMux()
mux.HandleFunc("/tags", s.handleTags)
mux.HandleFunc("/tag-values", s.handleTagValues)
s.resourceHandler = httpadapter.New(mux)
return s
}
func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc {
@@ -43,6 +68,8 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins
return nil, err
}
opts.ForwardHTTPHeaders = true
client, err := httpClientProvider.New(opts)
if err != nil {
ctxLogger.Error("Failed to get HTTP client provider", "error", err, "function", logEntrypoint())
@@ -123,6 +150,118 @@ func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext
return instance, nil
}
func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
return s.resourceHandler.CallResource(ctx, req, sender)
}
// handleTags handles requests to /tags resource
func (s *Service) handleTags(rw http.ResponseWriter, req *http.Request) {
s.proxyToTempo(rw, req, "api/v2/search/tags")
}
// handleTagValues handles requests to /tag-values resource
func (s *Service) handleTagValues(rw http.ResponseWriter, req *http.Request) {
// Extract the encoded tag from query parameters
encodedTag := req.URL.Query().Get("tag")
if encodedTag == "" {
http.Error(rw, "Missing required 'tag' parameter", http.StatusBadRequest)
return
}
tempoPath := fmt.Sprintf("api/v2/search/tag/%s/values", encodedTag)
s.proxyToTempo(rw, req, tempoPath)
}
// proxyToTempo is the shared function that builds the URL and proxies requests to Tempo
func (s *Service) proxyToTempo(rw http.ResponseWriter, req *http.Request, tempoPath string) {
ctx := req.Context()
pCtx := backend.PluginConfigFromContext(ctx)
// Get datasource info
dsInfo, err := s.getDSInfo(ctx, pCtx)
if err != nil {
s.logger.Error("Failed to get data source info", "error", err)
http.Error(rw, "Failed to get data source configuration", http.StatusInternalServerError)
return
}
ctx, span := s.tracer.Start(ctx, "datasource.tempo.proxyToTempo", trace.WithAttributes(
attribute.String("tempoPath", tempoPath),
))
defer span.End()
// Build the full URL to Tempo
parsedURL, err := url.Parse(dsInfo.URL)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("Failed to parse data source URL", "error", err, "url", dsInfo.URL)
http.Error(rw, "Invalid data source URL", http.StatusInternalServerError)
return
}
// Join the tempo path with the base URL
parsedURL.Path = path.Join(parsedURL.Path, tempoPath)
// Preserve query parameters from the original request
parsedURL.RawQuery = req.URL.RawQuery
s.logger.Debug("Making resource request to Tempo", "url", parsedURL.String())
start := time.Now()
// Create the request to Tempo
httpReq, err := http.NewRequestWithContext(ctx, req.Method, parsedURL.String(), req.Body)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("Failed to create HTTP request", "error", err)
http.Error(rw, "Failed to create request", http.StatusInternalServerError)
return
}
// Copy headers from the original request
for name, values := range req.Header {
for _, value := range values {
httpReq.Header.Add(name, value)
}
}
// Make the request to Tempo
resp, err := dsInfo.HTTPClient.Do(httpReq)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("Failed resource call to Tempo", "error", err, "url", parsedURL.String(), "duration", time.Since(start))
http.Error(rw, "Failed to connect to Tempo", http.StatusBadGateway)
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
s.logger.Warn("Failed to close response body", "error", err)
}
}()
s.logger.Debug("Response received from Tempo", "statusCode", resp.StatusCode, "contentLength", resp.Header.Get("Content-Length"), "duration", time.Since(start))
// Copy response headers
for name, values := range resp.Header {
for _, value := range values {
rw.Header().Add(name, value)
}
}
// Set the status code
rw.WriteHeader(resp.StatusCode)
// Copy the response body
_, err = io.Copy(rw, resp.Body)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("Failed to copy response body", "error", err)
return
}
}
// Return the file, line, and (full-path) function name of the caller
func getRunContext() (string, int, string) {
pc := make([]uintptr, 10)
@@ -8,7 +8,7 @@ import { initTemplateSrv } from '../test/test_utils';
import { Scope } from '../types';
import TagsInput from './TagsInput';
import { v1Tags, v2Tags } from './mocks';
import { v2Tags } from './mocks';
describe('TagsInput', () => {
let user: ReturnType<typeof userEvent.setup>;
@@ -33,22 +33,8 @@ describe('TagsInput', () => {
});
describe('should render correct tags', () => {
it('for API v1 tags', async () => {
renderTagsInput(v1Tags);
const tag = screen.getByText('Select tag');
expect(tag).toBeInTheDocument();
await user.click(tag);
await act(async () => {
jest.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(screen.getByText('bar')).toBeInTheDocument();
});
});
it('for API v2 tags with scope of resource', async () => {
renderTagsInput(undefined, v2Tags, TraceqlSearchScope.Resource);
renderTagsInput(v2Tags, TraceqlSearchScope.Resource);
const tag = screen.getByText('Select tag');
expect(tag).toBeInTheDocument();
@@ -64,7 +50,7 @@ describe('TagsInput', () => {
});
it('for API v2 tags with scope of span', async () => {
renderTagsInput(undefined, v2Tags, TraceqlSearchScope.Span);
renderTagsInput(v2Tags, TraceqlSearchScope.Span);
const tag = screen.getByText('Select tag');
expect(tag).toBeInTheDocument();
@@ -80,7 +66,7 @@ describe('TagsInput', () => {
});
it('for API v2 tags with scope of unscoped', async () => {
renderTagsInput(undefined, v2Tags, TraceqlSearchScope.Unscoped);
renderTagsInput(v2Tags, TraceqlSearchScope.Unscoped);
const tag = screen.getByText('Select tag');
expect(tag).toBeInTheDocument();
@@ -96,7 +82,7 @@ describe('TagsInput', () => {
});
});
const renderTagsInput = (tagsV1?: string[], tagsV2?: Scope[], scope?: TraceqlSearchScope) => {
const renderTagsInput = (tagsV2?: Scope[], scope?: TraceqlSearchScope) => {
const datasource: TempoDatasource = {
search: {
filters: [],
@@ -104,9 +90,7 @@ describe('TagsInput', () => {
} as unknown as TempoDatasource;
const lp = new TempoLanguageProvider(datasource);
if (tagsV1) {
lp.setV1Tags(tagsV1);
} else if (tagsV2) {
if (tagsV2) {
lp.setV2Tags(tagsV2);
}
datasource.languageProvider = lp;
@@ -48,7 +48,7 @@ import {
} from './datasource';
import mockJson from './test/mockJsonResponse.json';
import mockServiceGraph from './test/mockServiceGraph.json';
import { createMetadataRequest, createTempoDatasource } from './test/mocks';
import { createTempoDatasource } from './test/mocks';
import { initTemplateSrv } from './test/test_utils';
import { TempoJsonData, TempoQuery } from './types';
@@ -355,11 +355,11 @@ describe('Tempo data source', () => {
});
describe('test the metadataRequest function', () => {
it('should return the last value from the observed stream', async () => {
mockObservable = () => of('321', '123', '456');
it('should return the data from getResource', async () => {
const ds = new TempoDatasource(defaultSettings);
const response = await ds.metadataRequest('/api/search/tags');
expect(response).toBe('456');
jest.spyOn(ds, 'getResource').mockResolvedValue({ data: 'test-data' });
const response = await ds.metadataRequest('api/v2/search/tags');
expect(response).toBe('test-data');
});
});
@@ -1106,13 +1106,9 @@ describe('label names - v2 tags', () => {
beforeEach(() => {
datasource = createTempoDatasource();
jest.spyOn(datasource, 'metadataRequest').mockImplementation(
createMetadataRequest({
data: {
scopes: [{ name: 'span', tags: ['label1', 'label2'] }],
},
})
);
// Mock the language provider to return v2 tags
datasource.languageProvider.tagsV2 = [{ name: 'span', tags: ['label1', 'label2'] }];
jest.spyOn(datasource.languageProvider, 'start').mockResolvedValue([]);
});
it('get label names', async () => {
@@ -1123,55 +1119,18 @@ describe('label names - v2 tags', () => {
});
});
describe('label names - v1 tags', () => {
let datasource: TempoDatasource;
beforeEach(() => {
datasource = createTempoDatasource();
jest
.spyOn(datasource, 'metadataRequest')
.mockImplementationOnce(() => {
throw Error;
})
.mockImplementation(
createMetadataRequest({
data: {
tagNames: ['label1', 'label2'],
},
})
);
});
it('get label names', async () => {
// label_names()
const response = await datasource.executeVariableQuery({ refId: 'test', type: TempoVariableQueryType.LabelNames });
expect(response).toEqual([{ text: 'label1' }, { text: 'label2' }, { text: 'status.code' }]);
});
});
describe('label values', () => {
let datasource: TempoDatasource;
beforeEach(() => {
datasource = createTempoDatasource();
jest.spyOn(datasource, 'metadataRequest').mockImplementation(
createMetadataRequest({
data: {
tagValues: [
{
type: 'value1',
value: 'value1',
label: 'value1',
},
{
type: 'value2',
value: 'value2',
label: 'value2',
},
],
},
})
);
// Mock the language provider to return v2 tags that includes the "label" tag
datasource.languageProvider.tagsV2 = [{ name: 'span', tags: ['label'] }];
jest.spyOn(datasource.languageProvider, 'start').mockResolvedValue([]);
jest.spyOn(datasource.languageProvider, 'getOptionsV2').mockResolvedValue([
{ type: 'string', value: 'value1', label: 'value1' },
{ type: 'string', value: 'value2', label: 'value2' },
]);
});
it('get label values for given label', async () => {
@@ -1182,10 +1141,7 @@ describe('label values', () => {
label: 'label',
});
expect(response).toEqual([
{ text: { type: 'value1', value: 'value1', label: 'value1' } },
{ text: { type: 'value2', value: 'value2', label: 'value2' } },
]);
expect(response).toEqual([{ text: 'value1' }, { text: 'value2' }]);
});
it('do not raise error when label is not set', async () => {
@@ -1205,25 +1161,13 @@ describe('should provide functionality for ad-hoc filters', () => {
beforeEach(() => {
datasource = createTempoDatasource();
jest.spyOn(datasource, 'metadataRequest').mockImplementation(
createMetadataRequest({
data: {
scopes: [{ name: 'span', tags: ['label1', 'label2'] }],
tagValues: [
{
type: 'value1',
value: 'value1',
label: 'value1',
},
{
type: 'value2',
value: 'value2',
label: 'value2',
},
],
},
})
);
// Mock the language provider to return v2 tags
datasource.languageProvider.tagsV2 = [{ name: 'span', tags: ['label1', 'label2'] }];
jest.spyOn(datasource.languageProvider, 'fetchTags').mockResolvedValue();
jest.spyOn(datasource.languageProvider, 'getOptionsV2').mockResolvedValue([
{ type: 'string', value: 'value1', label: 'value1' },
{ type: 'string', value: 'value2', label: 'value2' },
]);
});
it('for getTagKeys', async () => {
@@ -38,7 +38,7 @@ import {
} from '@grafana/runtime';
import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema';
import { getTagWithoutScope, interpolateFilters } from './SearchTraceQLEditor/utils';
import { interpolateFilters } from './SearchTraceQLEditor/utils';
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types';
import { TagLimitOptions } from './configuration/TagLimitSettings';
@@ -206,33 +206,27 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
await this.languageProvider.start(range, this.timeRangeForTags);
let options;
try {
// Retrieve the scope of the tag
// Example: given `http.status_code`, we want scope `span`
// Note that we ignore possible name clashes, e.g., `http.status_code` in both `span` and `resource`
const scope: string | undefined = (this.languageProvider.tagsV2 || [])
// flatten the Scope objects
.flatMap((tagV2) => tagV2.tags.map((tag) => ({ scope: tagV2.name, name: tag })))
// find associated scope
.find((tag) => tag.name === labelName)?.scope;
if (!scope) {
throw Error(`Scope for tag ${labelName} not found`);
}
// For V2, we need to send scope and tag name, e.g. `span.http.status_code`,
// unless the tag has intrinsic scope
const scopeAndTag = scope === 'intrinsic' ? labelName : `${scope}.${labelName}`;
options = await this.languageProvider.getOptionsV2({
tag: scopeAndTag,
timeRangeForTags: this.timeRangeForTags,
range,
});
} catch {
// For V1, the tag name (e.g. `http.status_code`) is enough
options = await this.languageProvider.getOptionsV1(labelName);
// Retrieve the scope of the tag
// Example: given `http.status_code`, we want scope `span`
// Note that we ignore possible name clashes, e.g., `http.status_code` in both `span` and `resource`
const scope: string | undefined = (this.languageProvider.tagsV2 || [])
// flatten the Scope objects
.flatMap((tagV2) => tagV2.tags.map((tag) => ({ scope: tagV2.name, name: tag })))
// find associated scope
.find((tag) => tag.name === labelName)?.scope;
if (!scope) {
throw Error(`Scope for tag ${labelName} not found`);
}
// For V2, we need to send scope and tag name, e.g. `span.http.status_code`,
// unless the tag has intrinsic scope
const scopeAndTag = scope === 'intrinsic' ? labelName : `${scope}.${labelName}`;
const options = await this.languageProvider.getOptionsV2({
tag: scopeAndTag,
timeRangeForTags: this.timeRangeForTags,
range,
});
return options.flatMap((option: SelectableValue<string>) =>
option.value !== undefined ? [{ text: option.value }] : []
);
@@ -257,20 +251,14 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}
async tagValuesQuery(tag: string, query: string, range?: TimeRange): Promise<Array<{ text: string }>> {
let options;
try {
// For V2, we need to send scope and tag name, e.g. `span.http.status_code`,
// unless the tag has intrinsic scope
options = await this.languageProvider.getOptionsV2({
tag,
query,
timeRangeForTags: this.timeRangeForTags,
range,
});
} catch {
// For V1, the tag name (e.g. `http.status_code`) is enough
options = await this.languageProvider.getOptionsV1(getTagWithoutScope(tag));
}
// For V2, we need to send scope and tag name, e.g. `span.http.status_code`,
// unless the tag has intrinsic scope
const options = await this.languageProvider.getOptionsV2({
tag,
query,
timeRangeForTags: this.timeRangeForTags,
range,
});
return options.flatMap((option: SelectableValue<string>) =>
option.value !== undefined ? [{ text: option.value }] : []
@@ -912,7 +900,14 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}
async metadataRequest(url: string, params = {}) {
return await lastValueFrom(this._request(url, params, { method: 'GET', hideFromInspector: true }));
// url must not start with a `/`, otherwise the AJAX-request
// going from the browser will contain `//`, which can cause problems.
if (url.startsWith('/')) {
throw new Error(`invalid metadata request url: ${url}`);
}
const res = await this.getResource(url, params, { method: 'GET', hideFromInspector: true });
return res?.data ?? res;
}
_request(apiUrl: string, data?: unknown, options?: Partial<BackendSrvRequest>): Observable<Record<string, any>> {
@@ -1,6 +1,6 @@
import { uniq } from 'lodash';
import { v1Tags, v2Tags } from './SearchTraceQLEditor/mocks';
import { v2Tags } from './SearchTraceQLEditor/mocks';
import { TraceqlSearchScope } from './dataquery.gen';
import { TempoDatasource } from './datasource';
import TempoLanguageProvider from './language_provider';
@@ -9,72 +9,54 @@ import { Scope } from './types';
describe('Language_provider', () => {
describe('should get correct tags', () => {
it('for API v1 tags', async () => {
const lp = setup(v1Tags);
const tags = lp.getTags();
expect(tags).toEqual(['bar', 'foo', 'status']);
});
it('for API v2 resource tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTags(TraceqlSearchScope.Resource);
expect(tags).toEqual(['cluster', 'container']);
});
it('for API v2 span tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTags(TraceqlSearchScope.Span);
expect(tags).toEqual(['db']);
});
it('for API v2 unscoped tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTags(TraceqlSearchScope.Unscoped);
expect(tags).toEqual(['cluster', 'container', 'db']);
});
});
describe('should get correct traceql autocomplete tags', () => {
it('for API v1 tags', async () => {
const lp = setup(v1Tags);
const tags = lp.getTraceqlAutocompleteTags();
expect(tags).toEqual(['bar', 'foo', 'status']);
});
it('for API v2 resource tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTraceqlAutocompleteTags(TraceqlSearchScope.Resource);
expect(tags).toEqual(['cluster', 'container']);
});
it('for API v2 span tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTraceqlAutocompleteTags(TraceqlSearchScope.Span);
expect(tags).toEqual(['db']);
});
it('for API v2 unscoped tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTraceqlAutocompleteTags(TraceqlSearchScope.Unscoped);
expect(tags).toEqual(['cluster', 'container', 'db']);
});
it('for API v2 tags with no scope', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getTraceqlAutocompleteTags();
expect(tags).toEqual(['cluster', 'container', 'db']);
});
});
describe('should get correct autocomplete tags', () => {
it('for API v1 tags', async () => {
const lp = setup(v1Tags);
const tags = lp.getAutocompleteTags();
expect(tags).toEqual(['bar', 'foo', 'status', 'status.code']);
});
it('for API v2 tags', async () => {
const lp = setup(undefined, v2Tags);
const lp = setup(v2Tags);
const tags = lp.getAutocompleteTags();
expect(tags).toEqual(
uniq(['cluster', 'container', 'db', 'duration', 'kind', 'name', 'status'].concat(intrinsics))
@@ -85,7 +67,7 @@ describe('Language_provider', () => {
describe('generateQueryFromFilters generates the correct query for', () => {
let lp: TempoLanguageProvider;
beforeEach(() => {
lp = setup(v1Tags);
lp = setup(v2Tags);
});
it('an empty array', () => {
@@ -313,7 +295,7 @@ describe('Language_provider', () => {
});
});
const setup = (tagsV1?: string[], tagsV2?: Scope[]) => {
const setup = (tagsV2?: Scope[]) => {
const datasource: TempoDatasource = {
search: {
filters: [],
@@ -321,9 +303,7 @@ describe('Language_provider', () => {
} as unknown as TempoDatasource;
const lp = new TempoLanguageProvider(datasource);
if (tagsV1) {
lp.setV1Tags(tagsV1);
} else if (tagsV2) {
if (tagsV2) {
lp.setV2Tags(tagsV2);
}
datasource.languageProvider = lp;
@@ -30,7 +30,6 @@ interface GetOptionsV2 {
export default class TempoLanguageProvider extends LanguageProvider {
datasource: TempoDatasource;
tagsV1?: string[];
tagsV2?: Scope[];
private previousRange?: TimeRange;
@@ -41,8 +40,7 @@ export default class TempoLanguageProvider extends LanguageProvider {
}
request = async (url: string, params = {}) => {
const res = await this.datasource.metadataRequest(url, params);
return res?.data;
return await this.datasource.metadataRequest(url, params);
};
start = async (range?: TimeRange, timeRangeForTags?: number) => {
@@ -85,33 +83,21 @@ export default class TempoLanguageProvider extends LanguageProvider {
};
async fetchTags(timeRangeForTags?: number, range?: TimeRange) {
let v1Resp, v2Resp;
try {
const params: { limit: number; start?: number; end?: number } = {
limit: this.getTagsLimit(),
};
if (timeRangeForTags && range && timeRangeForTags !== DEFAULT_TIME_RANGE_FOR_TAGS) {
const { start, end } = this.getTimeRangeForTags(timeRangeForTags, range);
params.start = start;
params.end = end;
}
v2Resp = await this.request(`/api/v2/search/tags`, params);
} catch (error) {
v1Resp = await this.request('/api/search/tags', []);
const params: { limit: number; start?: number; end?: number } = {
limit: this.getTagsLimit(),
};
if (timeRangeForTags && range && timeRangeForTags !== DEFAULT_TIME_RANGE_FOR_TAGS) {
const { start, end } = this.getTimeRangeForTags(timeRangeForTags, range);
params.start = start;
params.end = end;
}
const v2Resp = await this.request(`tags`, params);
if (v2Resp && v2Resp.scopes) {
this.setV2Tags(v2Resp.scopes);
} else if (v1Resp) {
this.setV1Tags(v1Resp.tagNames);
}
}
setV1Tags = (tags: string[]) => {
this.tagsV1 = tags;
};
setV2Tags = (tags: Scope[]) => {
this.tagsV2 = tags;
};
@@ -129,13 +115,6 @@ export default class TempoLanguageProvider extends LanguageProvider {
return getUnscopedTags(this.tagsV2);
}
return getTagsByScope(this.tagsV2, scope);
} else if (this.tagsV1) {
// This is needed because the /api/v2/search/tag/${tag}/values API expects "status" and the v1 API expects "status.code"
// so Tempo doesn't send anything and we inject it here for the autocomplete
if (!this.tagsV1.find((t) => t === 'status')) {
this.tagsV1.push('status');
}
return this.tagsV1;
}
return [];
};
@@ -149,13 +128,6 @@ export default class TempoLanguageProvider extends LanguageProvider {
return getUnscopedTags(this.tagsV2);
}
return getTagsByScope(this.tagsV2, scope);
} else if (this.tagsV1) {
// This is needed because the /api/v2/search/tag/${tag}/values API expects "status" and the v1 API expects "status.code"
// so Tempo doesn't send anything and we inject it here for the autocomplete
if (!this.tagsV1.find((t) => t === 'status')) {
this.tagsV1.push('status');
}
return this.tagsV1;
}
return [];
};
@@ -163,33 +135,13 @@ export default class TempoLanguageProvider extends LanguageProvider {
getAutocompleteTags = () => {
if (this.tagsV2) {
return getAllTags(this.tagsV2);
} else if (this.tagsV1) {
// This is needed because the /api/search/tag/${tag}/values API expects "status.code" and the v2 API expects "status"
// so Tempo doesn't send anything and we inject it here for the autocomplete
if (!this.tagsV1.find((t) => t === 'status.code')) {
this.tagsV1.push('status.code');
}
return this.tagsV1;
}
return [];
};
async getOptionsV1(tag: string): Promise<Array<SelectableValue<string>>> {
const encodedTag = this.encodeTag(tag);
const response = await this.request(`/api/search/tag/${encodedTag}/values`);
let options: Array<SelectableValue<string>> = [];
if (response && response.tagValues) {
options = response.tagValues.map((v: string) => ({
value: v,
label: v,
}));
}
return options;
}
async getOptionsV2({ tag, query, timeRangeForTags, range }: GetOptionsV2): Promise<Array<SelectableValue<string>>> {
const encodedTag = this.encodeTag(tag);
const params: { q?: string; limit: number; start?: number; end?: number } = {
const params: { q?: string; limit: number; start?: number; end?: number; tag?: string } = {
limit: this.getTagsLimit(),
};
@@ -203,7 +155,10 @@ export default class TempoLanguageProvider extends LanguageProvider {
params.end = end;
}
const response = await this.request(`/api/v2/search/tag/${encodedTag}/values`, params);
// Add the encoded tag as a query parameter for the new resource endpoint
params.tag = encodedTag;
const response = await this.request(`tag-values`, params);
let options: Array<SelectableValue<string>> = [];
if (response && response.tagValues) {
response.tagValues.forEach((v: { type: string; value?: string }) => {
@@ -1,7 +1,7 @@
import { DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data';
import { monacoTypes } from '@grafana/ui';
import { v1Tags, v2Tags, emptyTags, testIntrinsics } from '../SearchTraceQLEditor/mocks';
import { v2Tags, emptyTags, testIntrinsics } from '../SearchTraceQLEditor/mocks';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { Scope, TempoJsonData } from '../types';
@@ -16,20 +16,8 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('CompletionProvider', () => {
it('suggests tags, intrinsics and scopes (API v1)', async () => {
const { provider, model } = setup('{}', 1, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsicsV1.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'bar', insertText: '.bar' }),
expect.objectContaining({ label: 'foo', insertText: '.foo' }),
expect.objectContaining({ label: 'status', insertText: '.status' }),
]);
});
it('suggests tags, intrinsics and scopes (API v2)', async () => {
const { provider, model } = setup('{}', 1, undefined, v2Tags);
const { provider, model } = setup('{}', 1, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
@@ -41,7 +29,7 @@ describe('CompletionProvider', () => {
});
it('does not wrap the tag value in quotes if the type in the response is something other than "string"', async () => {
const { provider, model } = setup('{.foo=}', 6, v1Tags);
const { provider, model } = setup('{.foo=}', 6, v2Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
@@ -63,7 +51,7 @@ describe('CompletionProvider', () => {
});
it('wraps the tag value in quotes if the type in the response is set to "string"', async () => {
const { provider, model } = setup('{.foo=}', 6, v1Tags);
const { provider, model } = setup('{.foo=}', 6, v2Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
@@ -85,7 +73,7 @@ describe('CompletionProvider', () => {
});
it('inserts the tag value without quotes if the user has entered quotes', async () => {
const { provider, model } = setup('{.foo="}', 6, v1Tags);
const { provider, model } = setup('{.foo="}', 6, v2Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
@@ -106,7 +94,7 @@ describe('CompletionProvider', () => {
});
it('suggests options when inside quotes', async () => {
const { provider, model } = setup('{.foo=""}', 7, undefined, v2Tags);
const { provider, model } = setup('{.foo=""}', 7, v2Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
@@ -133,20 +121,8 @@ describe('CompletionProvider', () => {
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it('suggests tags on empty input (API v1)', async () => {
const { provider, model } = setup('', 0, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
...intrinsicsV1.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
expect.objectContaining({ label: 'bar', insertText: '{ .bar' }),
expect.objectContaining({ label: 'foo', insertText: '{ .foo' }),
expect.objectContaining({ label: 'status', insertText: '{ .status' }),
]);
});
it('suggests tags on empty input (API v2)', async () => {
const { provider, model } = setup('', 0, undefined, v2Tags);
const { provider, model } = setup('', 0, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
@@ -157,32 +133,16 @@ describe('CompletionProvider', () => {
]);
});
it('only suggests tags after typing the global attribute scope (API v1)', async () => {
const { provider, model } = setup('{.}', 2, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('only suggests tags after typing the global attribute scope (API v2)', async () => {
const { provider, model } = setup('{.}', 2, undefined, v2Tags);
const { provider, model } = setup('{.}', 2, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['cluster', 'container', 'db'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests tags after a scope (API v1)', async () => {
const { provider, model } = setup('{ resource. }', 11, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests correct tags after the resource scope (API v2)', async () => {
const { provider, model } = setup('{ resource. }', 11, undefined, v2Tags);
const { provider, model } = setup('{ resource. }', 11, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['cluster', 'container'].map((s) => expect.objectContaining({ label: s, insertText: s }))
@@ -190,7 +150,7 @@ describe('CompletionProvider', () => {
});
it('suggests correct tags after the span scope (API v2)', async () => {
const { provider, model } = setup('{ span. }', 7, undefined, v2Tags);
const { provider, model } = setup('{ span. }', 7, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['db'].map((s) => expect.objectContaining({ label: s, insertText: s }))
@@ -198,7 +158,7 @@ describe('CompletionProvider', () => {
});
it('suggests logical operators and close bracket after the value', async () => {
const { provider, model } = setup('{.foo=300 }', 10, v1Tags);
const { provider, model } = setup('{.foo=300 }', 10, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps, ...CompletionProvider.comparisonOps].map(
@@ -224,7 +184,7 @@ describe('CompletionProvider', () => {
])(
'suggests operators that go after `|` (aggregators, selectorts, ...) - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset, undefined, v2Tags);
const { provider, model } = setup(input, offset, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...CompletionProvider.functions.map((s) =>
@@ -342,7 +302,7 @@ describe('CompletionProvider', () => {
['{ span.d }', 8],
['{ span.db }', 9],
])('suggests to complete attribute - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset, undefined, v2Tags);
const { provider, model } = setup(input, offset, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'db', insertText: 'db' }),
@@ -409,7 +369,7 @@ describe('CompletionProvider', () => {
])(
'suggests attributes when containing trigger characters and missing `}`- %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset, undefined, [
const { provider, model } = setup(input, offset, [
{
name: 'span',
tags: ['http.status_code'],
@@ -485,12 +445,10 @@ describe('CompletionProvider', () => {
});
});
function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[]) {
function setup(value: string, offset: number, tagsV2?: Scope[]) {
const ds = new TempoDatasource(defaultSettings);
const lp = new TempoLanguageProvider(ds);
if (tagsV1) {
lp.setV1Tags(tagsV1);
} else if (tagsV2) {
if (tagsV2) {
lp.setV2Tags(tagsV2);
}
const provider = new CompletionProvider({ languageProvider: lp, setAlertText: () => {} });
@@ -3,7 +3,7 @@ import { lastValueFrom } from 'rxjs';
import { DataQueryRequest, TimeRange } from '@grafana/data';
import { TempoVariableQuery } from './VariableQueryEditor';
import { createMetadataRequest, createTempoDatasource } from './test/mocks';
import { createTempoDatasource } from './test/mocks';
import { TempoVariableSupport } from './variables';
describe('TempoVariableSupport', () => {
@@ -11,14 +11,9 @@ describe('TempoVariableSupport', () => {
beforeEach(() => {
const datasource = createTempoDatasource();
jest.spyOn(datasource, 'metadataRequest').mockImplementation(
createMetadataRequest({
data: {
tagNames: ['label1', 'label2'],
scopes: [{ name: 'span', tags: ['label1', 'label2'] }],
},
})
);
// Mock the language provider to return v2 tags
datasource.languageProvider.tagsV2 = [{ name: 'span', tags: ['label1', 'label2'] }];
jest.spyOn(datasource.languageProvider, 'start').mockResolvedValue([]);
TempoVariableSupportMock = new TempoVariableSupport(datasource);
});