diff --git a/devenv/docker/blocks/zipkin/docker-compose.yaml b/devenv/docker/blocks/zipkin/docker-compose.yaml new file mode 100644 index 00000000000..55f8792df4f --- /dev/null +++ b/devenv/docker/blocks/zipkin/docker-compose.yaml @@ -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" diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx index 43a460d9ac7..69a15ad2577 100644 --- a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx @@ -52,3 +52,5 @@ export const ButtonCascader: React.FC = props => { ); }; + +ButtonCascader.displayName = 'ButtonCascader'; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index a6df27ed728..d09e9d3ce01 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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, diff --git a/public/app/plugins/datasource/jaeger/QueryField.tsx b/public/app/plugins/datasource/jaeger/QueryField.tsx index eb873f2a9a3..b505c01c14e 100644 --- a/public/app/plugins/datasource/jaeger/QueryField.tsx +++ b/public/app/plugins/datasource/jaeger/QueryField.tsx @@ -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 { componentDidMount() { this._isMounted = true; + // We should probably call this periodically to get new services after mount. this.getServices(); } diff --git a/public/app/plugins/datasource/zipkin/ConfigEditor.tsx b/public/app/plugins/datasource/zipkin/ConfigEditor.tsx index ba039f5551c..3d63fc7e6c4 100644 --- a/public/app/plugins/datasource/zipkin/ConfigEditor.tsx +++ b/public/app/plugins/datasource/zipkin/ConfigEditor.tsx @@ -7,7 +7,7 @@ export type Props = DataSourcePluginOptionsEditorProps; export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => { return ( { + it('renders properly', () => { + const ds = {} as ZipkinDatasource; + const wrapper = shallow( + {}} + 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): Promise { + 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): Promise { + 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 }, + }); + }); +}); diff --git a/public/app/plugins/datasource/zipkin/QueryField.tsx b/public/app/plugins/datasource/zipkin/QueryField.tsx index 566da99141a..4e31ed16165 100644 --- a/public/app/plugins/datasource/zipkin/QueryField.tsx +++ b/public/app/plugins/datasource/zipkin/QueryField.tsx @@ -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; -export const QueryField = (props: Props) => ( -
-
- - 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 ( + <> +
+
+ + Traces + +
+
+
+
+ + onChange({ + ...query, + query: e.currentTarget.value, + }) + } + /> +
+
+
+
+ + ); +}; + +// Exported for tests +export function useServices(datasource: ZipkinDatasource): AsyncState { + const url = `${apiPrefix}/services`; + + const [servicesOptions, fetch] = useAsyncFn(async (): Promise => { + 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 { + 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, + }; + }); } - /> -
-
-); + } 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 { + 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, 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, +}; diff --git a/public/app/plugins/datasource/zipkin/constants.ts b/public/app/plugins/datasource/zipkin/constants.ts new file mode 100644 index 00000000000..7e36f7eafb1 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/constants.ts @@ -0,0 +1 @@ +export const apiPrefix = '/api/v2'; diff --git a/public/app/plugins/datasource/zipkin/datasource.test.ts b/public/app/plugins/datasource/zipkin/datasource.test.ts new file mode 100644 index 00000000000..dffa9ace48f --- /dev/null +++ b/public/app/plugins/datasource/zipkin/datasource.test.ts @@ -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).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({ url, response }: { url: string; response: T }): void { + setBackendSrv({ + datasourceRequest(options: BackendSrvRequest): Promise { + 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: {}, +}; diff --git a/public/app/plugins/datasource/zipkin/datasource.ts b/public/app/plugins/datasource/zipkin/datasource.ts index b1f83871024..e96a054283e 100644 --- a/public/app/plugins/datasource/zipkin/datasource.ts +++ b/public/app/plugins/datasource/zipkin/datasource.ts @@ -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 { - constructor(instanceSettings: DataSourceInstanceSettings) { + constructor(private instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); } query(options: DataQueryRequest): Observable { - return of({ - data: [ - new MutableDataFrame({ - fields: [ - { - name: 'url', - values: [], - }, - ], - }), - ], - }); + const traceId = options.targets[0]?.query; + if (traceId) { + return this.request(`${apiPrefix}/trace/${traceId}`).pipe(map(responseToDataQueryResponse)); + } else { + return of(emptyDataQueryResponse); + } + } + + async metadataRequest(url: string, params?: Record): Promise { + const res = await this.request(url, params, { silent: true }).toPromise(); + return res.data; } async testDatasource(): Promise { + await this.metadataRequest(`${apiPrefix}/services`); return true; } + + private request(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: [], + }, + ], + }), + ], +}; diff --git a/public/app/plugins/datasource/zipkin/types.ts b/public/app/plugins/datasource/zipkin/types.ts new file mode 100644 index 00000000000..cc0d2de4c69 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/types.ts @@ -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; +}; diff --git a/public/app/plugins/datasource/zipkin/utils/testData.ts b/public/app/plugins/datasource/zipkin/utils/testData.ts new file mode 100644 index 00000000000..ecaae2d5f40 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/utils/testData.ts @@ -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', + }, + ], + }, + ], +}; diff --git a/public/app/plugins/datasource/zipkin/utils/transforms.test.ts b/public/app/plugins/datasource/zipkin/utils/transforms.test.ts new file mode 100644 index 00000000000..e49bac79d8d --- /dev/null +++ b/public/app/plugins/datasource/zipkin/utils/transforms.test.ts @@ -0,0 +1,8 @@ +import { transformResponse } from './transforms'; +import { jaegerTrace, zipkinResponse } from './testData'; + +describe('transformResponse', () => { + it('transforms response', () => { + expect(transformResponse(zipkinResponse)).toEqual(jaegerTrace); + }); +}); diff --git a/public/app/plugins/datasource/zipkin/utils/transforms.ts b/public/app/plugins/datasource/zipkin/utils/transforms.ts new file mode 100644 index 00000000000..78df970ea76 --- /dev/null +++ b/public/app/plugins/datasource/zipkin/utils/transforms.ts @@ -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 { + 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'); +} diff --git a/scripts/ci-frontend-metrics.sh b/scripts/ci-frontend-metrics.sh index 17300e96d96..27a5e09061e 100755 --- a/scripts/ci-frontend-metrics.sh +++ b/scripts/ci-frontend-metrics.sh @@ -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