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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tempo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@perses-dev/tempo-plugin",
"version": "0.58.0-beta.3",
"version": "0.59.0-beta.0",
"license": "Apache-2.0",
"homepage": "https://github.com/perses/plugins/blob/main/README.md",
"repository": {
Expand Down
12 changes: 7 additions & 5 deletions tempo/src/model/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,21 @@ export interface ServiceStats {
}

/**
* Request parameters of Tempo HTTP API endpoint GET /api/traces/<traceID>
* https://grafana.com/docs/tempo/latest/api_docs/#query
* Request parameters of Tempo HTTP API endpoint GET /api/v2/traces/<traceID>
* https://grafana.com/docs/tempo/latest/api_docs/#query-v2
*/
export interface QueryRequestParameters {
traceId: string;
}

/**
* Response of Tempo HTTP API endpoint GET /api/traces/<traceID>
* OTEL trace proto: https://github.com/open-telemetry/opentelemetry-proto-go/blob/main/otlp/trace/v1/trace.pb.go
* Response of Tempo HTTP API endpoint GET /api/v2/traces/<traceID>
* https://github.com/grafana/tempo/blob/v2.10.7/pkg/tempopb/tempo.pb.go#L260
*/
export interface QueryResponse {
batches: otlptracev1.ResourceSpan[];
trace: otlptracev1.TracesData;
status?: 'PARTIAL' | 'COMPLETE';
message?: string;
}

/**
Expand Down
61 changes: 53 additions & 8 deletions tempo/src/model/tempo-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,56 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { UserFriendlyError } from '@perses-dev/client';
import {
MOCK_SEARCH_RESPONSE_MIXED_VPARQUET3_AND_4,
MOCK_SEARCH_RESPONSE_VPARQUET3,
MOCK_SEARCH_RESPONSE_VPARQUET4,
MOCK_TRACE_RESPONSE,
} from '../test';
import { searchTagValues, searchWithFallback } from './tempo-client';
import { query, search, searchTagValues, searchWithFallback } from './tempo-client';

const fetchMock = (global.fetch = jest.fn());

function mockErrorResponse(status: number, statusText: string, overrides?: Record<string, unknown>): unknown {
const resp = {
ok: false,
status,
statusText,
headers: new Headers(),
clone: (): unknown => resp,
text: (): Promise<string> => Promise.resolve(statusText),
...overrides,
};
return resp;
}

describe('tempo-client', () => {
beforeEach(() => {
fetchMock.mockReset();
});

it('should return query results as-is when serviceStats are present', async () => {
fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve(MOCK_SEARCH_RESPONSE_VPARQUET4) });
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_SEARCH_RESPONSE_VPARQUET4) });

const results = await searchWithFallback({ q: '{}' }, { datasourceUrl: '' });
expect(results).toEqual(MOCK_SEARCH_RESPONSE_VPARQUET4);
});

it('should augment query results with serviceStats if they are not present', async () => {
fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve(MOCK_SEARCH_RESPONSE_VPARQUET3) });
fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve(MOCK_TRACE_RESPONSE) });
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_SEARCH_RESPONSE_VPARQUET3) });
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_TRACE_RESPONSE) });

const results = await searchWithFallback({ q: '{}' }, { datasourceUrl: '' });
expect(results).toEqual(MOCK_SEARCH_RESPONSE_VPARQUET4);
});

it('should augment query results with serviceStats if they are partially present', async () => {
fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve(MOCK_SEARCH_RESPONSE_MIXED_VPARQUET3_AND_4) });
fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve(MOCK_TRACE_RESPONSE) });
fetchMock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(MOCK_SEARCH_RESPONSE_MIXED_VPARQUET3_AND_4),
});
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_TRACE_RESPONSE) });

const results = await searchWithFallback({ q: '{}' }, { datasourceUrl: '' });

Expand All @@ -60,13 +77,41 @@ describe('tempo-client', () => {
});
});

it('should return v2 query response', async () => {
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_TRACE_RESPONSE) });

const result = await query({ traceId: 'abc123' }, { datasourceUrl: '' });
expect(result).toEqual(MOCK_TRACE_RESPONSE);
});

it('should throw 404 when trace has no resourceSpans', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ trace: {} }),
});

await expect(query({ traceId: 'nonexistent' }, { datasourceUrl: '' })).rejects.toThrow(
new UserFriendlyError('Trace not found', 404)
);
});

it('formats the search params and ignores undefined values', async () => {
fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve({}) });
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });

await searchTagValues({ tag: 'name', q: '{}', start: 10, end: undefined }, { datasourceUrl: '' });
expect(fetchMock).toHaveBeenCalledWith('/api/v2/search/tag/name/values?q=%7B%7D&start=10', {
headers: {},
headers: { Accept: 'application/json' },
method: 'GET',
});
});

it('should throw a short error when response is HTML instead of JSON', async () => {
fetchMock.mockResolvedValueOnce(
mockErrorResponse(502, '<html><body>Bad Gateway</body></html>', {
headers: new Headers({ 'content-type': 'text/html' }),
})
);

await expect(search({ q: '{}' }, { datasourceUrl: '' })).rejects.toThrow('Invalid response from server');
});
});
46 changes: 28 additions & 18 deletions tempo/src/model/tempo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// limitations under the License.

import { DatasourceClient } from '@perses-dev/plugin-system';
import { RequestHeaders } from '@perses-dev/client';
import { fetchJson, RequestHeaders, UserFriendlyError } from '@perses-dev/client';
import * as otlptracev1 from '@perses-dev/spec/dist/dashboard/query-type/otlp/trace/v1/trace';
import {
QueryRequestParameters,
Expand Down Expand Up @@ -46,17 +46,7 @@ export interface QueryOptions {
headers?: RequestHeaders;
}

export const executeRequest = async <T>(...args: Parameters<typeof global.fetch>): Promise<T> => {
const response = await fetch(...args);
try {
return await response.json();
} catch (e) {
console.error('Invalid response from server', e);
throw new Error('Invalid response from server');
}
};

function fetchWithGet<TRequest extends RequestParams<TRequest>, TResponse>(
async function fetchWithGet<TRequest extends RequestParams<TRequest>, TResponse>(
apiURI: string,
params: TRequest,
queryOptions: QueryOptions
Expand All @@ -70,10 +60,22 @@ function fetchWithGet<TRequest extends RequestParams<TRequest>, TResponse>(
}
const init = {
method: 'GET',
headers,
headers: {
Accept: 'application/json',
...headers,
},
};

return executeRequest<TResponse>(url, init);
try {
return await fetchJson<TResponse>(url, init);
} catch (e) {
// fetchJson() puts the entire response body in the error message,
// which can be a full HTML page. Replace with a short status message.
if (e instanceof Error && 'status' in e && /^\s*</.test(e.message)) {
throw new UserFriendlyError(`Invalid response from server`, e.status as number);
}
throw e;
}
}

type RequestParams<T> = { [K in keyof T]: string | number };
Expand Down Expand Up @@ -104,13 +106,21 @@ export function search(params: SearchRequestParameters, queryOptions: QueryOptio

/**
* Returns an entire trace.
* Throws an 404 if trace is not found.
*/
export function query(params: QueryRequestParameters, queryOptions: QueryOptions): Promise<QueryResponse> {
return fetchWithGet<Record<string, never>, QueryResponse>(
`/api/traces/${encodeURIComponent(params.traceId)}`,
export async function query(params: QueryRequestParameters, queryOptions: QueryOptions): Promise<QueryResponse> {
const response = await fetchWithGet<Record<string, never>, QueryResponse>(
`/api/v2/traces/${encodeURIComponent(params.traceId)}`,
{},
queryOptions
);

// /api/v2/traces returns an empty trace if trace is not found.
// Throw a 404 instead.
if (!response.trace?.resourceSpans) {
throw new UserFriendlyError('Trace not found', 404);
}
return response;
}

/**
Expand Down Expand Up @@ -150,7 +160,7 @@ export async function searchWithFallback(
const searchTraceIDResponse = await query({ traceId: trace.traceID }, queryOptions);

// For every trace, get the full trace, and find the number of spans and errors.
for (const batch of searchTraceIDResponse.batches) {
for (const batch of searchTraceIDResponse.trace.resourceSpans) {
let serviceName = 'unknown';
for (const attr of batch.resource?.attributes ?? []) {
if (attr.key === 'service.name' && 'stringValue' in attr.value) {
Expand Down
2 changes: 1 addition & 1 deletion tempo/src/plugins/TempoDatasourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function TempoDatasourceEditor(props: TempoDatasourceEditorProps): ReactE
method: 'GET',
},
{
endpointPattern: '/api/traces',
endpointPattern: '/api/v2/traces',
method: 'GET',
},
{
Expand Down
31 changes: 30 additions & 1 deletion tempo/src/plugins/tempo-trace-query/get-trace-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ describe('getTraceData', () => {
it('should use the replaced query when it resolves to a valid traceId', async () => {
const traceId = 'a'.repeat(32); // valid 32-char hex trace ID
const mockResponse = {
batches: [],
trace: { resourceSpans: [] },
};
const mockClient = createMockClient({ traces: [] });
mockClient.query = jest.fn(async () => mockResponse);
Expand All @@ -207,6 +207,35 @@ describe('getTraceData', () => {
expect(result.trace).toBeDefined();
});

it('should surface response message as a warning notice', async () => {
const traceId = 'b'.repeat(32);
const mockResponse = {
trace: { resourceSpans: [] },
message: 'trace exceeds max size',
};
const mockClient = createMockClient({ traces: [] });
mockClient.query = jest.fn(async () => mockResponse);
const stubContext = createStubContext(mockClient);

const result = await getTraceData({ query: traceId }, stubContext);

expect(result.metadata?.notices).toEqual([{ type: 'warning', message: 'trace exceeds max size' }]);
});

it('should not include notices when response has no message', async () => {
const traceId = 'c'.repeat(32);
const mockResponse = {
trace: { resourceSpans: [] },
};
const mockClient = createMockClient({ traces: [] });
mockClient.query = jest.fn(async () => mockResponse);
const stubContext = createStubContext(mockClient);

const result = await getTraceData({ query: traceId }, stubContext);

expect(result.metadata?.notices).toEqual([]);
});

it('should replace multiple variables in a complex TraceQL query', async () => {
const mockResponse: SearchResponse = {
traces: [
Expand Down
11 changes: 8 additions & 3 deletions tempo/src/plugins/tempo-trace-query/get-trace-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,17 @@ export const getTraceData: TraceQueryPlugin<TempoTraceQuerySpec>['getTraceData']
*/
if (isValidTraceId(query)) {
const response = await client.query({ traceId: query });

const notices: Notice[] = [];
if (response.message) {
notices.push({ type: 'warning', message: response.message });
}

return {
trace: parseTraceResponse(response),
metadata: {
executedQueryString: query,
notices,
},
};
} else {
Expand Down Expand Up @@ -112,9 +119,7 @@ export const getTraceData: TraceQueryPlugin<TempoTraceQuerySpec>['getTraceData']
};

function parseTraceResponse(response: QueryResponse): otlptracev1.TracesData {
const trace = {
resourceSpans: response.batches,
};
const trace = response.trace;

// Tempo returns Trace ID and Span ID base64-encoded.
// The OTLP spec defines the encoding in the hex format:
Expand Down
Loading
Loading