diff --git a/packages/jaeger-ui/src/model/ddg/PathElem.test.js b/packages/jaeger-ui/src/model/ddg/PathElem.test.js new file mode 100644 index 0000000000..f09214bfb1 --- /dev/null +++ b/packages/jaeger-ui/src/model/ddg/PathElem.test.js @@ -0,0 +1,55 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PathElem from './PathElem'; + +describe('PathElem', () => { + const testOperation = {}; + const testPath = { + focalIdx: 5, + }; + const testMemberIdx = 3; + const testVisibilityIdx = 105; + let pathElem; + + beforeEach(() => { + pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testMemberIdx }); + }); + + it('initializes instance properties', () => { + expect(pathElem.memberOf).toBe(testPath); + expect(pathElem.operation).toBe(testOperation); + expect(pathElem.memberIdx).toBe(testMemberIdx); + }); + + it('calculates distance', () => { + expect(pathElem.distance).toBe(-2); + }); + + it('sets visibilityIdx', () => { + pathElem.visibilityIdx = testVisibilityIdx; + expect(pathElem.visibilityIdx).toBe(testVisibilityIdx); + }); + + it('errors when trying to access unset visibilityIdx', () => { + expect(() => pathElem.visibilityIdx).toThrowError(); + }); + + it('errors when trying to override visibilityIdx', () => { + pathElem.visibilityIdx = testVisibilityIdx; + expect(() => { + pathElem.visibilityIdx = testVisibilityIdx; + }).toThrowError(); + }); +}); diff --git a/packages/jaeger-ui/src/model/ddg/PathElem.tsx b/packages/jaeger-ui/src/model/ddg/PathElem.tsx new file mode 100644 index 0000000000..56e18c4786 --- /dev/null +++ b/packages/jaeger-ui/src/model/ddg/PathElem.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TDdgOperation, TDdgPath } from './types'; + +export default class PathElem { + memberIdx: number; + memberOf: TDdgPath; + operation: TDdgOperation; + private _visibilityIdx?: number; + + constructor({ + path, + operation, + memberIdx, + }: { + path: TDdgPath; + operation: TDdgOperation; + memberIdx: number; + }) { + this.memberIdx = memberIdx; + this.memberOf = path; + this.operation = operation; + } + + get distance() { + return this.memberIdx - this.memberOf.focalIdx; + } + + set visibilityIdx(visibilityIdx: number) { + if (this._visibilityIdx == null) { + this._visibilityIdx = visibilityIdx; + } else { + throw new Error('Visibility Index cannot be changed once set'); + } + } + + get visibilityIdx(): number { + if (this._visibilityIdx == null) { + throw new Error('Visibility Index was never set for this PathElem'); + } + return this._visibilityIdx; + } +} diff --git a/packages/jaeger-ui/src/model/ddg/transformDdgData.test.js b/packages/jaeger-ui/src/model/ddg/transformDdgData.test.js new file mode 100644 index 0000000000..dd61af559c --- /dev/null +++ b/packages/jaeger-ui/src/model/ddg/transformDdgData.test.js @@ -0,0 +1,159 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _filter from 'lodash/filter'; +import _flatten from 'lodash/flatten'; +import _map from 'lodash/map'; + +import transformDdgData from './transformDdgData'; +import * as testResources from './transformDdgData.test.resources'; + +describe('transform ddg data', () => { + function outputValidator({ paths: payload, focalIndices, ignoreFocalOperation = false }) { + const { focalPathElem } = testResources; + const focalPathElemArgument = ignoreFocalOperation ? { service: focalPathElem.service } : focalPathElem; + const { paths, services, visibilityIdxToPathElem } = transformDdgData(payload, focalPathElemArgument); + + // Validate all services and operations are captured + expect(new Set(services.keys())).toEqual(new Set(_map(_flatten(payload), 'service'))); + services.forEach((service, serviceName) => { + expect(new Set(service.operations.keys())).toEqual( + new Set(_map(_filter(_flatten(payload), { service: serviceName }), 'operation')) + ); + }); + + const expectedVisibilityIndices = []; + const visibilityIndicesToDistance = new Map(); + + // Validate every pathElem has the correct data + paths.forEach((path, pathResultIndex) => { + expect(path.focalIdx).toBe(focalIndices[pathResultIndex]); + path.members.forEach((member, memberResultIndex) => { + const { distance, memberOf, operation, memberIdx, visibilityIdx } = member; + expect(distance).toBe(memberIdx - focalIndices[pathResultIndex]); + expect(memberOf).toBe(path); + expect(operation.name).toBe(payload[pathResultIndex][memberResultIndex].operation); + expect(operation.pathElems.includes(member)).toBe(true); + expect(operation.service.name).toBe(payload[pathResultIndex][memberResultIndex].service); + expect(memberIdx).toBe(memberResultIndex); + + expectedVisibilityIndices.push(expectedVisibilityIndices.length); + visibilityIndicesToDistance.set(visibilityIdx, distance); + }); + }); + + // Validate that visibility indices are concentric + const orderedVisibilityIndices = Array.from(visibilityIndicesToDistance.keys()).sort((a, b) => a - b); + expect(orderedVisibilityIndices).toEqual(expectedVisibilityIndices); + let distance = 0; + orderedVisibilityIndices.forEach(orderedIdx => { + const currentDistance = Math.abs(visibilityIndicesToDistance.get(orderedIdx)); + if (currentDistance < distance) { + throw new Error('Net distance did not increase or stay equal as visibilityIdx increased'); + } else if (currentDistance > distance) { + distance = currentDistance; + } + + expect(visibilityIdxToPathElem.get(orderedIdx).visibilityIdx).toBe(orderedIdx); + }); + } + + it('transforms an extremely simple payload', () => { + const { simplePath } = testResources; + outputValidator({ paths: [simplePath], focalIndices: [2] }); + }); + + it('transforms a path with multiple operations per service and multiple services per operation', () => { + const { longSimplePath } = testResources; + outputValidator({ paths: [longSimplePath], focalIndices: [6] }); + }); + + it('transforms a path that contains the focal path elem twice', () => { + const { doubleFocalPath } = testResources; + outputValidator({ paths: [doubleFocalPath], focalIndices: [2] }); + }); + + it('checks both operation and service when calculating focalIdx when both are provided', () => { + const { almostDoubleFocalPath } = testResources; + outputValidator({ paths: [almostDoubleFocalPath], focalIndices: [4] }); + }); + + it('checks only service when calculating focalIdx when only service is provided', () => { + const { almostDoubleFocalPath } = testResources; + outputValidator({ paths: [almostDoubleFocalPath], focalIndices: [2], ignoreFocalOperation: true }); + }); + + it('transforms a payload with significant overlap between paths', () => { + const { simplePath, longSimplePath, doubleFocalPath, almostDoubleFocalPath } = testResources; + outputValidator({ + paths: [simplePath, doubleFocalPath, almostDoubleFocalPath, longSimplePath], + focalIndices: [2, 2, 4, 6], + }); + }); + + it('sorts payload paths to ensure stable visibilityIndices', () => { + const { + focalPathElem, + simplePath, + longSimplePath, + doubleFocalPath, + almostDoubleFocalPath, + } = testResources; + const { visibilityIdxToPathElem: presortedPathsVisibilityIdxToPathElemMap } = transformDdgData( + [simplePath, doubleFocalPath, almostDoubleFocalPath, longSimplePath], + focalPathElem + ); + const { visibilityIdxToPathElem: unsortedPathsVisibilityIdxToPathElemMap } = transformDdgData( + [longSimplePath, almostDoubleFocalPath, simplePath, doubleFocalPath], + focalPathElem + ); + + expect(Array.from(presortedPathsVisibilityIdxToPathElemMap.keys())).toEqual( + Array.from(unsortedPathsVisibilityIdxToPathElemMap.keys()) + ); + presortedPathsVisibilityIdxToPathElemMap.forEach( + (presortedPathsPathElem, presortedPathsVisibilityIdx) => { + const { + memberIdx: presortedPathsMemberIdx, + memberOf: presortedPathsMemberOf, + operation: presortedPathsOperation, + } = presortedPathsPathElem; + const { focalIdx: presortedPathsFocalIdx } = presortedPathsMemberOf; + const { name: presortedPathsOperationName, service: presortedService } = presortedPathsOperation; + const { name: presortedPathsServiceName } = presortedService; + + const { + memberIdx: unsortedPathsMemberIdx, + memberOf: unsortedPathsMemberOf, + operation: unsortedPathsOperation, + visibilityIdx: unsortedPathsVisibilityIdx, + } = unsortedPathsVisibilityIdxToPathElemMap.get(presortedPathsVisibilityIdx); + const { focalIdx: unsortedPathsFocalIdx } = unsortedPathsMemberOf; + const { name: unsortedPathsOperationName, service: unsortedService } = unsortedPathsOperation; + const { name: unsortedPathsServiceName } = unsortedService; + + expect(unsortedPathsMemberIdx).toBe(presortedPathsMemberIdx); + expect(unsortedPathsFocalIdx).toBe(presortedPathsFocalIdx); + expect(unsortedPathsOperationName).toBe(presortedPathsOperationName); + expect(unsortedPathsServiceName).toBe(presortedPathsServiceName); + expect(unsortedPathsVisibilityIdx).toBe(presortedPathsVisibilityIdx); + } + ); + }); + + it('throws an error if a path lacks the focalPathElem', () => { + const { simplePath, noFocalPath, doubleFocalPath, focalPathElem } = testResources; + expect(() => transformDdgData([simplePath, noFocalPath, doubleFocalPath], focalPathElem)).toThrowError(); + }); +}); diff --git a/packages/jaeger-ui/src/model/ddg/transformDdgData.test.resources.js b/packages/jaeger-ui/src/model/ddg/transformDdgData.test.resources.js new file mode 100644 index 0000000000..2a2ab3bf34 --- /dev/null +++ b/packages/jaeger-ui/src/model/ddg/transformDdgData.test.resources.js @@ -0,0 +1,71 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const simplePathElemMaker = label => ({ + operation: `${label}Operation`, + service: `${label}Service`, +}); + +export const focalPathElem = simplePathElemMaker('focal'); + +const sameFocalServicePathElem = { + operation: `not-${focalPathElem.operation}`, + service: focalPathElem.service, +}; + +const pathLengthener = path => { + const prequels = []; + const sequels = []; + path.forEach(({ operation, service }) => { + if (operation !== focalPathElem.operation && service !== focalPathElem.service) { + prequels.push({ + operation: `prequel-${operation}`, + service, + }); + sequels.push({ + operation, + service: `sequel-${service}`, + }); + } + }); + return [...prequels, ...path, ...sequels]; +}; + +const firstPathElem = simplePathElemMaker('first'); +const beforePathElem = simplePathElemMaker('before'); +const midPathElem = simplePathElemMaker('mid'); +const afterPathElem = simplePathElemMaker('after'); +const lastPathElem = simplePathElemMaker('last'); + +export const simplePath = [firstPathElem, beforePathElem, focalPathElem, afterPathElem, lastPathElem]; +export const longSimplePath = pathLengthener(simplePath); +export const noFocalPath = [firstPathElem, beforePathElem, midPathElem, afterPathElem, lastPathElem]; +export const doubleFocalPath = [ + firstPathElem, + beforePathElem, + focalPathElem, + midPathElem, + focalPathElem, + afterPathElem, + lastPathElem, +]; +export const almostDoubleFocalPath = [ + firstPathElem, + beforePathElem, + sameFocalServicePathElem, + midPathElem, + focalPathElem, + afterPathElem, + lastPathElem, +]; diff --git a/packages/jaeger-ui/src/model/ddg/transformDdgData.tsx b/packages/jaeger-ui/src/model/ddg/transformDdgData.tsx new file mode 100644 index 0000000000..55e7533d7a --- /dev/null +++ b/packages/jaeger-ui/src/model/ddg/transformDdgData.tsx @@ -0,0 +1,135 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + PathElem, + TDdgModel, + TDdgPayload, + TDdgPayloadEntry, + TDdgPath, + TDdgPathElemsByDistance, + TDdgServiceMap, + TDdgVisibilityIdxToPathElem, +} from './types'; + +const stringifyPayloadEntry = ({ service, operation }: TDdgPayloadEntry) => `${service}\v${operation}`; + +export default function transformDdgData( + payload: TDdgPayload, + { service: focalService, operation: focalOperation }: { service: string; operation?: string } +): TDdgModel { + const serviceMap: TDdgServiceMap = new Map(); + const pathElemsByDistance: TDdgPathElemsByDistance = new Map(); + const pathsComparisonMap: Map = new Map(); + + const paths = payload + .slice() + .sort((a, b) => { + let aCompareValue = pathsComparisonMap.get(a); + if (!aCompareValue) { + aCompareValue = a.map(stringifyPayloadEntry).join(); + pathsComparisonMap.set(a, aCompareValue); + } + let bCompareValue = pathsComparisonMap.get(b); + if (!bCompareValue) { + bCompareValue = b.map(stringifyPayloadEntry).join(); + pathsComparisonMap.set(b, bCompareValue); + } + if (aCompareValue > bCompareValue) return 1; + if (aCompareValue < bCompareValue) return -1; + return 0; + }) + .map(payloadPath => { + // Path with stand-in values is necessary for assigning PathElem.memberOf + const path: TDdgPath = { focalIdx: -1, members: [] }; + + path.members = payloadPath.map(({ operation: operationName, service: serviceName }, i) => { + // Ensure pathElem.service exists, else create it + let service = serviceMap.get(serviceName); + if (!service) { + service = { + name: serviceName, + operations: new Map(), + }; + serviceMap.set(serviceName, service); + } + + // Ensure service has operation, else add it + let operation = service.operations.get(operationName); + if (!operation) { + operation = { + name: operationName, + service, + pathElems: [], + }; + service.operations.set(operationName, operation); + } + + // Set focalIdx to first occurrence of focalNode + if ( + path.focalIdx === -1 && + serviceName === focalService && + (focalOperation == null || operationName === focalOperation) + ) { + path.focalIdx = i; + } + + const pathElem = new PathElem({ path, operation, memberIdx: i }); + operation.pathElems.push(pathElem); + return pathElem; + }); + + if (path.focalIdx === -1) { + throw new Error('A payload path lacked the focalNode'); + } + + // Track all pathElems by their distance for visibilityIdx assignment and hop management + // This needs to be a separate loop as path.focalIdx must be set before distance can be calculated + path.members.forEach(member => { + const pathElemsAtDistance = pathElemsByDistance.get(member.distance); + if (pathElemsAtDistance) { + pathElemsAtDistance.push(member); + } else { + pathElemsByDistance.set(member.distance, [member]); + } + }); + + return path; + }); + + // Assign visibility indices such there is a positive, dependent correlation between visibilityIdx and distance + let downstream = 0; + let downstreamPathElems: PathElem[] | void; + let upstream = 1; + let upstreamPathElems: PathElem[] | void; + let visibilityIdx = 0; + const visibilityIdxToPathElem: TDdgVisibilityIdxToPathElem = new Map(); + function setPathElemVisibilityIdx(pathElem: PathElem) { + visibilityIdxToPathElem.set(visibilityIdx, pathElem); + pathElem.visibilityIdx = visibilityIdx++; // eslint-disable-line no-param-reassign + } + do { + downstreamPathElems = pathElemsByDistance.get(downstream--); + upstreamPathElems = pathElemsByDistance.get(upstream++); + if (downstreamPathElems) downstreamPathElems.forEach(setPathElemVisibilityIdx); + if (upstreamPathElems) upstreamPathElems.forEach(setPathElemVisibilityIdx); + } while (downstreamPathElems || upstreamPathElems); + + return { + paths, + pathElemsByDistance, + services: serviceMap, + visibilityIdxToPathElem, + }; +} diff --git a/packages/jaeger-ui/src/model/ddg/types.tsx b/packages/jaeger-ui/src/model/ddg/types.tsx new file mode 100644 index 0000000000..ef035d04b1 --- /dev/null +++ b/packages/jaeger-ui/src/model/ddg/types.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2019 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PathElem from './PathElem'; + +export { default as PathElem } from './PathElem'; + +export type TDdgPayloadEntry = { + operation: string; + service: string; +}; + +export type TDdgPayload = TDdgPayloadEntry[][]; + +export type TDdgService = { + name: string; + operations: Map; +}; + +export type TDdgOperation = { + name: string; + pathElems: PathElem[]; + service: TDdgService; +}; + +export type TDdgPath = { + focalIdx: number; + members: PathElem[]; +}; + +export type TDdgServiceMap = Map; + +export type TDdgPathElemsByDistance = Map; +export type TDdgVisibilityIdxToPathElem = Map; + +export type TDdgModel = { + pathElemsByDistance: TDdgPathElemsByDistance; + paths: TDdgPath[]; + services: TDdgServiceMap; + visibilityIdxToPathElem: TDdgVisibilityIdxToPathElem; +};