diff --git a/package.json b/package.json
index 2f8a07ab7b29a..94a121a338b14 100644
--- a/package.json
+++ b/package.json
@@ -147,6 +147,7 @@
"core-js": "2.6.9",
"css-loader": "2.1.1",
"custom-event-polyfill": "^0.3.0",
+ "cytoscape": "^3.8.1",
"d3": "3.5.17",
"d3-cloud": "1.2.5",
"del": "^4.0.0",
@@ -211,6 +212,7 @@
"react": "^16.8.0",
"react-addons-shallow-compare": "15.6.2",
"react-color": "^2.13.8",
+ "react-cytoscapejs": "^1.1.0",
"react-dom": "^16.8.0",
"react-grid-layout": "^0.16.2",
"react-hooks-testing-library": "^0.5.0",
diff --git a/src/legacy/core_plugins/apm_oss/index.js b/src/legacy/core_plugins/apm_oss/index.js
index 6c0c6d0e5fe52..43d283bafcc1f 100644
--- a/src/legacy/core_plugins/apm_oss/index.js
+++ b/src/legacy/core_plugins/apm_oss/index.js
@@ -38,7 +38,12 @@ export default function apmOss(kibana) {
spanIndices: Joi.string().default('apm-*'),
metricsIndices: Joi.string().default('apm-*'),
onboardingIndices: Joi.string().default('apm-*'),
- apmAgentConfigurationIndex: Joi.string().default('.apm-agent-configuration')
+ apmAgentConfigurationIndex: Joi.string().default('.apm-agent-configuration'),
+
+ serviceMapIndexPattern: Joi.string().default('apm-*'),
+ serviceMapDestinationIndex: Joi.string(),
+ serviceMapDestinationPipeline: Joi.string(),
+
}).default();
},
@@ -49,7 +54,10 @@ export default function apmOss(kibana) {
'transactionIndices',
'spanIndices',
'metricsIndices',
- 'onboardingIndices'
+ 'onboardingIndices',
+ 'serviceMapIndexPattern',
+ 'serviceMapDestinationIndex',
+ 'serviceMapDestinationPipeline'
].map(type => server.config().get(`apm_oss.${type}`))));
}
});
diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts
index aac946a36b458..42f62d23e152d 100644
--- a/x-pack/legacy/plugins/apm/index.ts
+++ b/x-pack/legacy/plugins/apm/index.ts
@@ -14,10 +14,18 @@ import {
import { LegacyPluginInitializer } from '../../../../src/legacy/types';
import mappings from './mappings.json';
import { plugin } from './server/new-platform/index';
+import { TaskManager, RunContext } from '../legacy/plugins/task_manager';
+import { serviceMapRun } from './server/lib/servicemap';
export const apm: LegacyPluginInitializer = kibana => {
return new kibana.Plugin({
- require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'],
+ require: [
+ 'kibana',
+ 'elasticsearch',
+ 'xpack_main',
+ 'apm_oss',
+ 'task_manager'
+ ],
id: 'apm',
configPrefix: 'xpack.apm',
publicDir: resolve(__dirname, 'public'),
@@ -104,13 +112,88 @@ export const apm: LegacyPluginInitializer = kibana => {
}
});
- const initializerContext = {} as PluginInitializerContext;
- const core = {
- http: {
- server
+ // fires off the job
+ // needed this during debugging
+ server.route({
+ method: 'GET',
+ path: '/api/apm/servicemap',
+ options: {
+ tags: ['access:apm']
+ },
+ handler: req => {
+ return serviceMapRun(this.kbnServer, this.kbnServer.config);
}
- } as InternalCoreSetup;
- plugin(initializerContext).setup(core);
+ });
+
+ const { taskManager } = server;
+ if (taskManager) {
+ // console.log('registering task');
+ taskManager.registerTaskDefinitions({
+ // serviceMap is the task type, and must be unique across the entire system
+ serviceMap: {
+ // Human friendly name, used to represent this task in logs, UI, etc
+ title: 'ServiceMapTask',
+
+ // Optional, human-friendly, more detailed description
+ description: 'Extract connections in traces for service maps',
+
+ // Optional, how long, in minutes, the system should wait before
+ // a running instance of this task is considered to be timed out.
+ // This defaults to 5 minutes.
+ timeout: '5m',
+
+ // The serviceMap task occupies 2 workers, so if the system has 10 worker slots,
+ // 5 serviceMap tasks could run concurrently per Kibana instance. This value is
+ // overridden by the `override_num_workers` config value, if specified.
+ numWorkers: 1,
+
+ // The createTaskRunner function / method returns an object that is responsible for
+ // performing the work of the task. context: { taskInstance, kbnServer }, is documented below.
+ createTaskRunner({ kbnServer, taskInstance }: RunContext) {
+ // Perform the work of the task. The return value should fit the TaskResult interface, documented
+ // below. Invalid return values will result in a logged warning.
+ return {
+ async run() {
+ const { state } = taskInstance;
+
+ const { mostRecent } = await serviceMapRun(
+ kbnServer,
+ kbnServer.config,
+ state.lastRun
+ );
+
+ return {
+ state: {
+ count: (state.count || 0) + 1,
+ lastRun: mostRecent
+ }
+ };
+ }
+ };
+ }
+ }
+ });
+
+ this.kbnServer.afterPluginsInit(() => {
+ // console.log('ahout to schedule');
+ const task = taskManager.schedule({
+ id: 'servicemap-processor',
+ taskType: 'serviceMap',
+ interval: '1m',
+ scope: ['apm']
+ });
+ // .catch(e => console.log('Err scheduling', e));
+ // console.log('scheduled', JSON.stringify(task));
+ });
+
+ const initializerContext = {} as PluginInitializerContext;
+ const core = {
+ http: {
+ server
+ }
+ } as InternalCoreSetup;
+ plugin(initializerContext).setup(core);
+ }
}
});
};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx
index 665bcf7e1aacb..3d8e29a31aace 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx
@@ -17,7 +17,10 @@ import { HistoryTabs, IHistoryTab } from '../../shared/HistoryTabs';
import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink';
import { ServiceOverview } from '../ServiceOverview';
import { TraceOverview } from '../TraceOverview';
+import { ServiceMap } from '../ServiceMap';
+
import { APMLink } from '../../shared/Links/APMLink';
+import { useUrlParams } from '../../../hooks/useUrlParams';
const homeTabs: IHistoryTab[] = [
{
@@ -35,6 +38,14 @@ const homeTabs: IHistoryTab[] = [
}),
render: () => ,
name: 'traces'
+ },
+ {
+ path: '/servicemap',
+ title: i18n.translate('xpack.apm.home.tracesTabLabel', {
+ defaultMessage: 'Service Map'
+ }),
+ render: () => ,
+ name: 'servicemap'
}
];
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx
index 5a2c16c9fbfe0..935b5eca058a7 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx
@@ -57,6 +57,15 @@ export const routes: BreadcrumbRoute[] = [
}),
name: RouteName.TRACES
},
+ {
+ exact: true,
+ path: '/servicemap',
+ component: Home,
+ breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', {
+ defaultMessage: 'Service Map'
+ }),
+ name: RouteName.SERVICEMAP
+ },
{
exact: true,
path: '/settings',
@@ -126,5 +135,12 @@ export const routes: BreadcrumbRoute[] = [
breadcrumb: ({ match }) =>
legacyDecodeURIComponent(match.params.transactionName) || '',
name: RouteName.TRANSACTION_NAME
+ },
+ {
+ exact: true,
+ path: '/:serviceName/servicemap',
+ component: ServiceDetails,
+ breadcrumb: null,
+ name: RouteName.SERVICE_SERVICEMAP
}
];
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx
index 10b633c40560f..954c2163d2571 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx
@@ -9,7 +9,9 @@ export enum RouteName {
SERVICES = 'services',
TRACES = 'traces',
SERVICE = 'service',
+ SERVICEMAP = 'servicemap',
TRANSACTIONS = 'transactions',
+ SERVICE_SERVICEMAP = 'service_servicemap',
ERRORS = 'errors',
ERROR = 'error',
METRICS = 'metrics',
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx
index ec7d3a827ce5e..091275f80350a 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx
@@ -11,6 +11,7 @@ import { HistoryTabs } from '../../shared/HistoryTabs';
import { ErrorGroupOverview } from '../ErrorGroupOverview';
import { TransactionOverview } from '../TransactionOverview';
import { ServiceMetrics } from '../ServiceMetrics';
+import { ServiceMap } from '../ServiceMap';
interface Props {
transactionTypes: string[];
@@ -61,9 +62,18 @@ export function ServiceDetailTabs({
render: () => ,
name: 'metrics'
};
+
+ const serviceMapTab = {
+ title: i18n.translate('xpack.apm.serviceDetails.mapTabLabel', {
+ defaultMessage: 'Service Map'
+ }),
+ path: `/${serviceName}/servicemap`,
+ render: () => ,
+ name: 'servicemap'
+ };
const tabs = isRumAgent
- ? [transactionsTab, errorsTab]
- : [transactionsTab, errorsTab, metricsTab];
+ ? [transactionsTab, errorsTab, serviceMapTab]
+ : [transactionsTab, errorsTab, serviceMapTab, metricsTab];
return ;
}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/MapOfServices.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/MapOfServices.tsx
new file mode 100644
index 0000000000000..4f4b4b837f782
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/MapOfServices.tsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import CytoscapeComponent from 'react-cytoscapejs';
+import { IUrlParams } from '../../../context/UrlParamsContext/types';
+
+function elementId(serviceName: string, environment?: string) {
+ return serviceName + '/' + (environment ? '/' + environment : '');
+}
+
+function label(serviceName: string, environment?: string) {
+ return serviceName + (environment ? '/' + environment : '');
+}
+
+// interface Props {
+// connections: any;
+// }
+export function MapOfServices({ connections, layout }: any) {
+ const services: { [s: string]: object } = {};
+ const conns: Array<{ data: object }> = [];
+ let destNodeID;
+ connections.forEach(c => {
+ if (c['callee.name']) {
+ destNodeID = elementId(c['callee.name'], c['callee.environment']);
+ const node = {
+ data: {
+ id: destNodeID,
+ label: label(c['callee.name'], c['callee.environment']),
+ color: 'black'
+ }
+ };
+ services[destNodeID] = node;
+ } else {
+ destNodeID = elementId(c['destination.address']);
+ services[destNodeID] = {
+ data: {
+ id: destNodeID,
+ label: label(c['destination.address'])
+ }
+ };
+ }
+ const sourceNodeID = elementId(c['service.name'], c['service.environment']);
+
+ services[sourceNodeID] = {
+ data: {
+ id: sourceNodeID,
+ label: label(c['service.name'], c['service.environment']),
+ color: 'black'
+ }
+ };
+
+ conns.push({
+ data: {
+ source: sourceNodeID,
+ target: destNodeID,
+ label: c['connection.subtype']
+ }
+ });
+ });
+ const elements = Object.values(services).concat(conns);
+ // const elements = [];
+ // { data: { id: 'one', label: 'Node 1' }},
+ // { data: { id: 'two', label: 'Node 2' }},
+ // { data: { source: 'one', target: 'two', label: 'Edge from Node1 to Node2' } }
+ // ];
+ const stylesheet = [
+ {
+ selector: 'node',
+ style: {
+ label: 'data(label)' // maps to data.label
+ }
+ },
+ {
+ selector: 'node[color]',
+ style: {
+ 'background-color': 'data(color)'
+ }
+ },
+ {
+ selector: 'edge',
+ style: {
+ width: 2,
+ label: 'data(label)', // maps to data.label
+ 'curve-style': 'bezier',
+ 'target-arrow-shape': 'triangle'
+ }
+ }
+ ];
+
+ return (
+
+ );
+}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx
new file mode 100644
index 0000000000000..00dc80dc1b36a
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiPanel } from '@elastic/eui';
+import React from 'react';
+import { useFetcher } from '../../../hooks/useFetcher';
+import { loadServiceMap } from '../../../services/rest/apm/services';
+import { MapOfServices } from './MapOfServices';
+import { useUrlParams } from '../../../hooks/useUrlParams';
+
+const initalData = {
+ items: [],
+ hasHistoricalData: true,
+ hasLegacyData: false
+};
+
+interface Props {
+ global?: boolean;
+ layout?: string;
+}
+
+export function ServiceMap({ global = false, layout = 'circle' }: Props) {
+ // console.log(global);
+ const {
+ urlParams: { start, end, serviceName },
+ uiFilters
+ } = useUrlParams();
+
+ const { data = initalData } = useFetcher(() => {
+ if (start && end) {
+ return loadServiceMap({
+ start,
+ end,
+ // TODO: i did this because useUrlParams() does not return
+ // the right service name when used at the higher level
+ serviceName: global ? undefined : serviceName,
+ uiFilters
+ });
+ }
+ }, [start, end, uiFilters]);
+
+ return (
+
+ {data.map ? (
+
+ ) : null}
+
+ );
+}
diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts
index 12c58d8ad54cc..261d1a0ebec15 100644
--- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts
+++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts
@@ -59,7 +59,6 @@ export function removeUndefinedProps(obj: T): Partial {
export function getPathParams(pathname: string = '') {
const paths = getPathAsArray(pathname);
const pageName = paths.length > 1 ? paths[1] : paths[0];
-
// TODO: use react router's real match params instead of guessing the path order
switch (pageName) {
case 'transactions':
@@ -69,6 +68,8 @@ export function getPathParams(pathname: string = '') {
transactionType: paths[2],
transactionName: paths[3]
};
+ case 'servicemap':
+ return { serviceName: paths[0] };
case 'errors':
return {
processorEvent: 'error',
diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts
index 538cdfa79fdb2..5f30ad84fc42d 100644
--- a/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts
+++ b/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts
@@ -9,6 +9,7 @@ import { ServiceListAPIResponse } from '../../../../server/lib/services/get_serv
import { callApi } from '../callApi';
import { getUiFiltersES } from '../../ui_filters/get_ui_filters_es';
import { UIFilters } from '../../../../typings/ui-filters';
+import { ServiceMapAPIResponse } from '../../../../server/lib/services/map';
export async function loadServiceList({
start,
@@ -49,3 +50,25 @@ export async function loadServiceDetails({
}
});
}
+
+export async function loadServiceMap({
+ start,
+ end,
+ uiFilters,
+ serviceName
+}: {
+ start: string;
+ end: string;
+ uiFilters: UIFilters;
+ serviceName?: string;
+}) {
+ return callApi({
+ pathname: '/api/apm/services/map',
+ query: {
+ start,
+ end,
+ serviceName,
+ uiFiltersES: await getUiFiltersES(uiFilters)
+ }
+ });
+}
diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts
index 20db3fc6bd526..8138a1cd4f01e 100644
--- a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts
+++ b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts
@@ -17,6 +17,7 @@ export interface APMRequestQuery {
start: string;
end: string;
uiFiltersES?: string;
+ serviceName?: string;
}
export type Setup = ReturnType;
diff --git a/x-pack/legacy/plugins/apm/server/lib/servicemap.ts b/x-pack/legacy/plugins/apm/server/lib/servicemap.ts
new file mode 100644
index 0000000000000..2598f32240c20
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/lib/servicemap.ts
@@ -0,0 +1,432 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+
+Necessary scripts:
+
+POST _scripts/map-service-conns
+{
+ "script": {
+ "lang": "painless",
+ "source": """
+ def s = new HashMap();
+
+ if (!doc['span.id'].empty) {
+ s.id = doc['span.id'].value
+ } else {
+ s.id = doc['transaction.id'].value;
+ s.transaction = true;
+ }
+ if (!doc['parent.id'].empty) {
+ s.parent = doc['parent.id'].value;
+ }
+ if (!doc['service.environment'].empty) {
+ s.environment = doc['service.environment'].value;
+ }
+
+ if (!doc['destination.address'].empty) {
+ s.destination = doc['destination.address'].value;
+ }
+
+ if (!doc['_index'].empty) {
+ s._index = doc['_index'].value;
+ }
+
+ if (!doc['span.type'].empty) {
+ s.span_type = doc['span.type'].value;
+ }
+
+ if (!doc['span.subtype'].empty) {
+ s.span_subtype = doc['span.subtype'].value;
+ }
+
+ s.timestamp = doc['@timestamp'].value;
+ s.service_name = doc['service.name'].value;
+ if(!state.spans.containsKey(s.parent)) {
+ state.spans.put(s.parent, new ArrayList())
+ }
+
+ if (s.parent != s.id) {
+ state.spans[s.parent].add(s)
+ }
+ """
+ }
+}
+
+POST _scripts/reduce-service-conns
+{
+ "script": {
+ "lang": "painless",
+ "source": """
+ void extractChildren(def caller, def spans, def upstream, def conns, def count) {
+ // TODO: simplify this
+ if (spans.containsKey(caller.id)) {
+ for(s in spans[caller.id]) {
+ if (caller.span_type=='external') {
+ upstream.add(caller.service_name+"/"+caller.environment);
+
+ def conn = new HashMap();
+ conn.caller = caller;
+ conn.callee = s;
+ conn.upstream = new ArrayList(upstream);
+ conns.add(conn);
+
+ extractChildren(s, spans, upstream, conns, count);
+ upstream.remove(upstream.size()-1);
+ } else {
+ extractChildren(s, spans, upstream, conns, count);
+ }
+ }
+ } else {
+ // no connection found
+ def conn = new HashMap();
+ conn.caller = caller;
+ conn.upstream = new ArrayList(upstream);
+ conn.upstream.add(caller.service_name+"/"+caller.environment);
+ conns.add(conn);
+ }
+ }
+ def conns = new HashSet();
+ def spans = new HashMap();
+
+ // merge results from shards
+ for(state in states) {
+ for(s in state.entrySet()) {
+ def v = s.getValue();
+ def k = s.getKey();
+ if(!spans.containsKey(k)) {
+ spans[k] = v;
+ } else {
+ for (p in v) {
+ spans[k].add(p);
+ }
+ }
+ }
+ }
+
+ if (spans.containsKey(null) && spans[null].size() > 0) {
+ def node = spans[null][0];
+ def upstream = new ArrayList();
+
+ extractChildren(node, spans, upstream, conns, 0);
+
+ return new ArrayList(conns)
+ }
+ return [];
+ """
+ }
+}
+
+POST _scripts/combine-service-conns
+{
+ "script": {
+ "lang": "painless",
+ "source": "return state.spans"
+ }
+}
+
+
+
+PUT /_ingest/pipeline/extract_destination
+{
+ "description": "sets destination on ext spans based on their name",
+ "processors": [
+ {
+ "set": {
+ "if": "ctx.span != null && ctx.span.type == 'ext'",
+ "field": "span.type",
+ "value": "external"
+ }
+ },
+ {
+ "script": """
+ if(ctx['span'] != null) {
+ if (ctx['span']['type'] == 'external') {
+ def spanName = ctx['span']['name'];
+ if (spanName.indexOf('/') > -1) {
+ spanName = spanName.substring(0, spanName.indexOf('/'));
+ }
+
+ if (spanName.indexOf(' ') > -1) {
+ spanName = spanName.substring(spanName.indexOf(' ')+1, spanName.length());
+ }
+ ctx['destination.address']=spanName;
+ }
+
+ if (ctx['span']['type'] == 'resource') {
+ def spanName = ctx['span']['name'];
+
+ if (spanName.indexOf('://') > -1) {
+ spanName = spanName.substring(spanName.indexOf('://')+3, spanName.length());
+ }
+ if (spanName.indexOf('/') > -1) {
+ spanName = spanName.substring(0, spanName.indexOf('/'));
+ }
+
+ ctx['destination.address']=spanName;
+ }
+
+ if (ctx['span']['type'] == 'db') {
+ def dest = ctx['span']['subtype'];
+ ctx['destination.address']=dest;
+ }
+
+ if (ctx['span']['type'] == 'cache') {
+ def dest = ctx['span']['subtype'];
+ ctx['destination.address']=dest;
+ }
+ }
+ """
+ }
+ ]
+}
+
+*/
+
+function interestingTransactions(since?: string, afterKey?: any) {
+ if (!since) {
+ since = 'now-1h';
+ }
+ const query = {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { exists: { field: 'destination.address' } },
+ { exists: { field: 'trace.id' } },
+ { exists: { field: 'span.duration.us' } },
+ { range: { '@timestamp': { gt: since } } }
+ ]
+ }
+ },
+ aggs: {
+ 'ext-conns': {
+ composite: {
+ sources: [
+ { 'service.name': { terms: { field: 'service.name' } } },
+ { 'span.type': { terms: { field: 'span.type' } } },
+ {
+ 'span.subtype': {
+ terms: { field: 'span.subtype', missing_bucket: true }
+ }
+ },
+ {
+ 'service.environment': {
+ terms: { field: 'service.environment', missing_bucket: true }
+ }
+ },
+ {
+ 'destination.address': { terms: { field: 'destination.address' } }
+ }
+ ],
+ // TODO: needs to be balanced with the 20 below
+ size: 200
+ },
+ aggs: {
+ smpl: {
+ diversified_sampler: {
+ shard_size: 20,
+ script: {
+ lang: 'painless',
+ source: "(int)doc['span.duration.us'].value/100000"
+ }
+ },
+ aggs: {
+ tracesample: {
+ top_hits: {
+ size: 20,
+ _source: ['trace.id', '@timestamp']
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ if (afterKey) {
+ query.aggs['ext-conns'].composite.after = afterKey;
+ }
+ return query;
+}
+
+function findConns(traceIds: string[]) {
+ const query = {
+ size: 0,
+ query: {
+ bool: {
+ should: [
+ { exists: { field: 'span.id' } },
+ { exists: { field: 'transaction.type' } }
+ ],
+ minimum_should_match: 2
+ }
+ },
+ aggs: {
+ trace_id: {
+ terms: {
+ field: 'trace.id',
+ order: { _key: 'asc' },
+ size: traceIds.length
+ },
+ aggs: {
+ connections: {
+ scripted_metric: {
+ init_script: 'state.spans = new HashMap();',
+ map_script: { id: 'map-service-conns' },
+ combine_script: { id: 'combine-service-conns' },
+ reduce_script: { id: 'reduce-service-conns' }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ for (const tid of traceIds) {
+ query.query.bool.should.push({ term: { 'trace.id': tid } });
+ }
+ return query;
+}
+
+interface Service {
+ name: string;
+ environment?: string;
+}
+
+interface ConnectionDoc {
+ '@timestamp': string;
+ observer: { version_major: number }; // TODO: make this dynamic
+ service: Service;
+ callee?: Service;
+ connection: Connection;
+ destination?: { address: string };
+}
+
+interface Connection {
+ upstream: { list: string[]; keyword: string };
+ in_trace: string[];
+ type: string;
+ subtype?: string;
+}
+
+export async function serviceMapRun(kbnServer, config, lastRun?: string) {
+ const apmIdxPattern = config.get('apm_oss.indexPattern');
+ const serviceConnsDestinationIndex = config.get(
+ 'apm_oss.serviceMapDestinationIndex'
+ );
+ const serviceConnsDestinationPipeline = config.get(
+ 'apm_oss.serviceMapDestinationPipeline'
+ );
+
+ const callCluster = kbnServer.server.plugins.elasticsearch.getCluster('data')
+ .callWithInternalUser;
+
+ let mostRecent = '';
+ let afterKey = null;
+ // console.log('HELLO TASK', lastRun);
+ while (true) {
+ const q = interestingTransactions(lastRun, afterKey);
+ const txs = await callCluster('search', {
+ index: apmIdxPattern,
+ body: q
+ });
+
+ if (txs.aggregations['ext-conns'].buckets.length < 1) {
+ // console.log('No buckets');
+ // console.log(JSON.stringify(q));
+ return { mostRecent };
+ }
+ afterKey = txs.aggregations['ext-conns'].after_key;
+
+ const traces = new Set();
+
+ txs.aggregations['ext-conns'].buckets.forEach((bucket: any) => {
+ bucket.smpl.tracesample.hits.hits.forEach((hit: any) => {
+ traces.add(hit._source.trace.id);
+ mostRecent =
+ mostRecent > hit._source['@timestamp']
+ ? mostRecent
+ : hit._source['@timestamp'];
+ });
+ });
+
+ const traceIds = Array.from(traces.values());
+ if (traceIds.length < 1) {
+ return { mostRecent: null };
+ }
+
+ const findConnsQ = findConns(traceIds);
+
+ const connsResult = await callCluster('search', {
+ index: apmIdxPattern,
+ body: findConnsQ
+ });
+
+ const connDocs = [];
+
+ connsResult.aggregations.trace_id.buckets.forEach((bucket: any) => {
+ const allServices = new Set(
+ bucket.connections.value.map(
+ (conn: any) =>
+ conn.caller.service_name +
+ '/' +
+ (conn.caller.environment ? conn.caller.environment : 'null')
+ )
+ );
+
+ bucket.connections.value.forEach((conn: any) => {
+ const index = serviceConnsDestinationIndex
+ ? serviceConnsDestinationIndex
+ : conn.caller._index;
+ const bulkOpts = { index: { _index: index } };
+
+ if (serviceConnsDestinationPipeline) {
+ bulkOpts.pipeline = serviceConnsDestinationPipeline;
+ }
+ connDocs.push(bulkOpts);
+ const doc: ConnectionDoc = {
+ '@timestamp': conn.caller.timestamp,
+ observer: { version_major: 7 }, // TODO: make this dynamic
+ service: {
+ name: conn.caller.service_name,
+ environment: conn.caller.environment
+ },
+ callee: conn.callee
+ ? {
+ name: conn.callee.service_name,
+ environment: conn.callee.environment
+ }
+ : undefined,
+ connection: {
+ upstream: {
+ list: conn.upstream,
+ keyword: conn.upstream.join('->')
+ },
+ in_trace: Array.from(allServices),
+ type: conn.caller.span_type,
+ subtype: conn.caller.span_substype
+ },
+ destination: conn.caller.destination
+ ? { address: conn.caller.destination }
+ : undefined
+ };
+
+ connDocs.push(doc);
+ });
+ });
+
+ // console.log(JSON.stringify(connDocs));
+ const body = connDocs.map(JSON.stringify).join('\n');
+ // console.log(body);
+ const bulkResult = await callCluster('bulk', {
+ body
+ });
+ // TODO: check result
+ }
+}
diff --git a/x-pack/legacy/plugins/apm/server/lib/services/map.ts b/x-pack/legacy/plugins/apm/server/lib/services/map.ts
new file mode 100644
index 0000000000000..f14e9c7d3dbd1
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/lib/services/map.ts
@@ -0,0 +1,115 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { idx } from '@kbn/elastic-idx';
+import { PromiseReturnType } from '../../../typings/common';
+import { rangeFilter } from '../helpers/range_filter';
+import { Setup } from '../helpers/setup_request';
+
+export interface IEnvOptions {
+ setup: Setup;
+ serviceName?: string;
+ environment?: string;
+}
+
+export type ServiceMapAPIResponse = PromiseReturnType;
+export async function getConnections({
+ setup,
+ serviceName,
+ environment
+}: IEnvOptions) {
+ const { start, end, client, config } = setup;
+
+ const params = {
+ index: config.get('apm_oss.serviceMapIndexPattern'),
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { range: rangeFilter(start, end) },
+ { exists: { field: 'connection.type' } }
+ ]
+ }
+ },
+ aggs: {
+ conns: {
+ composite: {
+ sources: [
+ { 'service.name': { terms: { field: 'service.name' } } },
+ {
+ 'service.environment': {
+ terms: { field: 'service.environment', missing_bucket: true }
+ }
+ },
+ {
+ 'destination.address': {
+ terms: {
+ field: 'destination.address'
+ }
+ }
+ },
+ { 'connection.type': { terms: { field: 'connection.type' } } }, // will filter out regular spans etc.
+ {
+ 'connection.subtype': {
+ terms: { field: 'connection.subtype', missing_bucket: true }
+ }
+ }
+ ],
+ size: 1000
+ },
+ aggs: {
+ dests: {
+ terms: {
+ field: 'callee.name'
+ },
+ aggs: {
+ envs: {
+ terms: {
+ field: 'callee.environment',
+ missing: ''
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ if (serviceName) {
+ serviceName = serviceName + '/' + (environment ? environment : 'null');
+ params.body.query.bool.filter.push({
+ term: { ['connection.upstream.list']: serviceName }
+ });
+ }
+
+ // console.log(JSON.stringify(params.body));
+
+ const { aggregations } = await client.search(params);
+ // console.log(JSON.stringify(aggregations));
+ const buckets = idx(aggregations, _ => _.conns.buckets) || [];
+ const conns = buckets.flatMap((bucket: any) => {
+ const base = bucket.key;
+ if (bucket.dests.buckets.length === 0) {
+ // no connection found
+ return base;
+ } else {
+ return bucket.dests.buckets.flatMap((calleeResult: any) =>
+ calleeResult.envs.buckets.map((env: any) => {
+ return {
+ 'callee.environment': env.key.length >= 0 ? env.key : null,
+ 'callee.name': calleeResult.key,
+ // 'upstream.list': base['connection.upstream.keyword'].split('->'),
+ ...base
+ };
+ })
+ );
+ }
+ });
+
+ return { conns };
+}
diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts
index 063bf0556daf5..3ec7a4497f029 100644
--- a/x-pack/legacy/plugins/apm/server/routes/services.ts
+++ b/x-pack/legacy/plugins/apm/server/routes/services.ts
@@ -5,6 +5,8 @@
*/
import Boom from 'boom';
+import Joi from 'joi';
+
import { InternalCoreSetup } from 'src/core/server';
import { AgentName } from '../../typings/es_schemas/ui/fields/Agent';
import { createApmTelementry, storeApmTelemetry } from '../lib/apm_telemetry';
@@ -12,6 +14,7 @@ import { withDefaultValidators } from '../lib/helpers/input_validation';
import { setupRequest } from '../lib/helpers/setup_request';
import { getService } from '../lib/services/get_service';
import { getServices } from '../lib/services/get_services';
+import { getConnections } from '../lib/services/map';
const ROOT = '/api/apm/services';
const defaultErrorHandler = (err: Error) => {
@@ -46,6 +49,29 @@ export function initServicesApi(core: InternalCoreSetup) {
}
});
+ server.route({
+ method: 'GET',
+ path: `${ROOT}/map`,
+ options: {
+ validate: {
+ query: withDefaultValidators({ serviceName: Joi.string() })
+ },
+ tags: ['access:apm']
+ },
+ handler: async req => {
+ const setup = setupRequest(req);
+
+ const { serviceName, environment } = req.query;
+ const mapProm = getConnections({ setup, serviceName, environment }).catch(
+ defaultErrorHandler
+ );
+
+ const map = await mapProm;
+
+ return { map };
+ }
+ });
+
server.route({
method: 'GET',
path: `${ROOT}/{serviceName}`,
diff --git a/yarn.lock b/yarn.lock
index a44da7ff970b5..78033bec213f3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9544,6 +9544,14 @@ cypress@^3.3.1:
url "0.11.0"
yauzl "2.10.0"
+cytoscape@^3.2.19, cytoscape@^3.8.1:
+ version "3.8.1"
+ resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.8.1.tgz#0b87f646054ea3e86f347e9c53a2fc64df1be31b"
+ integrity sha512-hfoGBlqj8LJPnU5WgmQKdGpZaMknk8dPyGEJlzLa1xPwhmBscPMrmPxK2ffyKJIozeHm4UoYw4gPc0rAif4kVA==
+ dependencies:
+ heap "^0.2.6"
+ lodash.debounce "^4.0.8"
+
d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0, d3-array@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
@@ -14796,6 +14804,11 @@ header-case@^1.0.0:
no-case "^2.2.0"
upper-case "^1.1.3"
+heap@^0.2.6:
+ version "0.2.6"
+ resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.6.tgz#087e1f10b046932fc8594dd9e6d378afc9d1e5ac"
+ integrity sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw=
+
heavy@6.x.x:
version "6.1.0"
resolved "https://registry.yarnpkg.com/heavy/-/heavy-6.1.0.tgz#1bbfa43dc61dd4b543ede3ff87db8306b7967274"
@@ -22746,6 +22759,14 @@ react-color@^2.17.0:
reactcss "^1.2.0"
tinycolor2 "^1.4.1"
+react-cytoscapejs@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/react-cytoscapejs/-/react-cytoscapejs-1.1.0.tgz#2175e444f12f4023901a960f71e761cb5b5d0b3a"
+ integrity sha512-CdeEVsOQmw3Cqz47Hx83RelZLT9y3ct6t+YTQ0nRdAt7n+bYD/bTskAmfAm1GvQhzCKylYi2VcY05B8nC4u2Gw==
+ dependencies:
+ cytoscape "^3.2.19"
+ prop-types "^15.6.2"
+
react-datepicker@v1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-1.4.1.tgz#ee171b71d9853e56f9eece5fc3186402f4648683"