Commit 58b566a2 by Andrej Ocenas Committed by GitHub

Tracing: Zipkin datasource (#23829)

parent 800228c1
# There is no data generator for this so easiest way to get some data here is run this example app
# https://github.com/openzipkin/zipkin-js-example/tree/master/web
zipkin:
image: openzipkin/zipkin:latest
ports:
- "9411:9411"
......@@ -52,3 +52,5 @@ export const ButtonCascader: React.FC<ButtonCascaderProps> = props => {
</RCCascader>
);
};
ButtonCascader.displayName = 'ButtonCascader';
......@@ -15,6 +15,8 @@ const influxdbPlugin = async () =>
const lokiPlugin = async () => await import(/* webpackChunkName: "lokiPlugin" */ 'app/plugins/datasource/loki/module');
const jaegerPlugin = async () =>
await import(/* webpackChunkName: "jaegerPlugin" */ 'app/plugins/datasource/jaeger/module');
const zipkinPlugin = async () =>
await import(/* webpackChunkName: "zipkinPlugin" */ 'app/plugins/datasource/zipkin/module');
const mixedPlugin = async () =>
await import(/* webpackChunkName: "mixedPlugin" */ 'app/plugins/datasource/mixed/module');
const mysqlPlugin = async () =>
......@@ -65,6 +67,7 @@ const builtInPlugins: any = {
'app/plugins/datasource/influxdb/module': influxdbPlugin,
'app/plugins/datasource/loki/module': lokiPlugin,
'app/plugins/datasource/jaeger/module': jaegerPlugin,
'app/plugins/datasource/zipkin/module': zipkinPlugin,
'app/plugins/datasource/mixed/module': mixedPlugin,
'app/plugins/datasource/mysql/module': mysqlPlugin,
'app/plugins/datasource/postgres/module': postgresPlugin,
......
......@@ -14,6 +14,7 @@ interface State {
}
function getLabelFromTrace(trace: any): string {
// TODO: seems like the spans are not ordered so this may not be actually a root span
const firstSpan = trace.spans && trace.spans[0];
if (firstSpan) {
return `${firstSpan.operationName} [${firstSpan.duration} ms]`;
......@@ -33,6 +34,7 @@ export class JaegerQueryField extends React.PureComponent<Props, State> {
componentDidMount() {
this._isMounted = true;
// We should probably call this periodically to get new services after mount.
this.getServices();
}
......
......@@ -7,7 +7,7 @@ export type Props = DataSourcePluginOptionsEditorProps;
export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
return (
<DataSourceHttpSettings
defaultUrl={'http://localhost:3100'}
defaultUrl={'http://localhost:9411'}
dataSourceConfig={options}
showAccessOptions={true}
onChange={onOptionsChange}
......
import React from 'react';
import { QueryField, useLoadOptions, useServices } from './QueryField';
import { ZipkinDatasource, ZipkinQuery } from './datasource';
import { shallow } from 'enzyme';
import { ButtonCascader, CascaderOption } from '@grafana/ui';
import { renderHook, act } from '@testing-library/react-hooks';
describe('QueryField', () => {
it('renders properly', () => {
const ds = {} as ZipkinDatasource;
const wrapper = shallow(
<QueryField
history={[]}
datasource={ds}
query={{ query: '1234' } as ZipkinQuery}
onRunQuery={() => {}}
onChange={() => {}}
/>
);
expect(wrapper.find(ButtonCascader).length).toBe(1);
expect(wrapper.find('input').length).toBe(1);
expect(wrapper.find('input').props().value).toBe('1234');
});
});
describe('useServices', () => {
it('returns services from datasource', async () => {
const ds = {
async metadataRequest(url: string, params?: Record<string, any>): Promise<any> {
if (url === '/api/v2/services') {
return Promise.resolve(['service1', 'service2']);
}
},
} as ZipkinDatasource;
const { result, waitForNextUpdate } = renderHook(() => useServices(ds));
await waitForNextUpdate();
expect(result.current.value).toEqual([
{ label: 'service1', value: 'service1', isLeaf: false },
{ label: 'service2', value: 'service2', isLeaf: false },
]);
});
});
describe('useLoadOptions', () => {
it('loads spans and traces', async () => {
const ds = {
async metadataRequest(url: string, params?: Record<string, any>): Promise<any> {
if (url === '/api/v2/spans' && params?.serviceName === 'service1') {
return Promise.resolve(['span1', 'span2']);
}
console.log({ url });
if (url === '/api/v2/traces' && params?.serviceName === 'service1' && params?.spanName === 'span1') {
return Promise.resolve([[{ name: 'trace1', duration: 10_000, traceId: 'traceId1' }]]);
}
},
} as ZipkinDatasource;
const { result, waitForNextUpdate } = renderHook(() => useLoadOptions(ds));
expect(result.current.allOptions).toEqual({});
act(() => {
result.current.onLoadOptions([{ value: 'service1' } as CascaderOption]);
});
await waitForNextUpdate();
expect(result.current.allOptions).toEqual({ service1: { span1: undefined, span2: undefined } });
act(() => {
result.current.onLoadOptions([{ value: 'service1' } as CascaderOption, { value: 'span1' } as CascaderOption]);
});
await waitForNextUpdate();
expect(result.current.allOptions).toEqual({
service1: { span1: { 'trace1 [10 ms]': 'traceId1' }, span2: undefined },
});
});
});
import React from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { ZipkinDatasource, ZipkinQuery } from './datasource';
import { ExploreQueryFieldProps } from '@grafana/data';
import { AppEvents, ExploreQueryFieldProps } from '@grafana/data';
import { ButtonCascader, CascaderOption } from '@grafana/ui';
import { useAsyncFn, useMount, useMountedState } from 'react-use';
import { appEvents } from '../../../core/core';
import { apiPrefix } from './constants';
import { ZipkinSpan } from './types';
import { fromPairs } from 'lodash';
import { AsyncState } from 'react-use/lib/useAsyncFn';
type Props = ExploreQueryFieldProps<ZipkinDatasource, ZipkinQuery>;
export const QueryField = (props: Props) => (
<div className={'slate-query-field__wrapper'}>
<div className="slate-query-field">
<input
style={{ width: '100%' }}
value={props.query.query || ''}
onChange={e =>
props.onChange({
...props.query,
query: e.currentTarget.value,
})
export const QueryField = ({ query, onChange, onRunQuery, datasource }: Props) => {
const serviceOptions = useServices(datasource);
const { onLoadOptions, allOptions } = useLoadOptions(datasource);
const onSelectTrace = useCallback(
(values: string[], selectedOptions: CascaderOption[]) => {
if (selectedOptions.length === 3) {
const traceID = selectedOptions[2].value;
onChange({ ...query, query: traceID });
onRunQuery();
}
},
[onChange, onRunQuery, query]
);
let cascaderOptions = useMapToCascaderOptions(serviceOptions, allOptions);
return (
<>
<div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form flex-shrink-0">
<ButtonCascader options={cascaderOptions} onChange={onSelectTrace} loadData={onLoadOptions}>
Traces
</ButtonCascader>
</div>
<div className="gf-form gf-form--grow flex-shrink-1">
<div className={'slate-query-field__wrapper'}>
<div className="slate-query-field">
<input
style={{ width: '100%' }}
value={query.query || ''}
onChange={e =>
onChange({
...query,
query: e.currentTarget.value,
})
}
/>
</div>
</div>
</div>
</div>
</>
);
};
// Exported for tests
export function useServices(datasource: ZipkinDatasource): AsyncState<CascaderOption[]> {
const url = `${apiPrefix}/services`;
const [servicesOptions, fetch] = useAsyncFn(async (): Promise<CascaderOption[]> => {
try {
const services: string[] | null = await datasource.metadataRequest(url);
if (services) {
return services.sort().map(service => ({
label: service,
value: service,
isLeaf: false,
}));
}
return [];
} catch (error) {
appEvents.emit(AppEvents.alertError, ['Failed to load services from Zipkin', error]);
throw error;
}
}, [datasource]);
useMount(() => {
// We should probably call this periodically to get new services after mount.
fetch();
});
return servicesOptions;
}
type OptionsState = {
[serviceName: string]: {
[spanName: string]: {
[traceId: string]: string;
};
};
};
// Exported for tests
export function useLoadOptions(datasource: ZipkinDatasource) {
const isMounted = useMountedState();
const [allOptions, setAllOptions] = useState({} as OptionsState);
const [, fetchSpans] = useAsyncFn(
async function findSpans(service: string): Promise<void> {
const url = `${apiPrefix}/spans`;
try {
// The response of this should have been full ZipkinSpan objects based on API docs but is just list
// of span names.
// TODO: check if this is some issue of version used or something else
const response: string[] = await datasource.metadataRequest(url, { serviceName: service });
if (isMounted()) {
setAllOptions(state => {
const spanOptions = fromPairs(response.map((span: string) => [span, undefined]));
return {
...state,
[service]: spanOptions as any,
};
});
}
/>
</div>
</div>
);
} catch (error) {
appEvents.emit(AppEvents.alertError, ['Failed to load spans from Zipkin', error]);
throw error;
}
},
[datasource, allOptions]
);
const [, fetchTraces] = useAsyncFn(
async function findTraces(serviceName: string, spanName: string): Promise<void> {
const url = `${apiPrefix}/traces`;
const search = {
serviceName,
spanName,
// See other params and default here https://zipkin.io/zipkin-api/#/default/get_traces
};
try {
// This should return just root traces as there isn't any nesting
const traces: ZipkinSpan[][] = await datasource.metadataRequest(url, search);
if (isMounted()) {
const newTraces = traces.length
? fromPairs(
traces.map(trace => {
const rootSpan = trace.find(span => !span.parentId);
return [`${rootSpan.name} [${Math.floor(rootSpan.duration / 1000)} ms]`, rootSpan.traceId];
})
)
: noTracesOptions;
setAllOptions(state => {
const spans = state[serviceName];
return {
...state,
[serviceName]: {
...spans,
[spanName]: newTraces,
},
};
});
}
} catch (error) {
appEvents.emit(AppEvents.alertError, ['Failed to load spans from Zipkin', error]);
throw error;
}
},
[datasource]
);
const onLoadOptions = useCallback(
(selectedOptions: CascaderOption[]) => {
const service = selectedOptions[0].value;
if (selectedOptions.length === 1) {
fetchSpans(service);
} else if (selectedOptions.length === 2) {
const spanName = selectedOptions[1].value;
fetchTraces(service, spanName);
}
},
[fetchSpans, fetchTraces]
);
return {
onLoadOptions,
allOptions,
};
}
function useMapToCascaderOptions(services: AsyncState<CascaderOption[]>, allOptions: OptionsState) {
return useMemo(() => {
let cascaderOptions: CascaderOption[];
if (services.value && services.value.length) {
cascaderOptions = services.value.map(services => {
return {
...services,
children:
allOptions[services.value] &&
Object.keys(allOptions[services.value]).map(spanName => {
return {
label: spanName,
value: spanName,
isLeaf: false,
children:
allOptions[services.value][spanName] &&
Object.keys(allOptions[services.value][spanName]).map(traceName => {
return {
label: traceName,
value: allOptions[services.value][spanName][traceName],
};
}),
};
}),
};
});
} else if (services.value && !services.value.length) {
cascaderOptions = noTracesFoundOptions;
}
return cascaderOptions;
}, [services, allOptions]);
}
const NO_TRACES_KEY = '__NO_TRACES__';
const noTracesFoundOptions = [
{
label: 'No traces found',
value: 'no_traces',
isLeaf: true,
// Cannot be disabled because then cascader shows 'loading' for some reason.
// disabled: true,
},
];
const noTracesOptions = {
'[No traces in time range]': NO_TRACES_KEY,
};
export const apiPrefix = '/api/v2';
import { ZipkinDatasource, ZipkinQuery } from './datasource';
import { DataQueryRequest, DataSourceInstanceSettings } from '@grafana/data';
import { BackendSrv, BackendSrvRequest, setBackendSrv } from '@grafana/runtime';
import { jaegerTrace, zipkinResponse } from './utils/testData';
describe('ZipkinDatasource', () => {
describe('query', () => {
it('runs query', async () => {
setupBackendSrv({ url: '/api/datasources/proxy/1/api/v2/trace/12345', response: zipkinResponse });
const ds = new ZipkinDatasource(defaultSettings);
const response = await ds.query({ targets: [{ query: '12345' }] } as DataQueryRequest<ZipkinQuery>).toPromise();
expect(response.data[0].fields[0].values.get(0)).toEqual(jaegerTrace);
});
});
describe('metadataRequest', () => {
it('runs query', async () => {
setupBackendSrv({ url: '/api/datasources/proxy/1/api/v2/services', response: ['service 1', 'service 2'] });
const ds = new ZipkinDatasource(defaultSettings);
const response = await ds.metadataRequest('/api/v2/services');
expect(response).toEqual(['service 1', 'service 2']);
});
});
});
function setupBackendSrv<T>({ url, response }: { url: string; response: T }): void {
setBackendSrv({
datasourceRequest(options: BackendSrvRequest): Promise<any> {
if (options.url === url) {
return Promise.resolve({ data: response });
}
throw new Error(`Unexpected url ${options.url}`);
},
} as BackendSrv);
}
const defaultSettings: DataSourceInstanceSettings = {
id: 1,
uid: '1',
type: 'tracing',
name: 'zipkin',
meta: {} as any,
jsonData: {},
};
......@@ -5,34 +5,88 @@ import {
DataQueryRequest,
DataQueryResponse,
DataQuery,
FieldType,
} from '@grafana/data';
import { Observable, of } from 'rxjs';
import { from, Observable, of } from 'rxjs';
import { DatasourceRequestOptions } from '../../../core/services/backend_srv';
import { serializeParams } from '../../../core/utils/fetch';
import { getBackendSrv } from '@grafana/runtime';
import { map } from 'rxjs/operators';
import { apiPrefix } from './constants';
import { ZipkinSpan } from './types';
import { transformResponse } from './utils/transforms';
export type ZipkinQuery = {
// At the moment this should be simply the trace ID to get
query: string;
} & DataQuery;
export class ZipkinDatasource extends DataSourceApi<ZipkinQuery> {
constructor(instanceSettings: DataSourceInstanceSettings) {
constructor(private instanceSettings: DataSourceInstanceSettings) {
super(instanceSettings);
}
query(options: DataQueryRequest<ZipkinQuery>): Observable<DataQueryResponse> {
return of({
data: [
new MutableDataFrame({
fields: [
{
name: 'url',
values: [],
},
],
}),
],
});
const traceId = options.targets[0]?.query;
if (traceId) {
return this.request<ZipkinSpan[]>(`${apiPrefix}/trace/${traceId}`).pipe(map(responseToDataQueryResponse));
} else {
return of(emptyDataQueryResponse);
}
}
async metadataRequest(url: string, params?: Record<string, any>): Promise<any> {
const res = await this.request(url, params, { silent: true }).toPromise();
return res.data;
}
async testDatasource(): Promise<any> {
await this.metadataRequest(`${apiPrefix}/services`);
return true;
}
private request<T = any>(apiUrl: string, data?: any, options?: DatasourceRequestOptions): Observable<{ data: T }> {
// Hack for proxying metadata requests
const baseUrl = `/api/datasources/proxy/${this.instanceSettings.id}`;
const params = data ? serializeParams(data) : '';
const url = `${baseUrl}${apiUrl}${params.length ? `?${params}` : ''}`;
const req = {
...options,
url,
};
return from(getBackendSrv().datasourceRequest(req));
}
}
function responseToDataQueryResponse(response: { data: ZipkinSpan[] }): DataQueryResponse {
return {
data: [
new MutableDataFrame({
fields: [
{
name: 'trace',
type: FieldType.trace,
// There is probably better mapping than just putting everything in as a single value but that's how
// we do it with jaeger and is the simplest right now.
values: response?.data ? [transformResponse(response?.data)] : [],
},
],
}),
],
};
}
const emptyDataQueryResponse = {
data: [
new MutableDataFrame({
fields: [
{
name: 'trace',
type: FieldType.trace,
values: [],
},
],
}),
],
};
export type ZipkinSpan = {
traceId: string;
parentId?: string;
name: string;
id: string;
timestamp: number;
duration: number;
localEndpoint: {
serviceName: string;
ipv4: string;
port?: number;
};
annotations?: ZipkinAnnotation[];
tags?: { [key: string]: string };
kind?: 'CLIENT' | 'SERVER' | 'PRODUCER' | 'CONSUMER';
};
export type ZipkinAnnotation = {
timestamp: number;
value: string;
};
import { SpanData, TraceData } from '@jaegertracing/jaeger-ui-components';
import { ZipkinSpan } from '../types';
export const zipkinResponse: ZipkinSpan[] = [
{
traceId: 'trace_id',
name: 'span 1',
id: 'span 1 id',
timestamp: 1,
duration: 10,
localEndpoint: {
serviceName: 'service 1',
ipv4: '1.0.0.1',
port: 42,
},
annotations: [
{
timestamp: 2,
value: 'annotation text',
},
{
timestamp: 6,
value: 'annotation text 3',
},
],
tags: {
tag1: 'val1',
tag2: 'val2',
},
kind: 'CLIENT',
},
{
traceId: 'trace_id',
parentId: 'span 1 id',
name: 'span 2',
id: 'span 2 id',
timestamp: 4,
duration: 5,
localEndpoint: {
serviceName: 'service 2',
ipv4: '1.0.0.1',
},
tags: {
error: '404',
},
},
];
export const jaegerTrace: TraceData & { spans: SpanData[] } = {
processes: {
'service 1': {
serviceName: 'service 1',
tags: [
{
key: 'ipv4',
type: 'string',
value: '1.0.0.1',
},
{
key: 'port',
type: 'number',
value: 42,
},
],
},
'service 2': {
serviceName: 'service 2',
tags: [
{
key: 'ipv4',
type: 'string',
value: '1.0.0.1',
},
],
},
},
traceID: 'trace_id',
warnings: null,
spans: [
{
duration: 10,
flags: 1,
logs: [
{
timestamp: 2,
fields: [{ key: 'annotation', type: 'string', value: 'annotation text' }],
},
{
timestamp: 6,
fields: [{ key: 'annotation', type: 'string', value: 'annotation text 3' }],
},
],
operationName: 'span 1',
processID: 'service 1',
startTime: 1,
spanID: 'span 1 id',
traceID: 'trace_id',
warnings: null as any,
tags: [
{
key: 'kind',
type: 'string',
value: 'CLIENT',
},
{
key: 'tag1',
type: 'string',
value: 'val1',
},
{
key: 'tag2',
type: 'string',
value: 'val2',
},
],
references: [],
},
{
duration: 5,
flags: 1,
logs: [],
operationName: 'span 2',
processID: 'service 2',
startTime: 4,
spanID: 'span 2 id',
traceID: 'trace_id',
warnings: null as any,
tags: [
{
key: 'error',
type: 'bool',
value: true,
},
],
references: [
{
refType: 'CHILD_OF',
spanID: 'span 1 id',
traceID: 'trace_id',
},
],
},
],
};
import { transformResponse } from './transforms';
import { jaegerTrace, zipkinResponse } from './testData';
describe('transformResponse', () => {
it('transforms response', () => {
expect(transformResponse(zipkinResponse)).toEqual(jaegerTrace);
});
});
import { identity } from 'lodash';
import { keyBy } from 'lodash';
import { ZipkinAnnotation, ZipkinSpan } from '../types';
import { KeyValuePair, Log, Process, SpanData, TraceData } from '@jaegertracing/jaeger-ui-components';
/**
* Transforms response to format similar to Jaegers as we use Jaeger ui on the frontend.
*/
export function transformResponse(zSpans: ZipkinSpan[]): TraceData & { spans: SpanData[] } {
return {
processes: gatherProcesses(zSpans),
traceID: zSpans[0].traceId,
spans: zSpans.map(transformSpan),
warnings: null,
};
}
function transformSpan(span: ZipkinSpan): SpanData {
const jaegerSpan: SpanData = {
duration: span.duration,
// TODO: not sure what this is
flags: 1,
logs: span.annotations?.map(transformAnnotation) ?? [],
operationName: span.name,
processID: span.localEndpoint.serviceName,
startTime: span.timestamp,
spanID: span.id,
traceID: span.traceId,
warnings: null as any,
tags: Object.keys(span.tags || {}).map(key => {
// If tag is error we remap it to simple boolean so that the Jaeger ui will show an error icon.
return {
key,
type: key === 'error' ? 'bool' : 'string',
value: key === 'error' ? true : span.tags![key],
};
}),
references: span.parentId
? [
{
refType: 'CHILD_OF',
spanID: span.parentId,
traceID: span.traceId,
},
]
: [],
};
if (span.kind) {
jaegerSpan.tags = [
{
key: 'kind',
type: 'string',
value: span.kind,
},
...jaegerSpan.tags,
];
}
return jaegerSpan;
}
/**
* Maps annotations as a Jaeger log as that seems to be the closest thing.
* See https://zipkin.io/zipkin-api/#/default/get_trace__traceId_
*/
function transformAnnotation(annotation: ZipkinAnnotation): Log {
return {
timestamp: annotation.timestamp,
fields: [
{
key: 'annotation',
type: 'string',
value: annotation.value,
},
],
};
}
function gatherProcesses(zSpans: ZipkinSpan[]): Record<string, Process> {
const processes = zSpans.map(span => ({
serviceName: span.localEndpoint.serviceName,
tags: [
{
key: 'ipv4',
type: 'string',
value: span.localEndpoint.ipv4,
},
span.localEndpoint.port
? {
key: 'port',
type: 'number',
value: span.localEndpoint.port,
}
: undefined,
].filter(identity) as KeyValuePair[],
}));
return keyBy(processes, 'serviceName');
}
......@@ -4,7 +4,7 @@ echo -e "Collecting code stats (typescript errors & more)"
ERROR_COUNT_LIMIT=791
ERROR_COUNT_LIMIT=788
DIRECTIVES_LIMIT=172
CONTROLLERS_LIMIT=139
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment