mirror of https://github.com/grafana/grafana
Tracing: Zipkin datasource (#23829)
parent
800228c100
commit
58b566a252
@ -0,0 +1,6 @@ |
||||
# 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" |
||||
@ -0,0 +1,82 @@ |
||||
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 }, |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,22 +1,235 @@ |
||||
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, |
||||
}; |
||||
|
||||
@ -0,0 +1 @@ |
||||
export const apiPrefix = '/api/v2'; |
||||
@ -0,0 +1,44 @@ |
||||
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: {}, |
||||
}; |
||||
@ -0,0 +1,21 @@ |
||||
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; |
||||
}; |
||||
@ -0,0 +1,145 @@ |
||||
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', |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}; |
||||
@ -0,0 +1,8 @@ |
||||
import { transformResponse } from './transforms'; |
||||
import { jaegerTrace, zipkinResponse } from './testData'; |
||||
|
||||
describe('transformResponse', () => { |
||||
it('transforms response', () => { |
||||
expect(transformResponse(zipkinResponse)).toEqual(jaegerTrace); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,98 @@ |
||||
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'); |
||||
} |
||||
Loading…
Reference in new issue