[APM] Distributed Tracing (#24062)

* Adds traces overview with mock data (#22628)
* Updates service overview snapshots
* Adds tests for ManagedTable and ImpactBar
* Refactored transaction overview to use new managed table component
* Removed jsconfig file in apm
* [APM] Distributed tracing - Trace details (waterfall) (#22763)
* [APM] Add typescript to waterfall (#23635)
* [APM] Migrate get_trace and constants to Typescript (#23634)
* [APM] Add types for setup_request (#23762)
* [APM] Adds trace overview queries and some refactoring (#23605)
* ImpactBar component to align EuiProgress usage for impact bars
* Sharing some logic between transaction and trace queries
* Typescript support
* Quick fix ‘banana’
* [APM] Ensure backwards compatibility for v1 and v2 (#23636)
* Make interfaces versioned
* Rename eventType to docType
* Fixes trace links on traces overview (#24089)
* [APM] use react-redux-request (#24117)
* Updated yarn lockfile for new yarn version
* Updated dependency issues for react-router-dom types
* [APM] Display transaction info on span flyout (#24189)
* [APM] Display transaction info on span flyout
* Brings in real location and url param data for transaction flyout
* Converts flyout to TS
* Adds query param state for flyouts with ts support
* Updates styles and uses EuiTabs for transaction flyout
* [APM] Transaction flyout
* [APM] Minor docs cleanup (#24325)
* [APM] Minor docs cleanup
* [APM] Fix issues with v1 spans (#24332)
* [APM] Add agent marks (#24361)
* [APM] Typescript migration for the transaction endpoints (#24397)
* [APM] DT transaction sample header (#24294)

Transaction sample header completed
* Fixes link target for traces overview to include trans/trace ids as query params
* Converts Transaction index file to TS
* Adds trace link to sample section
* Refactors the trace link and applies it to two usages
* Implements transaction sample action context menu
* Calculates and implements duration percentage
* Re-typed how transaction groups work
* Fixes transaction flyout links and context menu
* Removes unnecessary ms multiplication
* Removes unused commented code
* Finalizes infra links
* Fixes some type shenanigans
This commit is contained in:
Søren Louv-Jansen 2018-10-23 22:34:23 +02:00 committed by Jason Rhodes
parent 1bcdeab4dd
commit 3151da280b
149 changed files with 5995 additions and 2880 deletions

View file

@ -62,7 +62,8 @@
"url": "https://github.com/elastic/kibana.git"
},
"resolutions": {
"**/@types/node": "8.10.21"
"**/@types/node": "8.10.21",
"@types/react": "16.3.14"
},
"dependencies": {
"@elastic/eui": "4.5.1",
@ -166,7 +167,7 @@
"react-input-range": "^1.3.0",
"react-markdown": "^3.1.4",
"react-redux": "^5.0.7",
"react-router-dom": "4.2.2",
"react-router-dom": "^4.3.1",
"react-sizeme": "^2.3.6",
"react-toggle": "4.0.2",
"reactcss": "1.2.3",
@ -189,6 +190,7 @@
"topojson-client": "3.0.0",
"trunc-html": "1.0.2",
"trunc-text": "1.0.2",
"ts-optchain": "^0.1.1",
"tslib": "^1.9.3",
"type-detect": "^4.0.8",
"uglifyjs-webpack-plugin": "^1.2.7",
@ -227,7 +229,9 @@
"@types/boom": "^7.2.0",
"@types/chance": "^1.0.0",
"@types/classnames": "^2.2.3",
"@types/d3": "^5.0.0",
"@types/dedent": "^0.7.0",
"@types/elasticsearch": "^5.0.26",
"@types/enzyme": "^3.1.12",
"@types/eslint": "^4.16.2",
"@types/execa": "^0.9.0",
@ -243,19 +247,22 @@
"@types/listr": "^0.13.0",
"@types/lodash": "^3.10.1",
"@types/minimatch": "^2.0.29",
"@types/moment-timezone": "^0.5.8",
"@types/mustache": "^0.8.31",
"@types/node": "^8.10.20",
"@types/prop-types": "^15.5.3",
"@types/puppeteer": "^1.6.2",
"@types/react": "^16.3.14",
"@types/react": "16.3.14",
"@types/react-dom": "^16.0.5",
"@types/react-redux": "^6.0.6",
"@types/react-router-dom": "^4.3.1",
"@types/react-virtualized": "^9.18.7",
"@types/redux": "^3.6.31",
"@types/redux-actions": "^2.2.1",
"@types/semver": "^5.5.0",
"@types/sinon": "^5.0.1",
"@types/strip-ansi": "^3.0.0",
"@types/styled-components": "^3.0.1",
"@types/supertest": "^2.0.5",
"@types/type-detect": "^4.0.1",
"@types/uuid": "^3.4.4",

View file

@ -22,7 +22,8 @@
}
},
"resolutions": {
"**/@types/node": "8.10.21"
"**/@types/node": "8.10.21",
"@types/react": "16.3.14"
},
"devDependencies": {
"@kbn/dev-utils": "link:../packages/kbn-dev-utils",
@ -36,7 +37,7 @@
"@types/d3-shape": "^1.2.2",
"@types/d3-time": "^1.0.7",
"@types/d3-time-format": "^2.1.0",
"@types/elasticsearch": "^5.0.22",
"@types/elasticsearch": "^5.0.26",
"@types/expect.js": "^0.3.29",
"@types/graphql": "^0.13.1",
"@types/hapi": "15.0.1",
@ -48,11 +49,11 @@
"@types/mocha": "^5.2.5",
"@types/pngjs": "^3.3.1",
"@types/prop-types": "^15.5.3",
"@types/react": "^16.3.14",
"@types/react": "16.3.14",
"@types/react-datepicker": "^1.1.5",
"@types/react-dom": "^16.0.5",
"@types/react-redux": "^6.0.6",
"@types/react-router-dom": "4.2.6",
"@types/react-router-dom": "^4.3.1",
"@types/reduce-reducers": "^0.1.3",
"@types/sinon": "^5.0.1",
"@types/supertest": "^2.0.5",
@ -130,7 +131,6 @@
"@samverschueren/stream-to-observable": "^0.3.0",
"@scant/router": "^0.1.0",
"@slack/client": "^4.2.2",
"@types/moment-timezone": "^0.5.8",
"angular-resource": "1.4.9",
"angular-sanitize": "1.4.9",
"angular-ui-ace": "0.2.3",
@ -226,7 +226,7 @@
"react-redux": "^5.0.7",
"react-redux-request": "^1.5.6",
"react-router-breadcrumbs-hoc": "1.1.2",
"react-router-dom": "^4.2.2",
"react-router-dom": "^4.3.1",
"react-select": "^1.2.1",
"react-shortcuts": "^2.0.0",
"react-sticky": "^6.0.1",

View file

@ -0,0 +1,440 @@
/*
* 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 { get } from 'lodash';
import { Span } from '../typings/Span';
import { Transaction } from '../typings/Transaction';
import {
PROCESSOR_EVENT,
PROCESSOR_NAME,
REQUEST_URL_FULL,
SERVICE_AGENT_NAME,
SERVICE_LANGUAGE_NAME,
SERVICE_NAME,
SPAN_DURATION,
SPAN_HEX_ID,
SPAN_ID,
SPAN_NAME,
SPAN_SQL,
SPAN_START,
SPAN_TYPE,
TRANSACTION_DURATION,
TRANSACTION_ID,
TRANSACTION_NAME,
TRANSACTION_RESULT,
TRANSACTION_SAMPLED,
TRANSACTION_TYPE,
USER_ID
} from './constants';
describe('Transaction v1', () => {
const transaction: Transaction = {
version: 'v1',
'@timestamp': new Date().toString(),
beat: {
hostname: 'beat hostname',
name: 'beat name',
version: 'beat version'
},
host: {
name: 'string;'
},
processor: {
name: 'transaction',
event: 'transaction'
},
context: {
system: {
architecture: 'x86',
hostname: 'some-host',
ip: '111.0.2.3',
platform: 'linux'
},
service: {
name: 'service name',
agent: {
name: 'agent name',
version: 'v1337'
},
language: {
name: 'nodejs',
version: 'v1337'
}
},
user: {
id: '1337'
},
request: {
url: {
full: 'http://www.elastic.co'
}
}
},
transaction: {
duration: {
us: 1337
},
id: 'transaction id',
name: 'transaction name',
result: 'transaction result',
sampled: true,
type: 'transaction type'
}
};
// service
it('SERVICE_NAME', () => {
expect(get(transaction, SERVICE_NAME)).toBe('service name');
});
it('SERVICE_AGENT_NAME', () => {
expect(get(transaction, SERVICE_AGENT_NAME)).toBe('agent name');
});
it('SERVICE_LANGUAGE_NAME', () => {
expect(get(transaction, SERVICE_LANGUAGE_NAME)).toBe('nodejs');
});
it('REQUEST_URL_FULL', () => {
expect(get(transaction, REQUEST_URL_FULL)).toBe('http://www.elastic.co');
});
it('USER_ID', () => {
expect(get(transaction, USER_ID)).toBe('1337');
});
// processor
it('PROCESSOR_NAME', () => {
expect(get(transaction, PROCESSOR_NAME)).toBe('transaction');
});
it('PROCESSOR_EVENT', () => {
expect(get(transaction, PROCESSOR_EVENT)).toBe('transaction');
});
// transaction
it('TRANSACTION_DURATION', () => {
expect(get(transaction, TRANSACTION_DURATION)).toBe(1337);
});
it('TRANSACTION_TYPE', () => {
expect(get(transaction, TRANSACTION_TYPE)).toBe('transaction type');
});
it('TRANSACTION_RESULT', () => {
expect(get(transaction, TRANSACTION_RESULT)).toBe('transaction result');
});
it('TRANSACTION_NAME', () => {
expect(get(transaction, TRANSACTION_NAME)).toBe('transaction name');
});
it('TRANSACTION_ID', () => {
expect(get(transaction, TRANSACTION_ID)).toBe('transaction id');
});
it('TRANSACTION_SAMPLED', () => {
expect(get(transaction, TRANSACTION_SAMPLED)).toBe(true);
});
});
describe('Transaction v2', () => {
const transaction: Transaction = {
version: 'v2',
'@timestamp': new Date().toString(),
beat: {
hostname: 'beat hostname',
name: 'beat name',
version: 'beat version'
},
host: { name: 'string;' },
processor: { name: 'transaction', event: 'transaction' },
timestamp: { us: 1337 },
trace: { id: 'trace id' },
context: {
system: {
architecture: 'x86',
hostname: 'some-host',
ip: '111.0.2.3',
platform: 'linux'
},
service: {
name: 'service name',
agent: { name: 'agent name', version: 'v1337' },
language: { name: 'nodejs', version: 'v1337' }
},
user: { id: '1337' },
request: { url: { full: 'http://www.elastic.co' } }
},
transaction: {
duration: { us: 1337 },
id: 'transaction id',
name: 'transaction name',
result: 'transaction result',
sampled: true,
type: 'transaction type'
}
};
// service
it('SERVICE_NAME', () => {
expect(get(transaction, SERVICE_NAME)).toBe('service name');
});
it('SERVICE_AGENT_NAME', () => {
expect(get(transaction, SERVICE_AGENT_NAME)).toBe('agent name');
});
it('SERVICE_LANGUAGE_NAME', () => {
expect(get(transaction, SERVICE_LANGUAGE_NAME)).toBe('nodejs');
});
it('REQUEST_URL_FULL', () => {
expect(get(transaction, REQUEST_URL_FULL)).toBe('http://www.elastic.co');
});
it('USER_ID', () => {
expect(get(transaction, USER_ID)).toBe('1337');
});
// processor
it('PROCESSOR_NAME', () => {
expect(get(transaction, PROCESSOR_NAME)).toBe('transaction');
});
it('PROCESSOR_EVENT', () => {
expect(get(transaction, PROCESSOR_EVENT)).toBe('transaction');
});
// transaction
it('TRANSACTION_DURATION', () => {
expect(get(transaction, TRANSACTION_DURATION)).toBe(1337);
});
it('TRANSACTION_TYPE', () => {
expect(get(transaction, TRANSACTION_TYPE)).toBe('transaction type');
});
it('TRANSACTION_RESULT', () => {
expect(get(transaction, TRANSACTION_RESULT)).toBe('transaction result');
});
it('TRANSACTION_NAME', () => {
expect(get(transaction, TRANSACTION_NAME)).toBe('transaction name');
});
it('TRANSACTION_ID', () => {
expect(get(transaction, TRANSACTION_ID)).toBe('transaction id');
});
it('TRANSACTION_SAMPLED', () => {
expect(get(transaction, TRANSACTION_SAMPLED)).toBe(true);
});
});
describe('Span v1', () => {
const span: Span = {
version: 'v1',
'@timestamp': new Date().toString(),
beat: {
hostname: 'beat hostname',
name: 'beat name',
version: 'beat version'
},
host: {
name: 'string;'
},
processor: {
name: 'transaction',
event: 'span'
},
context: {
db: {
statement: 'db statement'
},
service: {
name: 'service name',
agent: {
name: 'agent name',
version: 'v1337'
},
language: {
name: 'nodejs',
version: 'v1337'
}
}
},
span: {
duration: {
us: 1337
},
start: {
us: 1337
},
name: 'span name',
type: 'span type',
id: 1337
},
transaction: {
id: 'transaction id'
}
};
// service
it('SERVICE_NAME', () => {
expect(get(span, SERVICE_NAME)).toBe('service name');
});
it('SERVICE_AGENT_NAME', () => {
expect(get(span, SERVICE_AGENT_NAME)).toBe('agent name');
});
it('SERVICE_LANGUAGE_NAME', () => {
expect(get(span, SERVICE_LANGUAGE_NAME)).toBe('nodejs');
});
// processor
it('PROCESSOR_NAME', () => {
expect(get(span, PROCESSOR_NAME)).toBe('transaction');
});
it('PROCESSOR_EVENT', () => {
expect(get(span, PROCESSOR_EVENT)).toBe('span');
});
// span
it('SPAN_START', () => {
expect(get(span, SPAN_START)).toBe(1337);
});
it('SPAN_DURATION', () => {
expect(get(span, SPAN_DURATION)).toBe(1337);
});
it('SPAN_TYPE', () => {
expect(get(span, SPAN_TYPE)).toBe('span type');
});
it('SPAN_NAME', () => {
expect(get(span, SPAN_NAME)).toBe('span name');
});
it('SPAN_ID', () => {
expect(get(span, SPAN_ID)).toBe(1337);
});
it('SPAN_SQL', () => {
expect(get(span, SPAN_SQL)).toBe('db statement');
});
it('SPAN_HEX_ID', () => {
expect(get(span, SPAN_HEX_ID)).toBe(undefined);
});
});
describe('Span v2', () => {
const span: Span = {
version: 'v2',
'@timestamp': new Date().toString(),
beat: {
hostname: 'beat hostname',
name: 'beat name',
version: 'beat version'
},
host: {
name: 'string;'
},
processor: {
name: 'transaction',
event: 'span'
},
timestamp: {
us: 1337
},
trace: {
id: 'trace id'
},
context: {
db: {
statement: 'db statement'
},
service: {
name: 'service name',
agent: {
name: 'agent name',
version: 'v1337'
},
language: {
name: 'nodejs',
version: 'v1337'
}
}
},
span: {
duration: {
us: 1337
},
name: 'span name',
type: 'span type',
id: 1337,
hex_id: 'hex id'
},
transaction: {
id: 'transaction id'
}
};
// service
it('SERVICE_NAME', () => {
expect(get(span, SERVICE_NAME)).toBe('service name');
});
it('SERVICE_AGENT_NAME', () => {
expect(get(span, SERVICE_AGENT_NAME)).toBe('agent name');
});
it('SERVICE_LANGUAGE_NAME', () => {
expect(get(span, SERVICE_LANGUAGE_NAME)).toBe('nodejs');
});
// processor
it('PROCESSOR_NAME', () => {
expect(get(span, PROCESSOR_NAME)).toBe('transaction');
});
it('PROCESSOR_EVENT', () => {
expect(get(span, PROCESSOR_EVENT)).toBe('span');
});
// span
it('SPAN_START', () => {
expect(get(span, SPAN_START)).toBe(undefined);
});
it('SPAN_DURATION', () => {
expect(get(span, SPAN_DURATION)).toBe(1337);
});
it('SPAN_TYPE', () => {
expect(get(span, SPAN_TYPE)).toBe('span type');
});
it('SPAN_NAME', () => {
expect(get(span, SPAN_NAME)).toBe('span name');
});
it('SPAN_ID', () => {
expect(get(span, SPAN_ID)).toBe(1337);
});
it('SPAN_SQL', () => {
expect(get(span, SPAN_SQL)).toBe('db statement');
});
it('SPAN_HEX_ID', () => {
expect(get(span, SPAN_HEX_ID)).toBe('hex id');
});
});

View file

@ -7,6 +7,8 @@
export const SERVICE_NAME = 'context.service.name';
export const SERVICE_AGENT_NAME = 'context.service.agent.name';
export const SERVICE_LANGUAGE_NAME = 'context.service.language.name';
export const REQUEST_URL_FULL = 'context.request.url.full';
export const USER_ID = 'context.user.id';
export const PROCESSOR_NAME = 'processor.name';
export const PROCESSOR_EVENT = 'processor.event';
@ -18,19 +20,21 @@ export const TRANSACTION_NAME = 'transaction.name';
export const TRANSACTION_ID = 'transaction.id';
export const TRANSACTION_SAMPLED = 'transaction.sampled';
export const TRACE_ID = 'trace.id';
export const SPAN_START = 'span.start.us';
export const SPAN_DURATION = 'span.duration.us';
export const SPAN_TYPE = 'span.type';
export const SPAN_NAME = 'span.name';
export const SPAN_ID = 'span.id';
export const SPAN_SQL = 'context.db.statement';
export const SPAN_HEX_ID = 'span.hex_id';
// Parent ID for a transaction or span
export const PARENT_ID = 'parent.id';
export const ERROR_GROUP_ID = 'error.grouping_key';
export const ERROR_CULPRIT = 'error.culprit';
export const ERROR_LOG_MESSAGE = 'error.log.message';
export const ERROR_EXC_MESSAGE = 'error.exception.message';
export const ERROR_EXC_HANDLED = 'error.exception.handled';
export const REQUEST_URL_FULL = 'context.request.url.full';
export const USER_ID = 'context.user.id';

View file

@ -9,6 +9,7 @@ import { initTransactionsApi } from './server/routes/transactions';
import { initServicesApi } from './server/routes/services';
import { initErrorsApi } from './server/routes/errors';
import { initStatusApi } from './server/routes/status_check';
import { initTracesApi } from './server/routes/traces';
export function apm(kibana) {
return new kibana.Plugin({
@ -55,6 +56,7 @@ export function apm(kibana) {
init(server) {
initTransactionsApi(server);
initTracesApi(server);
initServicesApi(server);
initErrorsApi(server);
initStatusApi(server);

View file

@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"exclude": ["node_modules", "**/node_modules/*", "build"]
}

View file

@ -31,7 +31,7 @@ import {
EuiLink
} from '@elastic/eui';
import { ELASTIC_DOCS } from '../../../../utils/documentation';
import { XPACK_DOCS } from '../../../../utils/documentation/xpack';
import { KibanaLink } from '../../../../utils/url';
import { createErrorGroupWatch } from './createErrorGroupWatch';
@ -226,10 +226,7 @@ export default class WatcherFlyout extends Component {
This form will assist in creating a Watch that can notify you of error
occurrences from this service. To learn more about Watcher, please
read our{' '}
<EuiLink
target="_blank"
href={_.get(ELASTIC_DOCS, 'watcher-get-started.url')}
>
<EuiLink target="_blank" href={XPACK_DOCS.xpackWatcher}>
documentation
</EuiLink>
.
@ -344,10 +341,7 @@ export default class WatcherFlyout extends Component {
helpText={
<span>
If you have not configured email, please see the{' '}
<EuiLink
target="_blank"
href={_.get(ELASTIC_DOCS, 'x-pack-emails.url')}
>
<EuiLink target="_blank" href={XPACK_DOCS.xpackEmails}>
documentation
</EuiLink>
.

View file

@ -0,0 +1,40 @@
/*
* 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.
*/
// @ts-ignore
import { EuiTabbedContent } from '@elastic/eui';
import React from 'react';
// @ts-ignore
import { KueryBar } from '../../shared/KueryBar';
import { SetupInstructionsLink } from '../../shared/SetupInstructionsLink';
// @ts-ignore
import { HeaderContainer } from '../../shared/UIComponents';
// @ts-ignore
import { ServiceOverview } from '../ServiceOverview';
import { TraceOverview } from '../TraceOverview';
export function Home() {
return (
<div>
<HeaderContainer>
<h1>APM</h1>
<SetupInstructionsLink />
</HeaderContainer>
<KueryBar />
<EuiTabbedContent
className="k6Tab--large"
tabs={[
{
id: 'services_overview',
name: 'Services',
content: <ServiceOverview />
},
{ id: 'traces_overview', name: 'Traces', content: <TraceOverview /> }
]}
/>
</div>
);
}

View file

@ -0,0 +1,15 @@
/*
* 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 { shallow } from 'enzyme';
import React from 'react';
import { Home } from '../Home';
describe('Home component', () => {
it('should render', () => {
expect(shallow(<Home />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Home component should render 1`] = `
<div>
<styled.div>
<h1>
APM
</h1>
<SetupInstructionsLink />
</styled.div>
<Connect(KueryBarView) />
<EuiTabbedContent
className="k6Tab--large"
tabs={
Array [
Object {
"content": <Connect(ServiceOverview) />,
"id": "services_overview",
"name": "Services",
},
Object {
"content": <Connect(TraceOverview) />,
"id": "traces_overview",
"name": "Traces",
},
]
}
/>
</div>
`;

View file

@ -6,25 +6,40 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import ServiceOverview from '../ServiceOverview';
import ErrorGroupDetails from '../ErrorGroupDetails';
import ErrorGroupOverview from '../ErrorGroupOverview';
import TransactionDetails from '../TransactionDetails';
import TransactionOverview from '../TransactionOverview';
import { StringMap } from '../../../../typings/common';
import { legacyDecodeURIComponent } from '../../../utils/url';
// @ts-ignore
import ErrorGroupDetails from '../ErrorGroupDetails';
// @ts-ignore
import ErrorGroupOverview from '../ErrorGroupOverview';
import { TransactionDetails } from '../TransactionDetails';
// @ts-ignore
import TransactionOverview from '../TransactionOverview';
import { Home } from './Home';
interface BreadcrumbArgs {
match: {
params: StringMap<any>;
};
}
interface RenderArgs {
location: StringMap<any>;
}
export const routes = [
{
exact: true,
path: '/',
component: ServiceOverview,
component: Home,
breadcrumb: 'APM'
},
{
exact: true,
path: '/:serviceName/errors/:groupId',
component: ErrorGroupDetails,
breadcrumb: ({ match }) => match.params.groupId
breadcrumb: ({ match }: BreadcrumbArgs) => match.params.groupId
},
{
exact: true,
@ -44,8 +59,8 @@ export const routes = [
{
exact: true,
path: '/:serviceName',
breadcrumb: ({ match }) => match.params.serviceName,
render: ({ location }) => {
breadcrumb: ({ match }: BreadcrumbArgs) => match.params.serviceName,
render: ({ location }: RenderArgs) => {
return (
<Redirect
to={{
@ -68,14 +83,14 @@ export const routes = [
exact: true,
path: '/:serviceName/transactions/:transactionType',
component: TransactionOverview,
breadcrumb: ({ match }) =>
breadcrumb: ({ match }: BreadcrumbArgs) =>
legacyDecodeURIComponent(match.params.transactionType)
},
{
exact: true,
path: '/:serviceName/transactions/:transactionType/:transactionName',
component: TransactionDetails,
breadcrumb: ({ match }) =>
breadcrumb: ({ match }: BreadcrumbArgs) =>
legacyDecodeURIComponent(match.params.transactionName)
}
];

View file

@ -1,143 +0,0 @@
/*
* 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, { Component } from 'react';
import PropTypes from 'prop-types';
import orderBy from 'lodash.orderby';
import styled from 'styled-components';
import numeral from '@elastic/numeral';
import { EuiBasicTable } from '@elastic/eui';
import { RelativeLink } from '../../../../utils/url';
import { fontSizes, truncate } from '../../../../style/variables';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { asMillisWithDefault } from '../../../../utils/formatters';
function formatNumber(value) {
if (value === 0) {
return '0';
}
const formatted = numeral(value).format('0.0');
return formatted <= 0.1 ? '< 0.1' : formatted;
}
// TODO: duplicated
function paginateItems({ items, pageIndex, pageSize }) {
return items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}
function formatString(value) {
return value || 'N/A';
}
const AppLink = styled(RelativeLink)`
font-size: ${fontSizes.large};
${truncate('100%')};
`;
class List extends Component {
state = {
page: {
index: 0,
size: 10
},
sort: {
field: 'serviceName',
direction: 'asc'
}
};
onTableChange = ({ page = {}, sort = {} }) => {
this.setState({ page, sort });
};
render() {
const columns = [
{
field: 'serviceName',
name: 'Name',
width: '50%',
sortable: true,
render: serviceName => (
<TooltipOverlay content={formatString(serviceName)}>
<AppLink path={`/${serviceName}/transactions`}>
{formatString(serviceName)}
</AppLink>
</TooltipOverlay>
)
},
{
field: 'agentName',
name: 'Agent',
sortable: true,
render: agentName => formatString(agentName)
},
{
field: 'avgResponseTime',
name: 'Avg. response time',
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'transactionsPerMinute',
name: 'Trans. per minute',
sortable: true,
dataType: 'number',
render: value => `${formatNumber(value)} tpm`
},
{
field: 'errorsPerMinute',
name: 'Errors per minute',
sortable: true,
dataType: 'number',
render: value => `${formatNumber(value)} err.`
}
];
const sortedItems = orderBy(
this.props.items,
this.state.sort.field,
this.state.sort.direction
);
const paginatedItems = paginateItems({
items: sortedItems,
pageIndex: this.state.page.index,
pageSize: this.state.page.size
});
return (
<EuiBasicTable
noItemsMessage={this.props.noItemsMessage}
items={paginatedItems}
columns={columns}
pagination={{
pageIndex: this.state.page.index,
pageSize: this.state.page.size,
totalItemCount: this.props.items.length
}}
sorting={{
sort: {
field: this.state.sort.field,
direction: this.state.sort.direction
}
}}
onChange={this.onTableChange}
/>
);
}
}
List.propTypes = {
noItemsMessage: PropTypes.node,
items: PropTypes.array
};
List.defaultProps = {
items: []
};
export default List;

View file

@ -8,7 +8,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
import List from '../index';
import { ServiceList } from '../index';
import props from './props.json';
import {
mountWithRouterAndStore,
@ -25,7 +25,7 @@ describe('ErrorGroupOverview -> List', () => {
const storeState = {};
const wrapper = mount(
<MemoryRouter>
<List items={[]} />
<ServiceList items={[]} />
</MemoryRouter>,
storeState
);
@ -36,7 +36,7 @@ describe('ErrorGroupOverview -> List', () => {
it('should render with data', () => {
const storeState = { location: {} };
const wrapper = mountWithRouterAndStore(
<List items={props.items} />,
<ServiceList items={props.items} />,
storeState
);

View file

@ -254,60 +254,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = `
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
className="euiPopover euiPopover--anchorUpRight euiPopover--withTitle"
id="customizablePagination"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="euiPopover__anchor"
>
<button
className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--xSmall euiButtonEmpty--iconRight"
onClick={[Function]}
type="button"
>
<span
className="euiButtonEmpty__content"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonEmpty__icon"
focusable="false"
height="16"
style={
Object {
"fill": undefined,
}
}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M13.069 5.157L8.384 9.768a.546.546 0 0 1-.768 0L2.93 5.158a.552.552 0 0 0-.771 0 .53.53 0 0 0 0 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 0 0 0-.76.552.552 0 0 0-.771 0z"
id="arrow_down-a"
/>
</defs>
<use
fillRule="nonzero"
xlinkHref="#arrow_down-a"
/>
</svg>
<span
className="euiButtonEmpty__text"
>
Rows per page: 10
</span>
</span>
</button>
</div>
</div>
</div>
/>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
@ -690,60 +637,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = `
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
className="euiPopover euiPopover--anchorUpRight euiPopover--withTitle"
id="customizablePagination"
onClick={[Function]}
onKeyDown={[Function]}
>
<div
className="euiPopover__anchor"
>
<button
className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--xSmall euiButtonEmpty--iconRight"
onClick={[Function]}
type="button"
>
<span
className="euiButtonEmpty__content"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiButtonEmpty__icon"
focusable="false"
height="16"
style={
Object {
"fill": undefined,
}
}
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<path
d="M13.069 5.157L8.384 9.768a.546.546 0 0 1-.768 0L2.93 5.158a.552.552 0 0 0-.771 0 .53.53 0 0 0 0 .759l4.684 4.61c.641.631 1.672.63 2.312 0l4.684-4.61a.53.53 0 0 0 0-.76.552.552 0 0 0-.771 0z"
id="arrow_down-a"
/>
</defs>
<use
fillRule="nonzero"
xlinkHref="#arrow_down-a"
/>
</svg>
<span
className="euiButtonEmpty__text"
>
Rows per page: 10
</span>
</span>
</button>
</div>
</div>
</div>
/>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>

View file

@ -0,0 +1,96 @@
/*
* 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 PropTypes from 'prop-types';
import styled from 'styled-components';
import numeral from '@elastic/numeral';
import { RelativeLink } from '../../../../utils/url';
import { fontSizes, truncate } from '../../../../style/variables';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { asMillisWithDefault } from '../../../../utils/formatters';
import { ManagedTable } from '../../../shared/ManagedTable';
// TODO: Consolidate these formatting helpers centrally
function formatNumber(value) {
if (value === 0) {
return '0';
}
const formatted = numeral(value).format('0.0');
return formatted <= 0.1 ? '< 0.1' : formatted;
}
function formatString(value) {
return value || 'N/A';
}
const AppLink = styled(RelativeLink)`
font-size: ${fontSizes.large};
${truncate('100%')};
`;
const SERVICE_COLUMNS = [
{
field: 'serviceName',
name: 'Name',
width: '50%',
sortable: true,
render: serviceName => (
<TooltipOverlay content={formatString(serviceName)}>
<AppLink path={`/${serviceName}/transactions`}>
{formatString(serviceName)}
</AppLink>
</TooltipOverlay>
)
},
{
field: 'agentName',
name: 'Agent',
sortable: true,
render: agentName => formatString(agentName)
},
{
field: 'avgResponseTime',
name: 'Avg. response time',
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'transactionsPerMinute',
name: 'Trans. per minute',
sortable: true,
dataType: 'number',
render: value => `${formatNumber(value)} tpm`
},
{
field: 'errorsPerMinute',
name: 'Errors per minute',
sortable: true,
dataType: 'number',
render: value => `${formatNumber(value)} err.`
}
];
export function ServiceList({ items, noItemsMessage }) {
return (
<ManagedTable
columns={SERVICE_COLUMNS}
items={items}
noItemsMessage={noItemsMessage}
initialSort={{ field: 'serviceName', direction: 'asc' }}
/>
);
}
ServiceList.propTypes = {
noItemsMessage: PropTypes.node,
items: PropTypes.array
};
ServiceList.defaultProps = {
items: []
};

View file

@ -6,7 +6,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import ServiceOverview from '../view';
import { ServiceOverview } from '../view';
import { STATUS } from '../../../../constants';
import * as apmRestServices from '../../../../services/rest/apm';

View file

@ -2,13 +2,9 @@
exports[`Service Overview -> View should render when historical data is found 1`] = `
<div>
<styled.div>
<h1>
Services
</h1>
<SetupInstructionsLink />
</styled.div>
<Connect(KueryBarView) />
<EuiSpacer
size="l"
/>
<ServiceListRequest
render={[Function]}
/>
@ -20,7 +16,6 @@ Object {
"items": Array [],
"noItemsMessage": <EmptyMessage
heading="No services were found"
hideSubheading={false}
subheading={null}
/>,
}
@ -28,13 +23,9 @@ Object {
exports[`Service Overview -> View should render when historical data is not found 1`] = `
<div>
<styled.div>
<h1>
Services
</h1>
<SetupInstructionsLink />
</styled.div>
<Connect(KueryBarView) />
<EuiSpacer
size="l"
/>
<ServiceListRequest
render={[Function]}
/>
@ -46,7 +37,6 @@ Object {
"items": Array [],
"noItemsMessage": <EmptyMessage
heading="Looks like you don't have any services with APM installed. Let's add some!"
hideSubheading={false}
subheading={
<SetupInstructionsLink
buttonFill={true}

View file

@ -5,7 +5,7 @@
*/
import { connect } from 'react-redux';
import ServiceOverview from './view';
import { ServiceOverview as View } from './view';
import { getServiceList } from '../../../store/reactReduxRequest/serviceList';
import { getUrlParams } from '../../../store/urlParams';
@ -17,7 +17,7 @@ function mapStateToProps(state = {}) {
}
const mapDispatchToProps = {};
export default connect(
export const ServiceOverview = connect(
mapStateToProps,
mapDispatchToProps
)(ServiceOverview);
)(View);

View file

@ -8,16 +8,13 @@ import React, { Component } from 'react';
import { STATUS } from '../../../constants';
import { isEmpty } from 'lodash';
import { loadAgentStatus } from '../../../services/rest/apm';
import { KibanaLink } from '../../../utils/url';
import { EuiButton } from '@elastic/eui';
import List from './List';
import { HeaderContainer } from '../../shared/UIComponents';
import { KueryBar } from '../../shared/KueryBar';
import { ServiceList } from './ServiceList';
import { EuiSpacer } from '@elastic/eui';
import { ServiceListRequest } from '../../../store/reactReduxRequest/serviceList';
import EmptyMessage from '../../shared/EmptyMessage';
import { SetupInstructionsLink } from '../../shared/SetupInstructionsLink';
class ServiceOverview extends Component {
export class ServiceOverview extends Component {
state = {
historicalDataFound: true
};
@ -59,32 +56,14 @@ class ServiceOverview extends Component {
return (
<div>
<HeaderContainer>
<h1>Services</h1>
<SetupInstructionsLink />
</HeaderContainer>
<KueryBar />
<EuiSpacer />
<ServiceListRequest
urlParams={urlParams}
render={({ data }) => (
<List items={data} noItemsMessage={noItemsMessage} />
<ServiceList items={data} noItemsMessage={noItemsMessage} />
)}
/>
</div>
);
}
}
function SetupInstructionsLink({ buttonFill = false }) {
return (
<KibanaLink pathname={'/app/kibana'} hash={'/home/tutorial/apm'}>
<EuiButton size="s" color="primary" fill={buttonFill}>
Setup Instructions
</EuiButton>
</KibanaLink>
);
}
export default ServiceOverview;

View file

@ -0,0 +1,90 @@
/*
* 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 styled from 'styled-components';
import { Transaction } from '../../../../typings/Transaction';
import { ITransactionGroup } from '../../../../typings/TransactionGroup';
import { fontSizes, truncate } from '../../../style/variables';
// @ts-ignore
import { asMillisWithDefault } from '../../../utils/formatters';
import { ImpactBar } from '../../shared/ImpactBar';
import { ITableColumn, ManagedTable } from '../../shared/ManagedTable';
// @ts-ignore
import TooltipOverlay from '../../shared/TooltipOverlay';
import { TraceLink } from '../../shared/TraceLink';
function formatString(value: string) {
return value || 'N/A';
}
const StyledTraceLink = styled(TraceLink)`
font-size: ${fontSizes.large};
${truncate('100%')};
`;
interface Props {
items: ITransactionGroup[];
noItemsMessage: any;
}
const traceListColumns: ITableColumn[] = [
{
field: 'sample',
name: 'Name',
width: '40%',
sortable: true,
render: (transaction: Transaction) => (
<TooltipOverlay content={formatString(transaction.transaction.name)}>
<StyledTraceLink transaction={transaction}>
{formatString(transaction.transaction.name)}
</StyledTraceLink>
</TooltipOverlay>
)
},
{
field: 'sample',
name: 'Originating service',
sortable: true,
render: (transaction: Transaction) =>
formatString(transaction.context.service.name)
},
{
field: 'averageResponseTime',
name: 'Avg. response time',
sortable: true,
dataType: 'number',
render: (value: number) => asMillisWithDefault(value)
},
{
field: 'transactionsPerMinute',
name: 'Traces per minute',
sortable: true,
dataType: 'number',
render: (value: number) => `${value.toLocaleString()} tpm`
},
{
field: 'impact',
name: 'Impact',
width: '20%',
align: 'right',
sortable: true,
render: (value: number) => <ImpactBar value={value} />
}
];
export function TraceList({ items = [], noItemsMessage, ...rest }: Props) {
return (
<ManagedTable
columns={traceListColumns}
items={items}
initialSort={{ field: 'impact', direction: 'desc' }}
noItemsMessage={noItemsMessage}
initialPageSize={25}
{...rest}
/>
);
}

View file

@ -5,18 +5,15 @@
*/
import { connect } from 'react-redux';
import TransactionsDetails from './view';
import { IReduxState } from '../../../store/rootReducer';
// @ts-ignore
import { getUrlParams } from '../../../store/urlParams';
import { TraceOverview as View } from './view';
function mapStateToProps(state = {}) {
function mapStateToProps(state = {} as IReduxState) {
return {
location: state.location,
urlParams: getUrlParams(state)
};
}
const mapDispatchToProps = {};
export default connect(
mapStateToProps,
mapDispatchToProps
)(TransactionsDetails);
export const TraceOverview = connect(mapStateToProps)(View);

View file

@ -0,0 +1,38 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { ITransactionGroup } from '../../../../typings/TransactionGroup';
// @ts-ignore
import { TraceListRequest } from '../../../store/reactReduxRequest/traceList';
import EmptyMessage from '../../shared/EmptyMessage';
import { TraceList } from './TraceList';
interface Props {
urlParams: object;
}
export function TraceOverview(props: Props) {
const { urlParams } = props;
return (
<div>
<EuiSpacer />
<TraceListRequest
urlParams={urlParams}
render={({ data }: { data: ITransactionGroup[] }) => (
<TraceList
items={data}
noItemsMessage={
<EmptyMessage heading="No traces found for this query" />
}
/>
)}
/>
</div>
);
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getFormattedBuckets } from '../view';
import { getFormattedBuckets } from '../index';
describe('Distribution', () => {
it('getFormattedBuckets', () => {
@ -12,32 +12,41 @@ describe('Distribution', () => {
{ key: 0, count: 0 },
{ key: 20, count: 0 },
{ key: 40, count: 0 },
{ key: 60, count: 5, transactionId: 'someTransactionId', sampled: true },
{
key: 60,
count: 5,
sample: {
transactionId: 'someTransactionId'
}
},
{
key: 80,
count: 100,
transactionId: 'anotherTransactionId',
sampled: true
sample: {
transactionId: 'anotherTransactionId'
}
}
];
expect(getFormattedBuckets(buckets, 20)).toEqual([
{ x: 20, x0: 0, y: 0, style: {} },
{ x: 40, x0: 20, y: 0, style: {} },
{ x: 60, x0: 40, y: 0, style: {} },
{ x: 20, x0: 0, y: 0, style: { cursor: 'default' } },
{ x: 40, x0: 20, y: 0, style: { cursor: 'default' } },
{ x: 60, x0: 40, y: 0, style: { cursor: 'default' } },
{
x: 80,
x0: 60,
y: 5,
sampled: true,
transactionId: 'someTransactionId',
sample: {
transactionId: 'someTransactionId'
},
style: { cursor: 'pointer' }
},
{
x: 100,
x0: 80,
y: 100,
sampled: true,
transactionId: 'anotherTransactionId',
sample: {
transactionId: 'anotherTransactionId'
},
style: { cursor: 'pointer' }
}
]);

View file

@ -1,22 +0,0 @@
/*
* 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 { connect } from 'react-redux';
import Distribution from './view';
import { getUrlParams } from '../../../../store/urlParams';
function mapStateToProps(state = {}) {
return {
urlParams: getUrlParams(state),
location: state.location
};
}
const mapDispatchToProps = {};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Distribution);

View file

@ -4,44 +4,66 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import d3 from 'd3';
import Histogram from '../../../shared/charts/Histogram';
import { toQuery, fromQuery, history } from '../../../../utils/url';
import { HeaderSmall } from '../../../shared/UIComponents';
import EmptyMessage from '../../../shared/EmptyMessage';
import React, { Component } from 'react';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { IBucket } from 'x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets';
import { IDistributionResponse } from 'x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution';
// @ts-ignore
import { getTimeFormatter, timeUnit } from '../../../../utils/formatters';
// @ts-ignore
import { fromQuery, history, toQuery } from '../../../../utils/url';
// @ts-ignore
import Histogram from '../../../shared/charts/Histogram';
import EmptyMessage from '../../../shared/EmptyMessage';
// @ts-ignore
import { HeaderSmall } from '../../../shared/UIComponents';
// @ts-ignore
import SamplingTooltip from './SamplingTooltip';
export function getFormattedBuckets(buckets, bucketSize) {
interface IChartPoint {
sample?: IBucket['sample'];
x0: string;
x: string;
y: number;
style: {
cursor: string;
};
}
export function getFormattedBuckets(buckets: IBucket[], bucketSize: number) {
if (!buckets) {
return null;
return [];
}
return buckets.map(({ sampled, count, key, transactionId }) => {
return buckets.map(({ sample, count, key }) => {
return {
sampled,
transactionId,
sample,
x0: key,
x: key + bucketSize,
y: count,
style: count > 0 && sampled ? { cursor: 'pointer' } : {}
style: { cursor: count > 0 && sample ? 'pointer' : 'default' }
};
});
}
class Distribution extends Component {
formatYShort = t => {
interface Props {
location: any;
distribution: IDistributionResponse;
urlParams: IUrlParams;
}
export class Distribution extends Component<Props> {
public formatYShort = (t: number) => {
return `${t} ${unitShort(this.props.urlParams.transactionType)}`;
};
formatYLong = t => {
public formatYLong = (t: number) => {
return `${t} ${unitLong(this.props.urlParams.transactionType, t)}`;
};
render() {
const { location, distribution } = this.props;
public render() {
const { location, distribution, urlParams } = this.props;
const buckets = getFormattedBuckets(
distribution.buckets,
@ -58,7 +80,10 @@ class Distribution extends Component {
}
const bucketIndex = buckets.findIndex(
bucket => bucket.transactionId === this.props.urlParams.transactionId
bucket =>
bucket.sample != null &&
bucket.sample.transactionId === urlParams.transactionId &&
bucket.sample.traceId === urlParams.traceId
);
return (
@ -76,13 +101,14 @@ class Distribution extends Component {
buckets={buckets}
bucketSize={distribution.bucketSize}
bucketIndex={bucketIndex}
onClick={bucket => {
if (bucket.sampled && bucket.y > 0) {
onClick={(bucket: IChartPoint) => {
if (bucket.sample && bucket.y > 0) {
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
transactionId: bucket.transactionId
transactionId: bucket.sample.transactionId,
traceId: bucket.sample.traceId
})
});
}
@ -90,16 +116,20 @@ class Distribution extends Component {
formatX={timeFormatter}
formatYShort={this.formatYShort}
formatYLong={this.formatYLong}
verticalLineHover={bucket => bucket.y > 0 && !bucket.sampled}
backgroundHover={bucket => bucket.y > 0 && bucket.sampled}
tooltipHeader={bucket =>
verticalLineHover={(bucket: IChartPoint) =>
bucket.y > 0 && !bucket.sample
}
backgroundHover={(bucket: IChartPoint) =>
bucket.y > 0 && bucket.sample
}
tooltipHeader={(bucket: IChartPoint) =>
`${timeFormatter(bucket.x0, false)} - ${timeFormatter(
bucket.x,
false
)} ${unit}`
}
tooltipFooter={bucket =>
!bucket.sampled && 'No sample available for this bucket'
tooltipFooter={(bucket: IChartPoint) =>
!bucket.sample && 'No sample available for this bucket'
}
/>
</div>
@ -107,20 +137,12 @@ class Distribution extends Component {
}
}
function unitShort(type) {
function unitShort(type: string | undefined) {
return type === 'request' ? 'req.' : 'trans.';
}
function unitLong(type, count) {
function unitLong(type: string | undefined, count: number) {
const suffix = count > 1 ? 's' : '';
return type === 'request' ? `request${suffix}` : `transaction${suffix}`;
}
Distribution.propTypes = {
urlParams: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
distribution: PropTypes.object
};
export default Distribution;

View file

@ -0,0 +1,132 @@
/*
* 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 {
EuiButton,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover
} from '@elastic/eui';
import React from 'react';
import {
PROCESSOR_EVENT,
TRACE_ID,
TRANSACTION_ID
} from 'x-pack/plugins/apm/common/constants';
import { KibanaLink } from 'x-pack/plugins/apm/public/utils/url';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
function getDiscoverQuery(transactionId: string, traceId?: string) {
let query = `${PROCESSOR_EVENT}:transaction AND ${TRANSACTION_ID}:${transactionId}`;
if (traceId) {
query += ` AND ${TRACE_ID}:${traceId}`;
}
return {
_a: {
interval: 'auto',
query: {
language: 'lucene',
query
}
}
};
}
function getInfraMetricsQuery(transaction: Transaction) {
const plus5 = new Date(transaction['@timestamp']);
const minus5 = new Date(transaction['@timestamp']);
plus5.setMinutes(plus5.getMinutes() + 5);
minus5.setMinutes(minus5.getMinutes() - 5);
return {
from: minus5.getTime(),
to: plus5.getTime()
};
}
function ActionMenuButton({ onClick }: { onClick: () => void }) {
return (
<EuiButton iconType="arrowDown" iconSide="right" onClick={onClick}>
Actions
</EuiButton>
);
}
interface ActionMenuProps {
readonly transaction: Transaction;
}
interface ActionMenuState {
readonly isOpen: boolean;
}
export class ActionMenu extends React.Component<
ActionMenuProps,
ActionMenuState
> {
public state = {
isOpen: false
};
public toggle = () => {
this.setState(state => ({ isOpen: !state.isOpen }));
};
public close = () => {
this.setState({ isOpen: false });
};
public render() {
const { transaction } = this.props;
const items = [
<EuiContextMenuItem icon="discoverApp" key="discover-transaction">
<KibanaLink
pathname="/app/kibana"
hash="/discover"
query={getDiscoverQuery(
transaction.transaction.id,
transaction.version === 'v2' ? transaction.trace.id : undefined
)}
>
View sample document
</KibanaLink>
</EuiContextMenuItem>,
<EuiContextMenuItem icon="infraApp" key="infra-host-metrics">
<KibanaLink
pathname="/app/infra"
hash={`/link-to/host-detail/${transaction.context.system.hostname}`}
query={getInfraMetricsQuery(transaction)}
>
<span>View host metrics (beta)</span>
</KibanaLink>
</EuiContextMenuItem>,
<EuiContextMenuItem icon="infraApp" key="infra-host-logs">
<KibanaLink
pathname="/app/infra"
hash={`/link-to/host-logs/${transaction.context.system.hostname}`}
query={{ time: new Date(transaction['@timestamp']).getTime() }}
>
<span>View host logs (beta)</span>
</KibanaLink>
</EuiContextMenuItem>
];
return (
<EuiPopover
id="transactionActionMenu"
button={<ActionMenuButton onClick={this.toggle} />}
isOpen={this.state.isOpen}
closePopover={this.close}
anchorPosition="downRight"
panelPaddingSize="none"
>
<EuiContextMenuPanel items={items} title="Actions" />
</EuiPopover>
);
}
}

View file

@ -1,164 +0,0 @@
/*
* 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 styled from 'styled-components';
import { get } from 'lodash';
import PropTypes from 'prop-types';
import { toQuery, fromQuery, history } from '../../../../../utils/url';
import SpanDetails from './SpanDetails';
import Modal from '../../../../shared/Modal';
import {
unit,
units,
colors,
px,
fontFamilyCode,
fontSizes
} from '../../../../../style/variables';
import {
SPAN_DURATION,
SPAN_START,
SPAN_ID,
SPAN_NAME
} from '../../../../../../common/constants';
const SpanBar = styled.div`
position: relative;
height: ${unit}px;
`;
const SpanLabel = styled.div`
white-space: nowrap;
position: relative;
direction: rtl;
text-align: left;
margin: ${px(units.quarter)} 0 0;
font-family: ${fontFamilyCode};
font-size: ${fontSizes.small};
`;
const Container = styled.div`
position: relative;
display: block;
user-select: none;
padding: ${px(units.half)} ${props => px(props.timelineMargins.right)}
${px(units.eighth)} ${props => px(props.timelineMargins.left)};
border-top: 1px solid ${colors.gray4};
background-color: ${props => (props.isSelected ? colors.gray5 : 'initial')};
cursor: pointer;
&:hover {
background-color: ${colors.gray5};
}
`;
function getLocationPath(location) {
return location.href.split('?')[0];
}
class Span extends React.Component {
componentDidMount() {
this.locationPath = getLocationPath(window.location);
}
onClose = () => {
// Hack: If the modal is open, and the user clicks the back button, the url changes, the modal will be destroyed,
// and the onClose handler will fire, causing it to change the url again. We want to avoid the url changing the second time.
// Therefore we
const currentLocationPath = getLocationPath(window.location);
const didNavigate = this.locationPath !== currentLocationPath;
if (!didNavigate) {
this.resetSpanId();
}
};
resetSpanId = () => {
const { location } = this.props;
const { spanId, ...currentQuery } = toQuery(location.search);
if (spanId === 'null') {
return;
}
history.replace({
...location,
search: fromQuery({
...currentQuery,
spanId: null
})
});
};
render() {
const {
timelineMargins,
totalDuration,
span,
spanTypeLabel,
color,
isSelected,
transactionId,
location
} = this.props;
const width = (get({ span }, SPAN_DURATION) / totalDuration) * 100;
const left = (get({ span }, SPAN_START) / totalDuration) * 100;
const spanId = get({ span }, SPAN_ID);
const spanName = get({ span }, SPAN_NAME);
return (
<Container
onClick={() => {
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
spanId
})
});
}}
timelineMargins={timelineMargins}
isSelected={isSelected}
>
<SpanBar
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: color
}}
/>
<SpanLabel style={{ left: `${left}%`, width: `${100 - left}%` }}>
&lrm;
{spanName}
&lrm;
</SpanLabel>
<Modal
header="Span details"
isOpen={isSelected}
onClose={this.onClose}
close={this.onClose}
>
<SpanDetails
span={span}
spanTypeLabel={spanTypeLabel}
spanTypeColor={color}
totalDuration={totalDuration}
transactionId={transactionId}
/>
</Modal>
</Container>
);
}
}
Span.propTypes = {
location: PropTypes.object.isRequired,
totalDuration: PropTypes.number.isRequired
};
export default Span;

View file

@ -1,205 +0,0 @@
/*
* 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 styled from 'styled-components';
import numeral from '@elastic/numeral';
import { get } from 'lodash';
import PropTypes from 'prop-types';
import Stacktrace from '../../../../../shared/Stacktrace';
import DiscoverButton from '../../../../../shared/DiscoverButton';
import { asMillis } from '../../../../../../utils/formatters';
import { Indicator } from '../../../../../shared/charts/Legend';
import {
SPAN_DURATION,
SPAN_NAME,
SERVICE_LANGUAGE_NAME
} from '../../../../../../../common/constants';
import {
unit,
units,
px,
colors,
borderRadius,
fontFamilyCode,
fontSizes,
truncate
} from '../../../../../../style/variables';
import TooltipOverlay, {
fieldNameHelper
} from '../../../../../shared/TooltipOverlay';
import SyntaxHighlighter, {
registerLanguage
} from 'react-syntax-highlighter/dist/light';
import { xcode } from 'react-syntax-highlighter/dist/styles';
import sql from 'react-syntax-highlighter/dist/languages/sql';
import { HeaderXSmall } from '../../../../../shared/UIComponents';
registerLanguage('sql', sql);
const DetailsWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid ${colors.gray4};
padding: ${px(unit)} 0;
position: relative;
`;
const DetailsElement = styled.div`
min-width: 0;
max-width: 50%;
line-height: 1.5;
`;
const DetailsHeader = styled.div`
font-size: ${fontSizes.small};
color: ${colors.gray3};
span {
cursor: help;
}
`;
const DetailsText = styled.div`
font-size: ${fontSizes.large};
`;
const SpanName = styled.div`
${truncate('100%')};
`;
const LegendIndicator = styled(Indicator)`
display: inline-block;
`;
const StackTraceContainer = styled.div`
margin-top: ${px(unit)};
`;
const DatabaseStatement = styled.div`
margin-top: ${px(unit)};
padding: ${px(units.half)} ${px(unit)};
background: ${colors.yellow};
border-radius: ${borderRadius};
border: 1px solid ${colors.gray4};
font-family: ${fontFamilyCode};
`;
function SpanDetails({ span, spanTypeLabel, spanTypeColor, totalDuration }) {
const spanDocId = get(span, 'docId');
const spanDuration = get({ span }, SPAN_DURATION);
const relativeDuration = spanDuration / totalDuration;
const spanName = get({ span }, SPAN_NAME);
const stackframes = span.stacktrace;
const codeLanguage = get(span, SERVICE_LANGUAGE_NAME);
const dbContext = get(span, 'context.db');
const discoverQuery = {
_a: {
interval: 'auto',
query: {
language: 'lucene',
query: `_id:${spanDocId}`
},
sort: { '@timestamp': 'desc' }
}
};
return (
<div>
<DetailsWrapper>
<DetailsElement>
<DetailsHeader>
<TooltipOverlay content={fieldNameHelper('span.name')}>
<span>Name</span>
</TooltipOverlay>
</DetailsHeader>
<DetailsText>
<TooltipOverlay content={`${spanName || 'N/A'}`}>
<SpanName>{spanName || 'N/A'}</SpanName>
</TooltipOverlay>
</DetailsText>
</DetailsElement>
<DetailsElement>
<DetailsHeader>
<TooltipOverlay content={fieldNameHelper('span.type')}>
<span>Type</span>
</TooltipOverlay>
</DetailsHeader>
<DetailsText>
<LegendIndicator radius={units.minus - 1} color={spanTypeColor} />
{spanTypeLabel}
</DetailsText>
</DetailsElement>
<DetailsElement>
<DetailsHeader>
<TooltipOverlay content={fieldNameHelper('span.duration.us')}>
<span>Duration</span>
</TooltipOverlay>
</DetailsHeader>
<DetailsText>{asMillis(spanDuration)}</DetailsText>
</DetailsElement>
<DetailsElement>
<DetailsHeader>% of total time</DetailsHeader>
<DetailsText>{numeral(relativeDuration).format('0.00%')}</DetailsText>
</DetailsElement>
<DetailsElement>
<DiscoverButton query={discoverQuery}>
{`View span in Discover`}
</DiscoverButton>
</DetailsElement>
</DetailsWrapper>
<DatabaseContext dbContext={dbContext} />
<StackTraceContainer>
<Stacktrace stackframes={stackframes} codeLanguage={codeLanguage} />
</StackTraceContainer>
</div>
);
}
function DatabaseContext({ dbContext }) {
if (!dbContext || !dbContext.statement) {
return null;
}
if (dbContext.type !== 'sql') {
return <DatabaseStatement>{dbContext.statement}</DatabaseStatement>;
}
return (
<div>
<HeaderXSmall>DB Statement</HeaderXSmall>
<DatabaseStatement>
<SyntaxHighlighter
language={'sql'}
style={xcode}
customStyle={{
color: null,
background: null,
padding: null,
lineHeight: px(unit * 1.5),
whiteSpace: 'pre-wrap',
overflowX: 'scroll'
}}
>
{dbContext.statement}
</SyntaxHighlighter>
</DatabaseStatement>
</div>
);
}
SpanDetails.propTypes = {
span: PropTypes.object.isRequired,
totalDuration: PropTypes.number.isRequired
};
export default SpanDetails;

View file

@ -1,58 +0,0 @@
/*
* 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 styled from 'styled-components';
import Legend from '../../../../shared/charts/Legend';
import {
fontSizes,
colors,
unit,
units,
px,
truncate
} from '../../../../../style/variables';
import TooltipOverlay from '../../../../shared/TooltipOverlay';
const TimelineHeaderContainer = styled.div`
display: flex;
justify-content: space-between;
padding: ${px(unit * 1.5)} ${px(units.plus)} 0 ${px(units.plus)};
line-height: 1.5;
`;
const Heading = styled.div`
font-size: ${fontSizes.large};
color: ${colors.gray2};
${truncate('90%')};
`;
const Legends = styled.div`
display: flex;
div {
margin-right: ${px(unit)};
&:last-child {
margin-right: 0;
}
}
`;
export default function TimelineHeader({ legends, transactionName }) {
return (
<TimelineHeaderContainer>
<TooltipOverlay content={transactionName || 'N/A'}>
<Heading>{transactionName || 'N/A'}</Heading>
</TooltipOverlay>
<Legends>
{legends.map(({ color, label }) => (
<Legend key={color} color={color} text={label} />
))}
</Legends>
</TimelineHeaderContainer>
);
}

View file

@ -1,22 +0,0 @@
/*
* 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 { connect } from 'react-redux';
import Spans from './view';
import { getUrlParams } from '../../../../../store/urlParams';
function mapStateToProps(state = {}) {
return {
urlParams: getUrlParams(state),
location: state.location
};
}
const mapDispatchToProps = {};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Spans);

View file

@ -1,193 +0,0 @@
/*
* 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, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { get, uniq, first, zipObject, difference, isEmpty } from 'lodash';
import Span from './Span';
import TimelineHeader from './TimelineHeader';
import { SPAN_ID } from '../../../../../../common/constants';
import { colors } from '../../../../../style/variables';
import { StickyContainer } from 'react-sticky';
import Timeline from '../../../../shared/charts/Timeline';
import EmptyMessage from '../../../../shared/EmptyMessage';
import { getFeatureDocs } from '../../../../../utils/documentation';
import { ExternalLink } from '../../../../../utils/url';
import { SpansRequest } from '../../../../../store/reactReduxRequest/spans';
const Container = styled.div`
transition: 0.1s padding ease;
position: relative;
overflow: hidden;
`;
const DroppedSpansContainer = styled.div`
border-top: 1px solid #ddd;
height: 43px;
line-height: 43px;
text-align: center;
color: ${colors.gray2};
`;
const TIMELINE_HEADER_HEIGHT = 100;
const TIMELINE_MARGINS = {
top: TIMELINE_HEADER_HEIGHT,
left: 50,
right: 50,
bottom: 0
};
class Spans extends PureComponent {
render() {
const {
agentName,
urlParams,
location,
droppedSpans,
agentMarks
} = this.props;
return (
<SpansRequest
urlParams={urlParams}
render={spans => {
if (isEmpty(spans.data.spans)) {
return (
<EmptyMessage
heading="No spans available for this transaction."
hideSubheading
/>
);
}
const spanTypes = uniq(
spans.data.spanTypes.map(({ type }) => getPrimaryType(type))
);
const getSpanColor = getColorByType(spanTypes);
const totalDuration = spans.data.duration;
const spanContainerHeight = 58;
const timelineHeight = spanContainerHeight * spans.data.spans.length;
return (
<div>
<Container>
<StickyContainer>
<Timeline
header={
<TimelineHeader
legends={spanTypes.map(type => ({
label: getSpanLabel(type),
color: getSpanColor(type)
}))}
transactionName={urlParams.transactionName}
/>
}
agentMarks={agentMarks}
duration={totalDuration}
height={timelineHeight}
margins={TIMELINE_MARGINS}
/>
<div
style={{
paddingTop: TIMELINE_MARGINS.top
}}
>
{spans.data.spans.map(span => (
<Span
location={location}
timelineMargins={TIMELINE_MARGINS}
key={get({ span }, SPAN_ID)}
color={getSpanColor(getPrimaryType(span.type))}
span={span}
spanTypeLabel={getSpanLabel(getPrimaryType(span.type))}
totalDuration={totalDuration}
isSelected={get({ span }, SPAN_ID) === urlParams.spanId}
/>
))}
</div>
</StickyContainer>
</Container>
{droppedSpans > 0 && (
<DroppedSpansContainer>
{droppedSpans} spans dropped due to limit of{' '}
{spans.data.spans.length}.{' '}
<DroppedSpansDocsLink agentName={agentName} />
</DroppedSpansContainer>
)}
</div>
);
}}
/>
);
}
}
function DroppedSpansDocsLink({ agentName }) {
const docs = getFeatureDocs('dropped-spans', agentName);
if (!docs || !docs.url) {
return null;
}
return (
<ExternalLink href={docs.url}>
Learn more in the documentation.
</ExternalLink>
);
}
function getColorByType(types) {
const assignedColors = {
app: colors.apmBlue,
cache: colors.apmGreen,
components: colors.apmGreen,
ext: colors.apmPurple,
xhr: colors.apmPurple,
template: colors.apmRed2,
resource: colors.apmRed2,
custom: colors.apmTan,
db: colors.apmOrange,
'hard-navigation': colors.apmYellow
};
const unknownTypes = difference(types, Object.keys(assignedColors));
const unassignedColors = zipObject(unknownTypes, [
colors.apmYellow,
colors.apmRed,
colors.apmBrown,
colors.apmPink
]);
return type => assignedColors[type] || unassignedColors[type];
}
function getSpanLabel(type) {
switch (type) {
case 'db':
return 'DB';
case 'hard-navigation':
return 'Navigation timing';
default:
return type;
}
}
function getPrimaryType(type) {
return first(type.split('.'));
}
Spans.propTypes = {
agentMarks: PropTypes.array,
agentName: PropTypes.string.isRequired,
droppedSpans: PropTypes.number.isRequired,
location: PropTypes.object.isRequired,
urlParams: PropTypes.object.isRequired
};
export default Spans;

View file

@ -1,57 +0,0 @@
/*
* 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 PropTypes from 'prop-types';
import { get } from 'lodash';
import { StickyProperties } from '../../../shared/StickyProperties';
import {
TRANSACTION_DURATION,
TRANSACTION_RESULT,
USER_ID,
REQUEST_URL_FULL
} from '../../../../../common/constants';
import { asTime } from '../../../../utils/formatters';
export default function StickyTransactionProperties({ transaction }) {
const timestamp = get(transaction, '@timestamp');
const url = get(transaction, REQUEST_URL_FULL, 'N/A');
const duration = get(transaction, TRANSACTION_DURATION);
const stickyProperties = [
{
label: 'Timestamp',
fieldName: '@timestamp',
val: timestamp
},
{
fieldName: REQUEST_URL_FULL,
label: 'URL',
val: url,
truncated: true
},
{
label: 'Duration',
fieldName: TRANSACTION_DURATION,
val: duration ? asTime(duration) : 'N/A'
},
{
label: 'Result',
fieldName: TRANSACTION_RESULT,
val: get(transaction, TRANSACTION_RESULT, 'N/A')
},
{
label: 'User ID',
fieldName: USER_ID,
val: get(transaction, USER_ID, 'N/A')
}
];
return <StickyProperties stickyProperties={stickyProperties} />;
}
StickyTransactionProperties.propTypes = {
transaction: PropTypes.object.isRequired
};

View file

@ -0,0 +1,96 @@
/*
* 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 { get } from 'lodash';
import React from 'react';
import { connect } from 'react-redux';
import { selectWaterfallRoot } from 'x-pack/plugins/apm/public/store/selectors/waterfall';
import {
REQUEST_URL_FULL,
TRANSACTION_DURATION,
TRANSACTION_RESULT,
USER_ID
} from '../../../../../common/constants';
import { Transaction } from '../../../../../typings/Transaction';
// @ts-ignore
import { asTime } from '../../../../utils/formatters';
// @ts-ignore
import { StickyProperties } from '../../../shared/StickyProperties';
function getDurationPercent(
transactionDuration: number,
rootDuration?: number
) {
if (rootDuration === undefined || rootDuration === 0) {
return '';
}
return ((transactionDuration / rootDuration) * 100).toFixed(2) + '%';
}
interface Props {
transaction: Transaction;
root?: Transaction;
}
export function StickyTransactionPropertiesComponent({
transaction,
root
}: Props) {
const timestamp = get(transaction, '@timestamp');
const url = get(transaction, REQUEST_URL_FULL, 'N/A');
const duration = transaction.transaction.duration.us;
const rootDuration = root && root.transaction.duration.us;
const stickyProperties = [
{
label: 'Timestamp',
fieldName: '@timestamp',
val: timestamp,
truncated: true,
width: '50%'
},
{
fieldName: REQUEST_URL_FULL,
label: 'URL',
val: url,
truncated: true,
width: '50%'
},
{
label: 'Duration',
fieldName: TRANSACTION_DURATION,
val: duration ? asTime(duration) : 'N/A',
width: '25%'
},
{
label: '% of trace',
val: getDurationPercent(duration, rootDuration),
width: '25%'
},
{
label: 'Result',
fieldName: TRANSACTION_RESULT,
val: get(transaction, TRANSACTION_RESULT, 'N/A'),
width: '25%'
},
{
label: 'User ID',
fieldName: USER_ID,
val: get(transaction, USER_ID, 'N/A'),
truncated: true,
width: '25%'
}
];
return <StickyProperties stickyProperties={stickyProperties} />;
}
const mapStateToProps = (state: any, props: Partial<Props>) => ({
root: selectWaterfallRoot(state, props)
});
export const StickyTransactionProperties = connect<{}, {}, Props>(
mapStateToProps
)(StickyTransactionPropertiesComponent);

View file

@ -0,0 +1,101 @@
/*
* 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 {
EuiSpacer,
// @ts-ignore
EuiTab,
// @ts-ignore
EuiTabs
} from '@elastic/eui';
import { capitalize, first, get } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { Transaction } from '../../../../../typings/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
import { px, units } from '../../../../style/variables';
import { fromQuery, history, toQuery } from '../../../../utils/url';
import {
getPropertyTabNames,
PropertiesTable
} from '../../../shared/PropertiesTable';
import { WaterfallContainer } from './WaterfallContainer';
const TableContainer = styled.div`
padding: ${px(units.plus)} ${px(units.plus)} 0;
`;
// Ensure the selected tab exists or use the first
function getCurrentTab(tabs: string[] = [], selectedTab?: string) {
return selectedTab && tabs.includes(selectedTab) ? selectedTab : first(tabs);
}
const TIMELINE_TAB = 'timeline';
function getTabs(transactionData: Transaction) {
const dynamicProps = Object.keys(transactionData.context || {});
return [TIMELINE_TAB, ...getPropertyTabNames(dynamicProps)];
}
interface TransactionPropertiesTableProps {
location: any;
transaction: Transaction;
urlParams: IUrlParams;
}
export const TransactionPropertiesTable: React.SFC<
TransactionPropertiesTableProps
> = ({ location, transaction, urlParams }) => {
const tabs = getTabs(transaction);
const currentTab = getCurrentTab(tabs, urlParams.detailTab);
const agentName = transaction.context.service.agent.name;
return (
<div>
<EuiTabs>
{tabs.map(key => {
return (
<EuiTab
onClick={() => {
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
detailTab: key
})
});
}}
selected={currentTab === key}
key={key}
>
{capitalize(key)}
</EuiTab>
);
})}
</EuiTabs>
<EuiSpacer />
{currentTab === TIMELINE_TAB && (
<WaterfallContainer
transaction={transaction}
location={location}
urlParams={urlParams}
/>
)}
{currentTab !== TIMELINE_TAB && (
<TableContainer>
<PropertiesTable
propData={get(transaction.context, currentTab)}
propKey={currentTab}
agentName={agentName}
/>
</TableContainer>
)}
</div>
);
};

View file

@ -0,0 +1,78 @@
/*
* 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.
*/
// @ts-ignore
import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import { capitalize, first, get } from 'lodash';
import React from 'react';
import { Transaction } from '../../../../../typings/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
// @ts-ignore
import { fromQuery, history, toQuery } from '../../../../utils/url';
import {
getPropertyTabNames,
PropertiesTable
} from '../../../shared/PropertiesTable';
// @ts-ignore
import { Tab } from '../../../shared/UIComponents';
// Ensure the selected tab exists or use the first
function getCurrentTab(tabs: string[] = [], selectedTab?: string) {
return selectedTab && tabs.includes(selectedTab) ? selectedTab : first(tabs);
}
function getTabs(transactionData: Transaction) {
const dynamicProps = Object.keys(transactionData.context || {});
return getPropertyTabNames(dynamicProps);
}
interface Props {
location: any;
transaction: Transaction;
urlParams: IUrlParams;
}
export const TransactionPropertiesTableForFlyout: React.SFC<Props> = ({
location,
transaction,
urlParams
}) => {
const tabs = getTabs(transaction);
const currentTab = getCurrentTab(tabs, urlParams.flyoutDetailTab);
const agentName = transaction.context.service.agent.name;
return (
<div>
<EuiTabs>
{tabs.map(key => {
return (
<EuiTab
onClick={() => {
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
flyoutDetailTab: key
})
});
}}
isSelected={currentTab === key}
key={key}
>
{capitalize(key)}
</EuiTab>
);
})}
</EuiTabs>
<EuiSpacer />
<PropertiesTable
propData={get(transaction.context, currentTab)}
propKey={currentTab}
agentName={agentName}
/>
</div>
);
};

View file

@ -0,0 +1,38 @@
/*
* 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 styled from 'styled-components';
import { px, unit } from '../../../../../style/variables';
// @ts-ignore
import Legend from '../../../../shared/charts/Legend';
const Legends = styled.div`
display: flex;
div {
margin-right: ${px(unit)};
&:last-child {
margin-right: 0;
}
}
`;
interface Props {
serviceColors: {
[key: string]: string;
};
}
export function ServiceLegends({ serviceColors }: Props) {
return (
<Legends>
{Object.entries(serviceColors).map(([label, color]) => (
<Legend key={color} color={color} text={label} />
))}
</Legends>
);
}

View file

@ -0,0 +1,58 @@
/*
* 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 {
SERVICE_NAME,
TRANSACTION_NAME
} from 'x-pack/plugins/apm/common/constants';
import {
KibanaLink,
legacyEncodeURIComponent
// @ts-ignore
} from 'x-pack/plugins/apm/public/utils/url';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
// @ts-ignore
import { StickyProperties } from '../../../../../shared/StickyProperties';
interface Props {
transaction: Transaction;
}
export function FlyoutTopLevelProperties({ transaction }: Props) {
const stickyProperties = [
{
label: 'Service',
fieldName: SERVICE_NAME,
val: (
<KibanaLink
pathname={'/app/apm'}
hash={`/${transaction.context.service.name}`}
>
{transaction.context.service.name}
</KibanaLink>
),
width: '50%'
},
{
label: 'Transaction',
fieldName: TRANSACTION_NAME,
val: (
<KibanaLink
pathname={'/app/apm'}
hash={`/${transaction.context.service.name}/transactions/${
transaction.transaction.type
}/${legacyEncodeURIComponent(transaction.transaction.name)}`}
>
{transaction.transaction.name}
</KibanaLink>
),
width: '50%'
}
];
return <StickyProperties stickyProperties={stickyProperties} />;
}

View file

@ -0,0 +1,79 @@
/*
* 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, { Fragment } from 'react';
import styled from 'styled-components';
import {
borderRadius,
colors,
fontFamilyCode,
px,
unit,
units
} from '../../../../../../../style/variables';
import SyntaxHighlighter, {
registerLanguage
// @ts-ignore
} from 'react-syntax-highlighter/dist/light';
// @ts-ignore
import { xcode } from 'react-syntax-highlighter/dist/styles';
// @ts-ignore
import sql from 'react-syntax-highlighter/dist/languages/sql';
import { EuiTitle } from '@elastic/eui';
import { DbContext } from '../../../../../../../../typings/Span';
registerLanguage('sql', sql);
const DatabaseStatement = styled.div`
margin-top: ${px(unit)};
padding: ${px(units.half)} ${px(unit)};
background: ${colors.yellow};
border-radius: ${borderRadius};
border: 1px solid ${colors.gray4};
font-family: ${fontFamilyCode};
`;
interface Props {
dbContext?: DbContext;
}
export function DatabaseContext({ dbContext }: Props) {
if (!dbContext || !dbContext.statement) {
return null;
}
if (dbContext.type !== 'sql') {
return <DatabaseStatement>{dbContext.statement}</DatabaseStatement>;
}
return (
<Fragment>
<EuiTitle size="xs">
<h3>Database statement</h3>
</EuiTitle>
<DatabaseStatement>
<SyntaxHighlighter
language={'sql'}
style={xcode}
customStyle={{
color: null,
background: null,
padding: null,
lineHeight: px(unit * 1.5),
whiteSpace: 'pre-wrap',
overflowX: 'scroll'
}}
>
{dbContext.statement}
</SyntaxHighlighter>
</DatabaseStatement>
</Fragment>
);
}

View file

@ -0,0 +1,67 @@
/*
* 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.
*/
// tslint:disable-next-line no-var-requires
const numeral = require('@elastic/numeral');
import React from 'react';
import { first } from 'lodash';
import { Span } from '../../../../../../../../typings/Span';
// @ts-ignore
import { asMillis } from '../../../../../../../utils/formatters';
// @ts-ignore
import { StickyProperties } from '../../../../../../shared/StickyProperties';
function getSpanLabel(type: string) {
switch (type) {
case 'db':
return 'DB';
case 'hard-navigation':
return 'Navigation timing';
default:
return type;
}
}
function getPrimaryType(type: string) {
return first(type.split('.'));
}
interface Props {
span: Span;
totalDuration: number;
}
export function StickySpanProperties({ span, totalDuration }: Props) {
const spanName = span.span.name;
const spanDuration = span.span.duration.us;
const relativeDuration = spanDuration / totalDuration;
const spanTypeLabel = getSpanLabel(getPrimaryType(span.span.type));
const stickyProperties = [
{
label: 'Name',
fieldName: 'span.name',
val: spanName || 'N/A'
},
{
fieldName: 'span.type',
label: 'Type',
val: spanTypeLabel
},
{
fieldName: 'span.duration.us',
label: 'Duration',
val: asMillis(spanDuration)
},
{
label: '% of transaction',
val: numeral(relativeDuration).format('0.00%')
}
];
return <StickyProperties stickyProperties={stickyProperties} />;
}

View file

@ -0,0 +1,104 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiTitle
} from '@elastic/eui';
import { get } from 'lodash';
import React from 'react';
import styled from 'styled-components';
// @ts-ignore
import { SERVICE_LANGUAGE_NAME } from '../../../../../../../../common/constants';
import { px, unit } from '../../../../../../../style/variables';
// @ts-ignore
import Stacktrace from '../../../../../../shared/Stacktrace';
import { DatabaseContext } from './DatabaseContext';
import { StickySpanProperties } from './StickySpanProperties';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
import { Span } from '../../../../../../../../typings/Span';
// @ts-ignore
import DiscoverButton from '../../../../../../shared/DiscoverButton';
import { FlyoutTopLevelProperties } from '../FlyoutTopLevelProperties';
const StackTraceContainer = styled.div`
margin-top: ${px(unit)};
`;
function getDiscoverQuery(span: Span) {
return {
_a: {
interval: 'auto',
query: {
language: 'lucene',
query:
span.version === 'v2'
? `span.hex_id:${span.span.hex_id}`
: `span.id:${span.span.id}`
}
}
};
}
interface Props {
span?: Span;
parentTransaction: Transaction;
totalDuration: number;
onClose: () => void;
}
export function SpanFlyout({
span,
parentTransaction,
totalDuration,
onClose
}: Props) {
if (!span) {
return null;
}
const stackframes = span.span.stacktrace;
const codeLanguage = get(span, SERVICE_LANGUAGE_NAME);
const dbContext = span.context.db;
return (
<EuiFlyout onClose={onClose} size="l">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>Span details</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DiscoverButton query={getDiscoverQuery(span)}>
{`View span in Discover`}
</DiscoverButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<FlyoutTopLevelProperties transaction={parentTransaction} />
<EuiHorizontalRule />
<StickySpanProperties span={span} totalDuration={totalDuration} />
<EuiHorizontalRule />
<DatabaseContext dbContext={dbContext} />
<StackTraceContainer>
<Stacktrace stackframes={stackframes} codeLanguage={codeLanguage} />
</StackTraceContainer>
</EuiFlyoutBody>
</EuiFlyout>
);
}

View file

@ -0,0 +1,82 @@
/*
* 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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiTitle
} from '@elastic/eui';
import React from 'react';
import { TraceLink } from 'x-pack/plugins/apm/public/components/shared/TraceLink';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
import { ActionMenu } from '../../../ActionMenu';
import { StickyTransactionProperties } from '../../../StickyTransactionProperties';
import { TransactionPropertiesTableForFlyout } from '../../../TransactionPropertiesTableForFlyout';
import { FlyoutTopLevelProperties } from '../FlyoutTopLevelProperties';
import { IWaterfall } from '../waterfall_helpers/waterfall_helpers';
interface Props {
onClose: () => void;
transaction?: Transaction;
location: any; // TODO: import location type from react router or history types?
urlParams: IUrlParams;
waterfall: IWaterfall;
}
export function TransactionFlyout({
transaction: transactionDoc,
onClose,
location,
urlParams,
waterfall
}: Props) {
if (!transactionDoc) {
return null;
}
return (
<EuiFlyout onClose={onClose} size="l">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle>
<h4>Transaction details</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ActionMenu transaction={transactionDoc} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TraceLink transaction={transactionDoc}>
<EuiButton iconType="visLine">
View transaction group details
</EuiButton>
</TraceLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<FlyoutTopLevelProperties transaction={transactionDoc} />
<EuiHorizontalRule />
<StickyTransactionProperties transaction={transactionDoc} />
<EuiHorizontalRule />
<TransactionPropertiesTableForFlyout
transaction={transactionDoc}
location={location}
urlParams={urlParams}
/>
</EuiFlyoutBody>
</EuiFlyout>
);
}

View file

@ -0,0 +1,104 @@
/*
* 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 styled from 'styled-components';
import {
colors,
fontFamilyCode,
fontSizes,
px,
unit,
units
} from '../../../../../../style/variables';
import { IWaterfallItem } from './waterfall_helpers/waterfall_helpers';
const ItemBar = styled.div`
position: relative;
height: ${unit}px;
`;
const ItemLabel = styled.div`
white-space: nowrap;
position: relative;
direction: rtl;
text-align: left;
margin: ${px(units.quarter)} 0 0;
font-family: ${fontFamilyCode};
font-size: ${fontSizes.small};
`;
const Container = styled<
{ timelineMargins: TimelineMargins; isSelected: boolean },
'div'
>('div')`
position: relative;
display: block;
user-select: none;
padding: ${px(units.half)} ${props => px(props.timelineMargins.right)}
${px(units.eighth)} ${props => px(props.timelineMargins.left)};
border-top: 1px solid ${colors.gray4};
background-color: ${props => (props.isSelected ? colors.gray5 : 'initial')};
cursor: pointer;
&:hover {
background-color: ${colors.gray5};
}
`;
interface TimelineMargins {
right: number;
left: number;
top: number;
bottom: number;
}
interface Props {
timelineMargins: TimelineMargins;
totalDuration: number;
item: IWaterfallItem;
color: string;
isSelected: boolean;
onClick: () => any;
}
export function WaterfallItem({
timelineMargins,
totalDuration,
item,
color,
isSelected,
onClick
}: Props) {
const width = (item.duration / totalDuration) * 100;
const left = (item.offset / totalDuration) * 100;
return (
<Container
onClick={onClick}
timelineMargins={timelineMargins}
isSelected={isSelected}
>
<ItemBar
style={{
left: `${left}%`,
width: `${width}%`,
minWidth: '2px',
backgroundColor: color
}}
/>
<ItemLabel
style={{
left: `${left}%`,
width: `${100 - left}%`
}}
>
&lrm;
{item.name}
&lrm;
</ItemLabel>
</Container>
);
}

View file

@ -0,0 +1,167 @@
/*
* 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, { Component, Fragment } from 'react';
// @ts-ignore
import { StickyContainer } from 'react-sticky';
import styled from 'styled-components';
import { IUrlParams } from '../../../../../../store/urlParams';
// @ts-ignore
import { fromQuery, history, toQuery } from '../../../../../../utils/url';
// @ts-ignore
import Timeline from '../../../../../shared/charts/Timeline';
import { AgentMark } from '../get_agent_marks';
import { SpanFlyout } from './SpanFlyout';
import { TransactionFlyout } from './TransactionFlyout';
import {
IWaterfall,
IWaterfallItem
} from './waterfall_helpers/waterfall_helpers';
import { WaterfallItem } from './WaterfallItem';
const Container = styled.div`
transition: 0.1s padding ease;
position: relative;
overflow: hidden;
`;
const TIMELINE_MARGINS = {
top: 40,
left: 50,
right: 50,
bottom: 0
};
interface Props {
agentMarks: AgentMark[];
urlParams: IUrlParams;
waterfall: IWaterfall;
location: any;
serviceColors: {
[key: string]: string;
};
}
export class Waterfall extends Component<Props> {
public onOpenFlyout = (item: IWaterfallItem) => {
this.setQueryParams({
flyoutDetailTab: undefined,
waterfallItemId: String(item.id)
});
};
public onCloseFlyout = () => {
this.setQueryParams({
flyoutDetailTab: undefined,
waterfallItemId: undefined
});
};
public renderWaterfall = (item?: IWaterfallItem) => {
if (!item) {
return null;
}
const { serviceColors, waterfall, urlParams }: Props = this.props;
return (
<Fragment key={item.id}>
<WaterfallItem
timelineMargins={TIMELINE_MARGINS}
color={serviceColors[item.serviceName]}
item={item}
totalDuration={waterfall.duration}
isSelected={item.id === urlParams.waterfallItemId}
onClick={() => this.onOpenFlyout(item)}
/>
{item.children && item.children.map(this.renderWaterfall)}
</Fragment>
);
};
public getFlyOut = () => {
const { waterfall, location, urlParams } = this.props;
const currentItem =
urlParams.waterfallItemId &&
waterfall.itemsById[urlParams.waterfallItemId];
if (!currentItem) {
return null;
}
switch (currentItem.docType) {
case 'span':
return (
<SpanFlyout
totalDuration={waterfall.duration}
span={currentItem.span}
parentTransaction={currentItem.parentTransaction}
onClose={this.onCloseFlyout}
/>
);
case 'transaction':
return (
<TransactionFlyout
transaction={currentItem.transaction}
onClose={this.onCloseFlyout}
location={location}
urlParams={urlParams}
waterfall={waterfall}
/>
);
default:
return null;
}
};
public render() {
const { waterfall } = this.props;
const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found
const waterfallHeight = itemContainerHeight * waterfall.childrenCount;
return (
<Container>
<StickyContainer>
<Timeline
agentMarks={this.props.agentMarks}
duration={waterfall.duration}
height={waterfallHeight}
margins={TIMELINE_MARGINS}
/>
<div
style={{
paddingTop: TIMELINE_MARGINS.top
}}
>
{this.renderWaterfall(waterfall.root)}
</div>
</StickyContainer>
{this.getFlyOut()}
</Container>
);
}
private setQueryParams(params: Partial<IUrlParams>) {
const { location } = this.props;
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
...params
})
});
}
}
// TODO: the agent marks and note about dropped spans were removed. Need to be re-added
// agentMarks: PropTypes.array,
// agentName: PropTypes.string.isRequired,
// droppedSpans: PropTypes.number.isRequired,

View file

@ -0,0 +1,274 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getWaterfallRoot 1`] = `
Object {
"itemsById": Object {
"a": Object {
"children": Array [
Object {
"children": Array [],
"docType": "span",
"duration": 4694,
"id": "b2",
"name": "GET [0:0:0:0:0:0:0:1]",
"offset": 1000,
"parentId": "a",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "a",
},
},
"timestamp": 1536763736367000,
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [],
"docType": "span",
"duration": 210,
"id": "d",
"name": "SELECT",
"offset": 5000,
"parentId": "c",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "c",
},
},
"timestamp": 1536763736371000,
},
],
"docType": "transaction",
"duration": 3581,
"id": "c",
"name": "APIRestController#productsRemote",
"offset": 3000,
"parentId": "b",
"serviceName": "opbeans-java",
"timestamp": 1536763736369000,
"transaction": Object {},
},
],
"docType": "span",
"duration": 4694,
"id": "b",
"name": "GET [0:0:0:0:0:0:0:1]",
"offset": 2000,
"parentId": "a",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "a",
},
},
"timestamp": 1536763736368000,
},
],
"docType": "transaction",
"duration": 9480,
"id": "a",
"name": "APIRestController#products",
"offset": 0,
"serviceName": "opbeans-java",
"timestamp": 1536763736366000,
"transaction": Object {},
},
"b": Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [],
"docType": "span",
"duration": 210,
"id": "d",
"name": "SELECT",
"offset": 5000,
"parentId": "c",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "c",
},
},
"timestamp": 1536763736371000,
},
],
"docType": "transaction",
"duration": 3581,
"id": "c",
"name": "APIRestController#productsRemote",
"offset": 3000,
"parentId": "b",
"serviceName": "opbeans-java",
"timestamp": 1536763736369000,
"transaction": Object {},
},
],
"docType": "span",
"duration": 4694,
"id": "b",
"name": "GET [0:0:0:0:0:0:0:1]",
"offset": 2000,
"parentId": "a",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "a",
},
},
"timestamp": 1536763736368000,
},
"b2": Object {
"children": Array [],
"docType": "span",
"duration": 4694,
"id": "b2",
"name": "GET [0:0:0:0:0:0:0:1]",
"offset": 1000,
"parentId": "a",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "a",
},
},
"timestamp": 1536763736367000,
},
"c": Object {
"children": Array [
Object {
"children": Array [],
"docType": "span",
"duration": 210,
"id": "d",
"name": "SELECT",
"offset": 5000,
"parentId": "c",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "c",
},
},
"timestamp": 1536763736371000,
},
],
"docType": "transaction",
"duration": 3581,
"id": "c",
"name": "APIRestController#productsRemote",
"offset": 3000,
"parentId": "b",
"serviceName": "opbeans-java",
"timestamp": 1536763736369000,
"transaction": Object {},
},
"d": Object {
"children": Array [],
"docType": "span",
"duration": 210,
"id": "d",
"name": "SELECT",
"offset": 5000,
"parentId": "c",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "c",
},
},
"timestamp": 1536763736371000,
},
},
"root": Object {
"children": Array [
Object {
"children": Array [],
"docType": "span",
"duration": 4694,
"id": "b2",
"name": "GET [0:0:0:0:0:0:0:1]",
"offset": 1000,
"parentId": "a",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "a",
},
},
"timestamp": 1536763736367000,
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [],
"docType": "span",
"duration": 210,
"id": "d",
"name": "SELECT",
"offset": 5000,
"parentId": "c",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "c",
},
},
"timestamp": 1536763736371000,
},
],
"docType": "transaction",
"duration": 3581,
"id": "c",
"name": "APIRestController#productsRemote",
"offset": 3000,
"parentId": "b",
"serviceName": "opbeans-java",
"timestamp": 1536763736369000,
"transaction": Object {},
},
],
"docType": "span",
"duration": 4694,
"id": "b",
"name": "GET [0:0:0:0:0:0:0:1]",
"offset": 2000,
"parentId": "a",
"parentTransaction": Object {},
"serviceName": "opbeans-java",
"span": Object {
"transaction": Object {
"id": "a",
},
},
"timestamp": 1536763736368000,
},
],
"docType": "transaction",
"duration": 9480,
"id": "a",
"name": "APIRestController#products",
"offset": 0,
"serviceName": "opbeans-java",
"timestamp": 1536763736366000,
"transaction": Object {},
},
}
`;

View file

@ -0,0 +1,47 @@
[
{
"@timestamp": "2018-09-12T15:16:05.351Z",
"processor": {
"name": "transaction",
"event": "span"
},
"span": {
"parent_id": "e070bc3c732087f8",
"trace_id": "7d4d29bea37e48ac8ba1f962d5eb8a41",
"name": "SELECT",
"type": "db.h2.sql",
"start": {
"us": 9249
},
"duration": {
"us": 1380
},
"hex_id": "8143a38f3367fd97"
},
"transaction": {
"id": "e070bc3c732087f8"
},
"context": {
"db": {
"statement": "select order0_.id as col_0_0_, order0_.created_at as col_1_0_, customer1_.full_name as col_2_0_ from orders order0_ left outer join customers customer1_ on order0_.customer_id=customer1_.id",
"type": "sql",
"user": "SA"
},
"service": {
"name": "opbeans-java",
"agent": {
"version": "0.7.0-SNAPSHOT",
"name": "java"
}
}
},
"beat": {
"version": "7.0.0-alpha1",
"name": "361022bff072",
"hostname": "361022bff072"
},
"host": {
"name": "361022bff072"
}
}
]

View file

@ -0,0 +1,93 @@
{
"@timestamp": "2018-09-12T15:16:05.341Z",
"processor": {
"name": "transaction",
"event": "transaction"
},
"transaction": {
"duration": {
"us": 9069543
},
"type": "request",
"result": "HTTP 2xx",
"trace_id": "7d4d29bea37e48ac8ba1f962d5eb8a41",
"sampled": true,
"span_count": {
"started": 1,
"dropped": {
"total": 0
}
},
"id": "e070bc3c732087f8",
"name": "APIRestController#orders"
},
"context": {
"request": {
"url": {
"full": "http://localhost:8080/api/orders",
"hostname": "localhost",
"port": "8080",
"pathname": "/api/orders",
"protocol": "http"
},
"socket": {
"encrypted": false,
"remote_address": "0:0:0:0:0:0:0:1"
},
"http_version": "1.1",
"method": "GET",
"headers": {
"accept-encoding": "gzip, deflate",
"host": "localhost:8080",
"connection": "keep-alive",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/70.0.3508.0 Safari/537.36",
"accept": "*/*",
"referer": "http://localhost:8080/orders"
}
},
"response": {
"finished": true,
"headers_sent": true,
"status_code": 200,
"headers": {
"Transfer-Encoding": "chunked",
"Date": "Wed, 12 Sep 2018 15:16:07 GMT",
"Content-Type": "application/json;charset=UTF-8"
}
},
"system": {
"architecture": "x86_64",
"platform": "Srens-MacBook-Pro.local",
"ip": "172.18.0.1",
"hostname": "Mac OS X"
},
"process": {
"ppid": 10060,
"title": "/Library/Java/JavaVirtualMachines/jdk-10.0.2.jdk/Contents/Home/bin/java",
"pid": 10069
},
"service": {
"language": {
"version": "10.0.2",
"name": "Java"
},
"runtime": {
"name": "Java",
"version": "10.0.2"
},
"name": "opbeans-java",
"agent": {
"name": "java",
"version": "0.7.0-SNAPSHOT"
}
}
},
"beat": {
"version": "7.0.0-alpha1",
"name": "361022bff072",
"hostname": "361022bff072"
},
"host": {
"name": "361022bff072"
}
}

View file

@ -0,0 +1,85 @@
/*
* 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 { Span } from 'x-pack/plugins/apm/typings/Span';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
import { getWaterfallRoot, IWaterfallItem } from './waterfall_helpers';
it('getWaterfallRoot', () => {
const items: IWaterfallItem[] = [
{
id: 'd',
parentId: 'c',
serviceName: 'opbeans-java',
name: 'SELECT',
duration: 210,
timestamp: 1536763736371000,
offset: 0,
docType: 'span',
parentTransaction: {} as Transaction,
span: {
transaction: {
id: 'c'
}
} as Span
},
{
id: 'b',
parentId: 'a',
serviceName: 'opbeans-java',
name: 'GET [0:0:0:0:0:0:0:1]',
duration: 4694,
timestamp: 1536763736368000,
offset: 0,
docType: 'span',
parentTransaction: {} as Transaction,
span: {
transaction: {
id: 'a'
}
} as Span
},
{
id: 'b2',
parentId: 'a',
serviceName: 'opbeans-java',
name: 'GET [0:0:0:0:0:0:0:1]',
duration: 4694,
timestamp: 1536763736367000,
offset: 0,
docType: 'span',
parentTransaction: {} as Transaction,
span: {
transaction: {
id: 'a'
}
} as Span
},
{
id: 'c',
parentId: 'b',
serviceName: 'opbeans-java',
name: 'APIRestController#productsRemote',
duration: 3581,
timestamp: 1536763736369000,
offset: 0,
docType: 'transaction',
transaction: {} as Transaction
},
{
id: 'a',
serviceName: 'opbeans-java',
name: 'APIRestController#products',
duration: 9480,
timestamp: 1536763736366000,
offset: 0,
docType: 'transaction',
transaction: {} as Transaction
}
];
expect(getWaterfallRoot(items, items[4])).toMatchSnapshot();
});

View file

@ -0,0 +1,190 @@
/*
* 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 { groupBy, indexBy, sortBy } from 'lodash';
import { Span } from '../../../../../../../../typings/Span';
import { Transaction } from '../../../../../../../../typings/Transaction';
export interface IWaterfallIndex {
[key: string]: IWaterfallItem;
}
export interface IWaterfall {
duration: number;
services: string[];
childrenCount: number;
root: IWaterfallItem;
itemsById: IWaterfallIndex;
}
interface IWaterfallItemBase {
id: string | number;
parentId?: string;
serviceName: string;
name: string;
duration: number;
timestamp: number;
offset: number;
}
interface IWaterfallItemTransaction extends IWaterfallItemBase {
transaction: Transaction;
docType: 'transaction';
children?: Array<IWaterfallItemSpan | IWaterfallItemTransaction>;
}
interface IWaterfallItemSpan extends IWaterfallItemBase {
parentTransaction: Transaction;
span: Span;
docType: 'span';
children?: Array<IWaterfallItemSpan | IWaterfallItemTransaction>;
}
export type IWaterfallItem = IWaterfallItemSpan | IWaterfallItemTransaction;
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
function getTransactionItem(
transaction: Transaction
): IWaterfallItemTransaction {
if (transaction.version === 'v1') {
return {
id: transaction.transaction.id,
serviceName: transaction.context.service.name,
name: transaction.transaction.name,
duration: transaction.transaction.duration.us,
timestamp: new Date(transaction['@timestamp']).getTime() * 1000,
offset: 0,
docType: 'transaction',
transaction
};
}
return {
id: transaction.transaction.id,
parentId: transaction.parent && transaction.parent.id,
serviceName: transaction.context.service.name,
name: transaction.transaction.name,
duration: transaction.transaction.duration.us,
timestamp: transaction.timestamp.us,
offset: 0,
docType: 'transaction',
transaction
};
}
type PartialSpanItem = Omit<IWaterfallItemSpan, 'parentTransaction'>;
function getSpanItem(span: Span): PartialSpanItem {
if (span.version === 'v1') {
return {
id: span.span.id,
parentId: span.span.parent || span.transaction.id,
serviceName: span.context.service.name,
name: span.span.name,
duration: span.span.duration.us,
timestamp:
new Date(span['@timestamp']).getTime() * 1000 + span.span.start.us,
offset: 0,
docType: 'span',
span
};
}
return {
id: span.span.hex_id,
parentId: span.parent && span.parent.id,
serviceName: span.context.service.name,
name: span.span.name,
duration: span.span.duration.us,
timestamp: span.timestamp.us,
offset: 0,
docType: 'span',
span
};
}
export function getWaterfallRoot(
items: Array<PartialSpanItem | IWaterfallItemTransaction>,
entryTransactionItem: IWaterfallItem
) {
const itemsByParentId = groupBy(
items,
item => (item.parentId ? item.parentId : 'root')
);
const itemsById: IWaterfallIndex = {};
const itemsByTransactionId = indexBy(
items.filter(item => item.docType === 'transaction'),
item => item.id
) as { [key: string]: IWaterfallItemTransaction };
function getWithChildren(
item: PartialSpanItem | IWaterfallItemTransaction
): IWaterfallItem {
const children = itemsByParentId[item.id] || [];
const nextChildren = sortBy(children, 'timestamp').map(getWithChildren);
let fullItem;
// add parent transaction to spans
if (item.docType === 'span') {
fullItem = {
parentTransaction:
itemsByTransactionId[item.span.transaction.id].transaction,
...item,
offset: item.timestamp - entryTransactionItem.timestamp,
children: nextChildren
};
} else {
fullItem = {
...item,
offset: item.timestamp - entryTransactionItem.timestamp,
children: nextChildren
};
}
// TODO: Think about storing this tree as a single, flat, indexed structure
// with "children" being an array of ids, instead of it being a real tree
itemsById[item.id] = fullItem;
return fullItem;
}
return { root: getWithChildren(entryTransactionItem), itemsById };
}
export function getWaterfall(
hits: Array<Span | Transaction>,
services: string[],
entryTransaction: Transaction
): IWaterfall {
const items = hits
.filter(hit => {
const docType = hit.processor.event;
return ['span', 'transaction'].includes(docType);
})
.map(hit => {
const docType = hit.processor.event;
switch (docType) {
case 'span':
return getSpanItem(hit as Span);
case 'transaction':
return getTransactionItem(hit as Transaction);
default:
throw new Error(`Unknown type ${docType}`);
}
});
const entryTransactionItem = getTransactionItem(entryTransaction);
const { root, itemsById } = getWaterfallRoot(items, entryTransactionItem);
return {
duration: root.duration,
services,
childrenCount: hits.length,
root,
itemsById
};
}

View file

@ -0,0 +1,26 @@
/*
* 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 { zipObject } from 'lodash';
import { colors } from '../../../../../style/variables';
interface IServiceColors {
[key: string]: string;
}
export function getServiceColors(services: string[]): IServiceColors {
const assignedColors = [
colors.apmBlue,
colors.apmGreen,
colors.apmPurple,
colors.apmRed2,
colors.apmTan,
colors.apmOrange,
colors.apmYellow
];
return zipObject(services, assignedColors);
}

View file

@ -0,0 +1,36 @@
/*
* 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 { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
import { getAgentMarks } from './get_agent_marks';
describe('getAgentMarks', () => {
it('should sort the marks', () => {
const transaction: Transaction = {
transaction: {
marks: {
agent: {
domInteractive: 117,
timeToFirstByte: 10,
domComplete: 118
}
}
}
} as any;
expect(getAgentMarks(transaction)).toEqual([
{ name: 'timeToFirstByte', us: 10000 },
{ name: 'domInteractive', us: 117000 },
{ name: 'domComplete', us: 118000 }
]);
});
it('should return empty array if marks are missing', () => {
const transaction: Transaction = {
transaction: {}
} as any;
expect(getAgentMarks(transaction)).toEqual([]);
});
});

View file

@ -0,0 +1,27 @@
/*
* 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 { sortBy } from 'lodash';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
export interface AgentMark {
name: string;
us: number;
}
export function getAgentMarks(transaction: Transaction): AgentMark[] {
if (!transaction.transaction.marks) {
return [];
}
return sortBy(
Object.entries(transaction.transaction.marks.agent).map(([name, ms]) => ({
name,
us: ms * 1000
})),
'us'
);
}

View file

@ -0,0 +1,96 @@
/*
* 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';
// @ts-ignore
import {
SERVICE_NAME,
TRACE_ID,
TRANSACTION_ID
} from '../../../../../../common/constants';
import { Transaction } from '../../../../../../typings/Transaction';
import { RRRRender } from 'react-redux-request';
import { WaterfallV1Request } from 'x-pack/plugins/apm/public/store/reactReduxRequest/waterfallV1';
import { WaterfallV2Request } from 'x-pack/plugins/apm/public/store/reactReduxRequest/waterfallV2';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { WaterfallResponse } from 'x-pack/plugins/apm/typings/waterfall';
import { getAgentMarks } from './get_agent_marks';
import { getServiceColors } from './getServiceColors';
import { ServiceLegends } from './ServiceLegends';
import { Waterfall } from './Waterfall';
import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers';
interface Props {
urlParams: IUrlParams;
transaction: Transaction;
location: any;
}
interface WaterfallRequestProps {
urlParams: IUrlParams;
transaction: Transaction;
render: RRRRender<WaterfallResponse>;
}
function WaterfallRequest({
urlParams,
transaction,
render
}: WaterfallRequestProps) {
const hasTrace = transaction.hasOwnProperty('trace');
if (hasTrace) {
return (
<WaterfallV2Request
urlParams={urlParams}
transaction={transaction}
render={render}
/>
);
} else {
return (
<WaterfallV1Request
urlParams={urlParams}
transaction={transaction}
render={render}
/>
);
}
}
export function WaterfallContainer({
location,
urlParams,
transaction
}: Props) {
return (
<WaterfallRequest
urlParams={urlParams}
transaction={transaction}
render={({ data }) => {
const agentMarks = getAgentMarks(transaction);
const waterfall = getWaterfall(data.hits, data.services, transaction);
if (!waterfall) {
return null;
}
const serviceColors = getServiceColors(waterfall.services);
return (
<div>
<ServiceLegends serviceColors={serviceColors} />
<Waterfall
agentMarks={agentMarks}
location={location}
serviceColors={serviceColors}
urlParams={urlParams}
waterfall={waterfall}
/>
</div>
);
}}
/>
);
}

View file

@ -1,64 +0,0 @@
/*
* 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 { getAgentMarks } from '../view';
describe('TransactionDetailsView', () => {
describe('getAgentMarks', () => {
it('should be sorted', () => {
const transaction = {
transaction: {
marks: {
agent: {
domInteractive: 117,
timeToFirstByte: 10,
domComplete: 118
}
}
}
};
expect(getAgentMarks(transaction)).toEqual([
{ name: 'timeToFirstByte', timeLabel: 10000, timeAxis: 10000 },
{ name: 'domInteractive', timeLabel: 117000, timeAxis: 117000 },
{ name: 'domComplete', timeLabel: 118000, timeAxis: 118000 }
]);
});
it('should ensure they are not too close', () => {
const transaction = {
transaction: {
duration: {
us: 1000 * 1000
},
marks: {
agent: {
a: 0,
b: 10,
c: 11,
d: 12,
e: 968,
f: 969,
timeToFirstByte: 970,
domInteractive: 980,
domComplete: 990
}
}
}
};
expect(getAgentMarks(transaction)).toEqual([
{ timeLabel: 0, name: 'a', timeAxis: 0 },
{ timeLabel: 10000, name: 'b', timeAxis: 20000 },
{ timeLabel: 11000, name: 'c', timeAxis: 40000 },
{ timeLabel: 12000, name: 'd', timeAxis: 60000 },
{ timeLabel: 968000, name: 'e', timeAxis: 910000 },
{ timeLabel: 969000, name: 'f', timeAxis: 930000 },
{ timeLabel: 970000, name: 'timeToFirstByte', timeAxis: 950000 },
{ timeLabel: 980000, name: 'domInteractive', timeAxis: 970000 },
{ timeLabel: 990000, name: 'domComplete', timeAxis: 990000 }
]);
});
});
});

View file

@ -1,20 +0,0 @@
/*
* 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 { connect } from 'react-redux';
import Transaction from './view';
function mapStateToProps(state = {}) {
return {
location: state.location
};
}
const mapDispatchToProps = {};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Transaction);

View file

@ -0,0 +1,112 @@
/*
* 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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiTitle,
EuiToolTip
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import React from 'react';
import { Transaction as ITransaction } from '../../../../../typings/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
import EmptyMessage from '../../../shared/EmptyMessage';
import { TraceLink } from '../../../shared/TraceLink';
import { ActionMenu } from './ActionMenu';
import { StickyTransactionProperties } from './StickyTransactionProperties';
// @ts-ignore
import { TransactionPropertiesTable } from './TransactionPropertiesTable';
function MaybeViewTraceLink({
root,
transaction
}: {
root: ITransaction;
transaction: ITransaction;
}) {
const isRoot = transaction.transaction.id === root.transaction.id;
let button;
if (isRoot || !root) {
button = (
<EuiToolTip content="Currently viewing the full trace">
<EuiButton iconType="apmApp" disabled={true}>
View full trace
</EuiButton>
</EuiToolTip>
);
} else {
button = <EuiButton iconType="apmApp">View full trace</EuiButton>;
}
return (
<EuiFlexItem grow={false}>
<TraceLink transaction={root}>{button}</TraceLink>
</EuiFlexItem>
);
}
interface Props {
transaction: ITransaction;
urlParams: IUrlParams;
location: Location;
waterfallRoot?: ITransaction;
}
export const Transaction: React.SFC<Props> = ({
transaction,
urlParams,
location,
waterfallRoot
}) => {
if (isEmpty(transaction)) {
return (
<EmptyMessage
heading="No transaction sample available."
subheading="Try another time range, reset the search filter or select another bucket from the distribution histogram."
/>
);
}
const root = waterfallRoot || transaction;
return (
<EuiPanel paddingSize="m" hasShadow={true}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="s">
<span>Transaction sample</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<ActionMenu transaction={transaction} />
</EuiFlexItem>
<MaybeViewTraceLink transaction={transaction} root={root} />
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<StickyTransactionProperties transaction={transaction} root={root} />
<EuiSpacer />
<TransactionPropertiesTable
transaction={transaction}
location={location}
urlParams={urlParams}
/>
</EuiPanel>
);
};

View file

@ -1,218 +0,0 @@
/*
* 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 PropTypes from 'prop-types';
import styled from 'styled-components';
import {
unit,
units,
colors,
px,
borderRadius
} from '../../../../style/variables';
import { Tab, HeaderMedium } from '../../../shared/UIComponents';
import { isEmpty, capitalize, get, sortBy, last } from 'lodash';
import StickyTransactionProperties from './StickyTransactionProperties';
import {
PropertiesTable,
getPropertyTabNames
} from '../../../shared/PropertiesTable';
import Spans from './Spans';
import DiscoverButton from '../../../shared/DiscoverButton';
import {
TRANSACTION_ID,
PROCESSOR_EVENT,
SERVICE_AGENT_NAME,
TRANSACTION_DURATION
} from '../../../../../common/constants';
import { fromQuery, toQuery, history } from '../../../../utils/url';
import EmptyMessage from '../../../shared/EmptyMessage';
const Container = styled.div`
position: relative;
border: 1px solid ${colors.gray4};
border-radius: ${borderRadius};
margin-top: ${px(units.plus)};
`;
const HeaderContainer = styled.div`
display: flex;
justify-content: space-between;
padding: ${px(units.plus)} ${px(units.plus)} 0;
margin-bottom: ${px(unit)};
`;
const TabContainer = styled.div`
padding: 0 ${px(units.plus)};
border-bottom: 1px solid ${colors.gray4};
`;
const TabContentContainer = styled.div`
border-radius: 0 0 ${borderRadius} ${borderRadius};
`;
const PropertiesTableContainer = styled.div`
padding: ${px(units.plus)} ${px(units.plus)} 0;
`;
const DEFAULT_TAB = 'timeline';
export function getAgentMarks(transaction) {
const duration = get(transaction, TRANSACTION_DURATION);
const threshold = (duration / 100) * 2;
return sortBy(
Object.entries(get(transaction, 'transaction.marks.agent', [])),
'1'
)
.map(([name, ms]) => ({
name,
timeLabel: ms * 1000,
timeAxis: ms * 1000
}))
.reduce((acc, curItem) => {
const prevTime = get(last(acc), 'timeAxis');
const nextValidTime = prevTime + threshold;
const isTooClose = prevTime != null && nextValidTime > curItem.timeAxis;
const canFit = nextValidTime <= duration;
if (isTooClose && canFit) {
acc.push({ ...curItem, timeAxis: nextValidTime });
} else {
acc.push(curItem);
}
return acc;
}, [])
.reduceRight((acc, curItem) => {
const prevTime = get(last(acc), 'timeAxis');
const nextValidTime = prevTime - threshold;
const isTooClose = prevTime != null && nextValidTime < curItem.timeAxis;
const canFit = nextValidTime >= 0;
if (isTooClose && canFit) {
acc.push({ ...curItem, timeAxis: nextValidTime });
} else {
acc.push(curItem);
}
return acc;
}, [])
.reverse();
}
// Ensure the selected tab exists or use the default
function getCurrentTab(tabs = [], detailTab) {
return tabs.includes(detailTab) ? detailTab : DEFAULT_TAB;
}
function getTabs(transactionData) {
const dynamicProps = Object.keys(transactionData.context || {});
return getPropertyTabNames(dynamicProps);
}
function Transaction({ transaction, location, urlParams }) {
const { transactionId } = urlParams;
if (isEmpty(transaction)) {
return (
<EmptyMessage
heading="No transaction sample available."
subheading="Try another time range, reset the search filter or select another bucket from the distribution histogram."
/>
);
}
const agentName = get(transaction, SERVICE_AGENT_NAME);
const tabs = getTabs(transaction);
const currentTab = getCurrentTab(tabs, urlParams.detailTab);
const discoverQuery = {
_a: {
interval: 'auto',
query: {
language: 'lucene',
query: `${PROCESSOR_EVENT}:transaction AND ${TRANSACTION_ID}:${transactionId}`
},
sort: { '@timestamp': 'desc' }
}
};
return (
<Container>
<HeaderContainer>
<HeaderMedium
css={`
margin-top: ${px(units.quarter)};
margin-bottom: 0;
`}
>
Transaction sample
</HeaderMedium>
<DiscoverButton query={discoverQuery}>
{`View transaction in Discover`}
</DiscoverButton>
</HeaderContainer>
<StickyTransactionProperties transaction={transaction} />
<TabContainer>
{[DEFAULT_TAB, ...tabs].map(key => {
return (
<Tab
onClick={() => {
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
detailTab: key
})
});
}}
selected={currentTab === key}
key={key}
>
{capitalize(key)}
</Tab>
);
})}
</TabContainer>
<TabContentContainer>
{currentTab === DEFAULT_TAB ? (
<Spans
agentName={agentName}
agentMarks={getAgentMarks(transaction)}
droppedSpans={get(
transaction,
'transaction.spanCount.dropped.total',
0
)}
/>
) : (
<PropertiesTableContainer>
<PropertiesTable
propData={get(transaction.context, currentTab)}
propKey={currentTab}
agentName={agentName}
/>
</PropertiesTableContainer>
)}
</TabContentContainer>
</Container>
);
}
Transaction.propTypes = {
urlParams: PropTypes.object.isRequired,
transaction: PropTypes.object
};
Transaction.defaultProps = {
transaction: {}
};
export default Transaction;

View file

@ -0,0 +1,34 @@
/*
* 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 { connect } from 'react-redux';
import { TransactionDetailsView } from 'x-pack/plugins/apm/public/components/app/TransactionDetails/view';
import { selectWaterfallRoot } from 'x-pack/plugins/apm/public/store/selectors/waterfall';
import {
getUrlParams,
IUrlParams
} from 'x-pack/plugins/apm/public/store/urlParams';
import { Transaction } from '../../../../typings/Transaction';
interface Props {
location: any;
urlParams: IUrlParams;
waterfallRoot: Transaction;
}
function mapStateToProps(state: any = {}, props: Partial<Props>) {
return {
location: state.location,
urlParams: getUrlParams(state),
waterfallRoot: selectWaterfallRoot(state, props)
};
}
const mapDispatchToProps = {};
export const TransactionDetails = connect<{}, {}, Props>(
mapStateToProps,
mapDispatchToProps
)(TransactionDetailsView);

View file

@ -1,51 +0,0 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import { HeaderLarge } from '../../shared/UIComponents';
import Transaction from './Transaction';
import Distribution from './Distribution';
import { TransactionDetailsChartsRequest } from '../../../store/reactReduxRequest/transactionDetailsCharts';
import Charts from '../../shared/charts/TransactionCharts';
import { TransactionDistributionRequest } from '../../../store/reactReduxRequest/transactionDistribution';
import { TransactionDetailsRequest } from '../../../store/reactReduxRequest/transactionDetails';
import { KueryBar } from '../../shared/KueryBar';
function TransactionDetails({ urlParams, location }) {
return (
<div>
<HeaderLarge>{urlParams.transactionName}</HeaderLarge>
<KueryBar />
<EuiSpacer size="s" />
<TransactionDetailsChartsRequest
urlParams={urlParams}
render={({ data }) => (
<Charts charts={data} urlParams={urlParams} location={location} />
)}
/>
<TransactionDistributionRequest
urlParams={urlParams}
render={({ data }) => (
<Distribution distribution={data} urlParams={urlParams} />
)}
/>
<TransactionDetailsRequest
urlParams={urlParams}
render={({ data }) => (
<Transaction transaction={data} urlParams={urlParams} />
)}
/>
</div>
);
}
export default TransactionDetails;

View file

@ -0,0 +1,84 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { RRRRenderArgs } from 'react-redux-request';
import { Transaction as ITransaction } from '../../../../typings/Transaction';
// @ts-ignore
import { TransactionDetailsRequest } from '../../../store/reactReduxRequest/transactionDetails';
// @ts-ignore
import { TransactionDetailsChartsRequest } from '../../../store/reactReduxRequest/transactionDetailsCharts';
import { TransactionDistributionRequest } from '../../../store/reactReduxRequest/transactionDistribution';
import { IUrlParams } from '../../../store/urlParams';
// @ts-ignore
import TransactionCharts from '../../shared/charts/TransactionCharts';
// @ts-ignore
import { KueryBar } from '../../shared/KueryBar';
// @ts-ignore
import { HeaderLarge } from '../../shared/UIComponents';
import { Distribution } from './Distribution';
import { Transaction } from './Transaction';
interface Props {
urlParams: IUrlParams;
location: any;
waterfallRoot: ITransaction;
}
export function TransactionDetailsView({
urlParams,
location,
waterfallRoot
}: Props) {
return (
<div>
<HeaderLarge>{urlParams.transactionName}</HeaderLarge>
<KueryBar />
<EuiSpacer size="s" />
<TransactionDetailsChartsRequest
urlParams={urlParams}
render={({ data }: RRRRenderArgs<any>) => (
<TransactionCharts
charts={data}
urlParams={urlParams}
location={location}
/>
)}
/>
<TransactionDistributionRequest
urlParams={urlParams}
render={({ data }: RRRRenderArgs<any>) => (
<Distribution
distribution={data}
urlParams={urlParams}
location={location}
/>
)}
/>
<EuiSpacer size="l" />
<TransactionDetailsRequest
urlParams={urlParams}
render={(res: RRRRenderArgs<any>) => {
return (
<Transaction
location={location}
transaction={res.data}
urlParams={urlParams}
waterfallRoot={waterfallRoot}
/>
);
}}
/>
</div>
);
}

View file

@ -4,11 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import React from 'react';
import styled from 'styled-components';
import { EuiBasicTable } from '@elastic/eui';
import orderBy from 'lodash.orderby';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { RelativeLink, legacyEncodeURIComponent } from '../../../../utils/url';
import {
@ -16,9 +13,10 @@ import {
asDecimal,
tpmUnit
} from '../../../../utils/formatters';
import { ImpactBar } from '../../../shared/ImpactBar';
import { fontFamilyCode, truncate } from '../../../../style/variables';
import ImpactSparkline from './ImpactSparkLine';
import { ManagedTable } from '../../../shared/ManagedTable';
function tpmLabel(type) {
return type === 'request' ? 'Req. per minute' : 'Trans. per minute';
@ -28,129 +26,75 @@ function avgLabel(agentName) {
return agentName === 'js-base' ? 'Page load time' : 'Avg. resp. time';
}
function paginateItems({ items, pageIndex, pageSize }) {
return items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}
const TransactionNameLink = styled(RelativeLink)`
${truncate('100%')};
font-family: ${fontFamilyCode};
`;
class List extends Component {
state = {
page: {
index: 0,
size: 25
},
sort: {
field: 'impactRelative',
direction: 'desc'
}
};
export default function TransactionList({
items,
agentName,
serviceName,
type,
...rest
}) {
const columns = [
{
field: 'name',
name: 'Name',
width: '50%',
sortable: true,
render: transactionName => {
const transactionUrl = `${serviceName}/transactions/${legacyEncodeURIComponent(
type
)}/${legacyEncodeURIComponent(transactionName)}`;
onTableChange = ({ page = {}, sort = {} }) => {
this.setState({ page, sort });
};
render() {
const { agentName, serviceName, type } = this.props;
const columns = [
{
field: 'name',
name: 'Name',
width: '50%',
sortable: true,
render: transactionName => {
const transactionUrl = `${serviceName}/transactions/${legacyEncodeURIComponent(
type
)}/${legacyEncodeURIComponent(transactionName)}`;
return (
<TooltipOverlay content={transactionName || 'N/A'}>
<TransactionNameLink path={`/${transactionUrl}`}>
{transactionName || 'N/A'}
</TransactionNameLink>
</TooltipOverlay>
);
}
},
{
field: 'avg',
name: avgLabel(agentName),
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'p95',
name: '95th percentile',
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'tpm',
name: tpmLabel(type),
sortable: true,
dataType: 'number',
render: value => `${asDecimal(value)} ${tpmUnit(type)}`
},
{
field: 'impactRelative',
name: 'Impact',
sortable: true,
dataType: 'number',
render: value => <ImpactSparkline impact={value} />
return (
<TooltipOverlay content={transactionName || 'N/A'}>
<TransactionNameLink path={`/${transactionUrl}`}>
{transactionName || 'N/A'}
</TransactionNameLink>
</TooltipOverlay>
);
}
];
},
{
field: 'averageResponseTime',
name: avgLabel(agentName),
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'p95',
name: '95th percentile',
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'transactionsPerMinute',
name: tpmLabel(type),
sortable: true,
dataType: 'number',
render: value => `${asDecimal(value)} ${tpmUnit(type)}`
},
{
field: 'impact',
name: 'Impact',
sortable: true,
dataType: 'number',
render: value => <ImpactBar value={value} />
}
];
const sortedItems = orderBy(
this.props.items,
this.state.sort.field,
this.state.sort.direction
);
const paginatedItems = paginateItems({
items: sortedItems,
pageIndex: this.state.page.index,
pageSize: this.state.page.size
});
return (
<EuiBasicTable
noItemsMessage="No transactions found"
items={paginatedItems}
columns={columns}
pagination={{
pageIndex: this.state.page.index,
pageSize: this.state.page.size,
totalItemCount: this.props.items.length
}}
sorting={{
sort: {
field: this.state.sort.field,
direction: this.state.sort.direction
}
}}
onChange={this.onTableChange}
/>
);
}
return (
<ManagedTable
columns={columns}
items={items}
initialSort={{ field: 'impact', direction: 'desc' }}
initialPageSize={25}
{...rest}
/>
);
}
List.propTypes = {
agentName: PropTypes.string,
items: PropTypes.array,
serviceName: PropTypes.string,
type: PropTypes.string
};
export default List;
// const renderFooterText = () => {
// return items.length === 500
// ? 'Showing first 500 results ordered by response time'
// : '';
// };

View file

@ -8,9 +8,14 @@ import React from 'react';
import { KibanaLink } from '../../utils/url';
import { EuiButton } from '@elastic/eui';
function DiscoverButton({ query, children }) {
function DiscoverButton({ query, children, ...rest }) {
return (
<KibanaLink pathname={'/app/kibana'} hash={'/discover'} query={query}>
<KibanaLink
pathname={'/app/kibana'}
hash={'/discover'}
query={query}
{...rest}
>
<EuiButton iconType="discoverApp">
{children || 'View in Discover'}
</EuiButton>

View file

@ -4,15 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { EuiEmptyPrompt } from '@elastic/eui';
import React from 'react';
function EmptyMessage({ heading, subheading, hideSubheading }) {
if (!subheading) {
subheading = 'Try another time range or reset the search filter.';
}
function EmptyMessage({
heading = 'No data found.',
subheading = 'Try another time range or reset the search filter.',
hideSubheading = false
}) {
return (
<EuiEmptyPrompt
titleSize="s"
@ -22,13 +21,5 @@ function EmptyMessage({ heading, subheading, hideSubheading }) {
);
}
EmptyMessage.propTypes = {
heading: PropTypes.string,
hideSubheading: PropTypes.bool
};
EmptyMessage.defaultProps = {
hideSubheading: false
};
// tslint:disable-next-line:no-default-export
export default EmptyMessage;

View file

@ -0,0 +1,21 @@
/*
* 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 { shallow } from 'enzyme';
import React from 'react';
import { ImpactBar } from '..';
describe('ImpactBar component', () => {
it('should render with default values', () => {
expect(shallow(<ImpactBar value={25} />)).toMatchSnapshot();
});
it('should render with overridden values', () => {
expect(
shallow(<ImpactBar value={2} max={5} color="danger" size="s" />)
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImpactBar component should render with default values 1`] = `
<EuiProgress
color="primary"
max={100}
position="static"
size="l"
value={25}
/>
`;
exports[`ImpactBar component should render with overridden values 1`] = `
<EuiProgress
color="danger"
max={5}
position="static"
size="s"
value={2}
/>
`;

View file

@ -0,0 +1,21 @@
/*
* 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 { EuiProgress } from '@elastic/eui';
import React from 'react';
import { StringMap } from '../../../../typings/common';
// TODO: extend from EUI's EuiProgress prop interface
export interface ImpactBarProps extends StringMap<any> {
value: number;
max?: number;
}
export function ImpactBar({ value, max = 100, ...rest }: ImpactBarProps) {
return (
<EuiProgress size="l" value={value} max={max} color="primary" {...rest} />
);
}

View file

@ -5,8 +5,8 @@
*/
import { connect } from 'react-redux';
import view from './view';
import { getUrlParams } from '../../../store/urlParams';
import view from './view';
function mapStateToProps(state = {}) {
return {

View file

@ -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 { shallow } from 'enzyme';
import React from 'react';
import { ManagedTable } from '..';
describe('ManagedTable component', () => {
let people;
let columns;
beforeEach(() => {
people = [
{ name: 'Jess', age: 29 },
{ name: 'Becky', age: 43 },
{ name: 'Thomas', age: 31 }
];
columns = [
{
field: 'name',
name: 'Name',
sortable: true,
render: name => `Name: ${name}`
},
{ field: 'age', name: 'Age', render: age => `Age: ${age}` }
];
});
it('should render a page-full of items, with defaults', () => {
expect(
shallow(<ManagedTable columns={columns} items={people} />)
).toMatchSnapshot();
});
it('should render when specifying initial values', () => {
expect(
shallow(
<ManagedTable
columns={columns}
items={people}
initialSort={{ field: 'age', direction: 'desc' }}
initialPageIndex={1}
initialPageSize={2}
hidePerPageOptions={false}
/>
)
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ManagedTable component should render a page-full of items, with defaults 1`] = `
<EuiBasicTable
columns={
Array [
Object {
"field": "name",
"name": "Name",
"render": [Function],
"sortable": true,
},
Object {
"field": "age",
"name": "Age",
"render": [Function],
},
]
}
items={
Array [
Object {
"age": 43,
"name": "Becky",
},
Object {
"age": 29,
"name": "Jess",
},
Object {
"age": 31,
"name": "Thomas",
},
]
}
noItemsMessage="No items found"
onChange={[Function]}
pagination={
Object {
"hidePerPageOptions": true,
"pageIndex": 0,
"pageSize": 10,
"totalItemCount": 3,
}
}
responsive={true}
sorting={
Object {
"sort": Object {
"direction": "asc",
"field": "name",
},
}
}
/>
`;
exports[`ManagedTable component should render when specifying initial values 1`] = `
<EuiBasicTable
columns={
Array [
Object {
"field": "name",
"name": "Name",
"render": [Function],
"sortable": true,
},
Object {
"field": "age",
"name": "Age",
"render": [Function],
},
]
}
items={
Array [
Object {
"age": 29,
"name": "Jess",
},
]
}
noItemsMessage="No items found"
onChange={[Function]}
pagination={
Object {
"hidePerPageOptions": false,
"pageIndex": 1,
"pageSize": 2,
"totalItemCount": 3,
}
}
responsive={true}
sorting={
Object {
"sort": Object {
"direction": "desc",
"field": "age",
},
}
}
/>
`;

View file

@ -0,0 +1,95 @@
/*
* 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.
*/
// @ts-ignore
import { EuiBasicTable } from '@elastic/eui';
import { get, sortByOrder } from 'lodash';
import React, { Component } from 'react';
import { StringMap } from '../../../../typings/common';
// TODO: this should really be imported from EUI
export interface ITableColumn {
field: string;
name: string;
dataType?: string;
align?: string;
width?: string;
sortable?: boolean;
render: (value: any, item?: any) => any;
}
export interface IManagedTableProps {
items: Array<StringMap<any>>;
columns: ITableColumn[];
initialPageIndex?: number;
initialPageSize?: number;
hidePerPageOptions?: boolean;
initialSort?: {
field: string;
direction: 'asc' | 'desc';
};
noItemsMessage?: any;
}
export class ManagedTable extends Component<IManagedTableProps, any> {
constructor(props: IManagedTableProps) {
super(props);
const defaultSort = {
field: get(props, 'columns[0].field', ''),
direction: 'asc'
};
const {
initialPageIndex = 0,
initialPageSize = 10,
initialSort = defaultSort
} = props;
this.state = {
page: { index: initialPageIndex, size: initialPageSize },
sort: initialSort
};
}
public onTableChange = ({ page = {}, sort = {} }) => {
this.setState({ page, sort });
};
public getCurrentItems() {
const { items } = this.props;
const { sort = {}, page = {} } = this.state;
// TODO: Use _.orderBy once we upgrade to lodash 4+
const sorted = sortByOrder(items, sort.field, sort.direction);
return sorted.slice(page.index * page.size, (page.index + 1) * page.size);
}
public render() {
const {
columns,
noItemsMessage,
items,
hidePerPageOptions = true
} = this.props;
const { page, sort } = this.state;
return (
<EuiBasicTable
noItemsMessage={noItemsMessage}
items={this.getCurrentItems()}
columns={columns}
pagination={{
hidePerPageOptions,
totalItemCount: items.length,
pageIndex: page.index,
pageSize: page.size
}}
sorting={{ sort }}
onChange={this.onTableChange}
/>
);
}
}

View file

@ -1,127 +0,0 @@
/*
* 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 Portal from 'react-portal';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Close } from './Icons';
import { fontSizes, units, colors } from '../../style/variables';
import { rgba } from 'polished';
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const HeaderTitle = styled.div`
font-size: ${fontSizes.xlarge};
`;
const CloseButton = styled(Close)`
cursor: pointer;
font-size: ${fontSizes.large};
`;
const ModalFixed = styled.div`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;
const ModalOverlay = styled(ModalFixed)`
z-index: 10;
background: ${rgba(colors.gray2, 0.8)};
height: 100%;
`;
const ModalOuterContainer = styled(ModalFixed)`
z-index: 20;
overflow-x: hidden;
overflow-y: auto;
`;
const ModalInnerContainer = styled.div`
position: relative;
background: white;
min-width: 800px;
width: 80%;
left: 50%;
transform: translateX(-50%);
padding: ${units.double}px;
border-radius: ${units.quarter}px;
margin: ${units.quadruple}px 0;
`;
class Modal extends React.Component {
shouldComponentUpdate(nextProps) {
// TODO: Make sure this doesn't cause rendering issues
return this.props.isOpen !== nextProps.isOpen;
}
componentWillUnmount() {
document.body.style.overflow = '';
}
onOpen = () => {
document.body.style.overflow = 'hidden';
this.props.onOpen();
};
onClose = () => {
document.body.style.overflow = '';
this.props.onClose();
};
close = () => this.props.close();
render() {
if (!this.props.isOpen) {
return null;
}
return (
<Portal
onClose={this.onClose}
onOpen={this.onOpen}
closeOnEsc
isOpened={this.props.isOpen}
>
<div>
<ModalOverlay />
<ModalOuterContainer onClick={this.close}>
<ModalInnerContainer onClick={e => e.stopPropagation()}>
<Header>
<HeaderTitle>{this.props.header}</HeaderTitle>
<CloseButton onClick={this.close} />
</Header>
{this.props.children}
</ModalInnerContainer>
</ModalOuterContainer>
</div>
</Portal>
);
}
}
Modal.propTypes = {
children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
onOpen: PropTypes.func,
onClose: PropTypes.func,
close: PropTypes.func,
isOpen: PropTypes.bool
};
Modal.defaultProps = {
onOpen: () => {},
onClose: () => {},
close: () => {}
};
export default Modal;

View file

@ -7,6 +7,8 @@
import _ from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { StringMap } from '../../../../typings/common';
import {
colors,
fontFamilyCode,
@ -15,6 +17,8 @@ import {
units
} from '../../../style/variables';
export type KeySorter = (data: StringMap<any>, parentKey?: string) => string[];
const Table = styled.table`
font-family: ${fontFamilyCode};
font-size: ${fontSizes.small};

View file

@ -12,9 +12,9 @@ import {
sortKeysByConfig,
getPropertyTabNames
} from '..';
import { getFeatureDocs } from '../../../../utils/documentation';
import { getAgentFeatureDocsUrl } from '../../../../utils/documentation/agents';
jest.mock('../../../../utils/documentation');
jest.mock('../../../../utils/documentation/agents');
jest.mock('../propertyConfig.json', () => [
{
key: 'testProperty',
@ -105,32 +105,13 @@ describe('getPropertyTabNames', () => {
});
describe('AgentFeatureTipMessage component', () => {
let mockDocs;
const featureName = '';
const agentName = '';
beforeEach(() => {
mockDocs = {
text: 'Mock Docs Text',
url: 'mock-url'
};
getFeatureDocs.mockImplementation(() => mockDocs);
});
const featureName = 'user';
const agentName = 'nodejs';
it('should render when docs are returned', () => {
expect(
shallow(
<AgentFeatureTipMessage
featureName={featureName}
agentName={agentName}
/>
)
).toMatchSnapshot();
expect(getFeatureDocs).toHaveBeenCalledWith(featureName, agentName);
});
const mockDocs = 'mock-url';
getAgentFeatureDocsUrl.mockImplementation(() => mockDocs);
it('should render when docs are returned, but missing a url', () => {
delete mockDocs.url;
expect(
shallow(
<AgentFeatureTipMessage
@ -139,10 +120,11 @@ describe('AgentFeatureTipMessage component', () => {
/>
)
).toMatchSnapshot();
expect(getAgentFeatureDocsUrl).toHaveBeenCalledWith(featureName, agentName);
});
it('should render null empty string when no docs are returned', () => {
mockDocs = null;
getAgentFeatureDocsUrl.mockImplementation(() => null);
expect(
shallow(
<AgentFeatureTipMessage

View file

@ -8,7 +8,7 @@ exports[`AgentFeatureTipMessage component should render when docs are returned 1
size="m"
type="iInCircle"
/>
Mock Docs Text
You can configure your agent to add contextual information about your users.
<ExternalLink
href="mock-url"
@ -18,17 +18,6 @@ exports[`AgentFeatureTipMessage component should render when docs are returned 1
</styled.div>
`;
exports[`AgentFeatureTipMessage component should render when docs are returned, but missing a url 1`] = `
<styled.div>
<EuiIcon
size="m"
type="iInCircle"
/>
Mock Docs Text
</styled.div>
`;
exports[`PropertiesTable component should render empty when data has no keys 1`] = `
<styled.div>
<styled.div>
@ -68,7 +57,7 @@ exports[`PropertiesTable component should render with data 1`] = `
/>
<AgentFeatureTipMessage
agentName="testAgentName"
featureName="context-testPropKey"
featureName="testPropKey"
/>
</styled.div>
`;

View file

@ -8,11 +8,13 @@ import { EuiIcon } from '@elastic/eui';
import _ from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { StringMap } from '../../../../typings/common';
import { colors, fontSize, px, unit, units } from '../../../style/variables';
import { getFeatureDocs } from '../../../utils/documentation';
import { getAgentFeatureDocsUrl } from '../../../utils/documentation/agents';
// @ts-ignore
import { ExternalLink } from '../../../utils/url';
import { NestedKeyValueTable } from './NestedKeyValueTable';
import { KeySorter, NestedKeyValueTable } from './NestedKeyValueTable';
import PROPERTY_CONFIG from './propertyConfig.json';
const indexedPropertyConfig = _.indexBy(PROPERTY_CONFIG, 'key');
@ -36,28 +38,36 @@ export function getPropertyTabNames(selected: string[]): string[] {
).map(({ key }: { key: string }) => key);
}
function getAgentFeatureText(featureName: string) {
switch (featureName) {
case 'user':
return 'You can configure your agent to add contextual information about your users.';
case 'tags':
return 'You can configure your agent to add filterable tags on transactions.';
case 'custom':
return 'You can configure your agent to add custom contextual information on transactions.';
}
}
export function AgentFeatureTipMessage({
featureName,
agentName
}: {
featureName: string;
agentName: string;
}): JSX.Element | null {
const docs = getFeatureDocs(featureName, agentName);
if (!docs) {
agentName?: string;
}) {
const docsUrl = getAgentFeatureDocsUrl(featureName, agentName);
if (!docsUrl) {
return null;
}
return (
<TableInfo>
<EuiIcon type="iInCircle" />
{docs.text}{' '}
{docs.url && (
<ExternalLink href={docs.url}>
Learn more in the documentation.
</ExternalLink>
)}
{getAgentFeatureText(featureName)}{' '}
<ExternalLink href={docsUrl}>
Learn more in the documentation.
</ExternalLink>
</TableInfo>
);
}
@ -78,7 +88,7 @@ export function PropertiesTable({
}: {
propData: StringMap<any>;
propKey: string;
agentName: string;
agentName?: string;
}) {
if (_.isEmpty(propData)) {
return (
@ -98,10 +108,7 @@ export function PropertiesTable({
keySorter={sortKeysByConfig}
depth={1}
/>
<AgentFeatureTipMessage
featureName={`context-${propKey}`}
agentName={agentName}
/>
<AgentFeatureTipMessage featureName={propKey} agentName={agentName} />
</TableContainer>
);
}

View file

@ -0,0 +1,24 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import React from 'react';
// @ts-ignore
import { KibanaLink } from '../../utils/url';
export function SetupInstructionsLink({
buttonFill = false
}: {
buttonFill?: boolean;
}) {
return (
<KibanaLink pathname={'/app/kibana'} hash={'/home/tutorial/apm'}>
<EuiButton size="s" color="primary" fill={buttonFill}>
Setup Instructions
</EuiButton>
</KibanaLink>
);
}

View file

@ -11,8 +11,7 @@ import CodePreview from '../../shared/CodePreview';
import { Ellipsis } from '../../shared/Icons';
import { units, px } from '../../../style/variables';
import EmptyMessage from '../../shared/EmptyMessage';
import { EuiLink } from '@elastic/eui';
import { HeaderXSmall } from '../UIComponents';
import { EuiLink, EuiTitle } from '@elastic/eui';
const LibraryFrameToggle = styled.div`
margin: 0 0 ${px(units.plus)} 0;
@ -75,7 +74,9 @@ class Stacktrace extends PureComponent {
return (
<div>
<HeaderXSmall>Stacktraces</HeaderXSmall>
<EuiTitle size="xs">
<h3>Stack traces</h3>
</EuiTitle>
{getCollapsedLibraryFrames(stackframes).map((item, i) => {
if (!item.libraryFrame) {
return (

View file

@ -2,46 +2,25 @@
exports[`StickyProperties should render 1`] = `
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0 24px;
width: 100%;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.c1 {
width: 33%;
margin-bottom: 16px;
}
.c2 {
margin-bottom: 8px;
font-size: 12px;
color: #999999;
}
.c2 span {
.c0 span {
cursor: help;
}
.c4 {
.c2 {
color: #999999;
}
.c3 {
.c1 {
display: inline-block;
line-height: 16px;
}
.c5 {
.c3 {
display: inline-block;
line-height: 16px;
max-width: 100%;
@ -51,13 +30,25 @@ exports[`StickyProperties should render 1`] = `
}
<div
className="c0"
className="euiFlexGroup euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap"
style={
Object {
"marginBottom": "-1em",
"marginTop": "-1em",
}
}
>
<div
className="c1"
className="euiFlexItem"
style={
Object {
"minWidth": 0,
"padding": "1em 1em 1em 0",
}
}
>
<div
className="c2"
className="c0"
>
<span
aria-describedby="overlay1"
@ -68,12 +59,12 @@ exports[`StickyProperties should render 1`] = `
</span>
</div>
<div
className="c3"
className="c1"
>
1337 minutes ago (mocking 1536405447)
<span
className="c4"
className="c2"
>
(
1st of January (mocking 1536405447)
@ -82,10 +73,16 @@ exports[`StickyProperties should render 1`] = `
</div>
</div>
<div
className="c1"
className="euiFlexItem"
style={
Object {
"minWidth": 0,
"padding": "1em 1em 1em 0",
}
}
>
<div
className="c2"
className="c0"
>
<span
aria-describedby="overlay2"
@ -97,7 +94,7 @@ exports[`StickyProperties should render 1`] = `
</div>
<span
aria-describedby="overlay3"
className="c5"
className="c3"
onMouseOut={[Function]}
onMouseOver={[Function]}
>
@ -105,10 +102,16 @@ exports[`StickyProperties should render 1`] = `
</span>
</div>
<div
className="c1"
className="euiFlexItem"
style={
Object {
"minWidth": 0,
"padding": "1em 1em 1em 0",
}
}
>
<div
className="c2"
className="c0"
>
<span
aria-describedby="overlay4"
@ -119,16 +122,22 @@ exports[`StickyProperties should render 1`] = `
</span>
</div>
<div
className="c3"
className="c1"
>
GET
</div>
</div>
<div
className="c1"
className="euiFlexItem"
style={
Object {
"minWidth": 0,
"padding": "1em 1em 1em 0",
}
}
>
<div
className="c2"
className="c0"
>
<span
aria-describedby="overlay5"
@ -139,16 +148,22 @@ exports[`StickyProperties should render 1`] = `
</span>
</div>
<div
className="c3"
className="c1"
>
true
</div>
</div>
<div
className="c1"
className="euiFlexItem"
style={
Object {
"minWidth": 0,
"padding": "1em 1em 1em 0",
}
}
>
<div
className="c2"
className="c0"
>
<span
aria-describedby="overlay6"
@ -159,7 +174,7 @@ exports[`StickyProperties should render 1`] = `
</span>
</div>
<div
className="c3"
className="c1"
>
1337
</div>

View file

@ -4,32 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import moment from 'moment';
import TooltipOverlay from '../../shared/TooltipOverlay';
import {
unit,
units,
px,
fontFamilyCode,
fontSizes,
colors,
truncate
} from '../../../style/variables';
import TooltipOverlay, { fieldNameHelper } from '../../shared/TooltipOverlay';
const PropertiesContainer = styled.div`
display: flex;
padding: 0 ${px(units.plus)};
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
`;
const Property = styled.div`
width: 33%;
margin-bottom: ${px(unit)};
const TooltipFieldName = styled.span`
font-family: ${fontFamilyCode};
`;
const PropertyLabel = styled.div`
@ -57,6 +48,15 @@ const PropertyValueTruncated = styled.span`
${truncate('100%')};
`;
function fieldNameHelper(name) {
return (
<span>
Field name: <br />
<TooltipFieldName>{name}</TooltipFieldName>
</span>
);
}
function TimestampValue({ timestamp }) {
const time = moment(timestamp);
const timeAgo = timestamp ? time.fromNow() : 'N/A';
@ -98,19 +98,46 @@ function getPropertyValue({ val, fieldName, truncated = false }) {
);
}
return <PropertyValue>{String(val)}</PropertyValue>;
return <PropertyValue>{val}</PropertyValue>;
}
export function StickyProperties({ stickyProperties }) {
/**
* Note: the padding and margin styles here are strange because
* EUI flex groups and items have a default "gutter" applied that
* won't allow percentage widths to line up correctly, so we have
* to turn the gutter off with gutterSize: none. When we do that,
* the top/bottom spacing *also* collapses, so we have to add
* padding between each item without adding it to the outside of
* the flex group itself.
*
* Hopefully we can make EUI handle this better and remove all this.
*/
const itemStyles = {
padding: '1em 1em 1em 0'
};
const groupStyles = {
marginTop: '-1em',
marginBottom: '-1em'
};
return (
<PropertiesContainer>
<EuiFlexGroup wrap={true} gutterSize="none" style={groupStyles}>
{stickyProperties &&
stickyProperties.map((prop, i) => (
<Property key={i}>
{getPropertyLabel(prop)}
{getPropertyValue(prop)}
</Property>
))}
</PropertiesContainer>
stickyProperties.map(({ width = 0, ...prop }, i) => {
return (
<EuiFlexItem
key={i}
style={{
minWidth: width,
...itemStyles
}}
>
{getPropertyLabel(prop)}
{getPropertyValue(prop)}
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
}

View file

@ -5,15 +5,9 @@
*/
import React from 'react';
import styled from 'styled-components';
import { fontFamilyCode } from '../../style/variables';
import { Tooltip } from 'pivotal-ui/react/tooltip';
import { OverlayTrigger } from 'pivotal-ui/react/overlay-trigger';
const TooltipFieldName = styled.span`
font-family: ${fontFamilyCode};
`;
function TooltipOverlay({ children, content, delay = 1000 }) {
return (
<OverlayTrigger
@ -27,13 +21,4 @@ function TooltipOverlay({ children, content, delay = 1000 }) {
);
}
export function fieldNameHelper(name) {
return (
<span>
Field name: <br />
<TooltipFieldName>{name}</TooltipFieldName>
</span>
);
}
export default TooltipOverlay;

View file

@ -0,0 +1,58 @@
/*
* 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 { Transaction } from '../../../typings/Transaction';
import { KibanaLink, legacyEncodeURIComponent } from '../../utils/url';
interface TraceLinkProps {
transaction: Transaction;
}
/**
* Return the path and query used to build a trace link,
* given either a v2 Transaction or a Transaction Group
*/
export function getLinkProps(transaction: Transaction) {
const serviceName = transaction.context.service.name;
const transactionType = transaction.transaction.type;
const traceId =
transaction.version === 'v2' ? transaction.trace.id : undefined;
const transactionId = transaction.transaction.id;
const name = transaction.transaction.name;
const encodedName = legacyEncodeURIComponent(name);
return {
hash: `/${serviceName}/transactions/${transactionType}/${encodedName}`,
query: {
traceId,
transactionId
}
};
}
export const TraceLink: React.SFC<TraceLinkProps> = ({
transaction,
children
}) => {
if (!transaction) {
return null;
}
const linkProps = getLinkProps(transaction);
if (!linkProps) {
// TODO: Should this case return unlinked children, null, or something else?
return <React.Fragment>{children}</React.Fragment>;
}
return (
<KibanaLink pathname="/app/apm" {...linkProps}>
{children}
</KibanaLink>
);
};

View file

@ -5,14 +5,7 @@
*/
import styled from 'styled-components';
import {
unit,
units,
px,
fontSizes,
colors,
fontSize
} from '../../style/variables';
import { unit, units, px, fontSizes, colors } from '../../style/variables';
import { RelativeLink } from '../../utils/url';
export const HeaderContainer = styled.div`
@ -47,12 +40,6 @@ export const HeaderSmall = styled.h3`
${props => props.css};
`;
export const HeaderXSmall = styled.h4`
margin: ${px(units.plus)} 0;
font-size: ${fontSize};
${props => props.css};
`;
export const Tab = styled.div`
display: inline-block;
font-size: ${fontSizes.large};

View file

@ -16,7 +16,7 @@ import {
timeUnit
} from '../../../../../utils/formatters';
import { toJson } from '../../../../../utils/testHelpers';
import { getFormattedBuckets } from '../../../../app/TransactionDetails/Distribution/view';
import { getFormattedBuckets } from '../../../../app/TransactionDetails/Distribution/index';
describe('Histogram', () => {
let wrapper;
@ -98,9 +98,10 @@ describe('Histogram', () => {
it('should update state with "hoveredBucket"', () => {
expect(wrapper.state()).toEqual({
hoveredBucket: {
sampled: true,
sample: {
transactionId: '99c50a5b-44b4-4289-a3d1-a2815d128192'
},
style: { cursor: 'pointer' },
transactionId: '99c50a5b-44b4-4289-a3d1-a2815d128192',
x: 869010,
x0: 811076,
y: 49
@ -124,9 +125,10 @@ describe('Histogram', () => {
it('should call onClick with bucket', () => {
expect(onClick).toHaveBeenCalledWith({
sampled: true,
sample: {
transactionId: '99c50a5b-44b4-4289-a3d1-a2815d128192'
},
style: { cursor: 'pointer' },
transactionId: '99c50a5b-44b4-4289-a3d1-a2815d128192',
x: 869010,
x0: 811076,
y: 49

View file

@ -961,6 +961,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -976,6 +977,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -991,6 +993,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1006,6 +1009,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1021,6 +1025,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1084,6 +1089,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1099,6 +1105,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1130,6 +1137,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1145,6 +1153,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1160,6 +1169,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1175,6 +1185,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1190,6 +1201,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1205,6 +1217,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1220,6 +1233,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1235,6 +1249,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1250,6 +1265,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1265,6 +1281,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1280,6 +1297,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1295,6 +1313,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1310,6 +1329,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1325,6 +1345,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1340,6 +1361,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1355,6 +1377,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}
@ -1370,6 +1393,7 @@ exports[`Histogram Initially should have default markup 1`] = `
onMouseUp={[Function]}
style={
Object {
"cursor": "default",
"pointerEvents": "all",
}
}

View file

@ -8,20 +8,23 @@
{
"key": 579340,
"count": 8,
"transactionId": "99437ee4-08d4-41f5-9b2b-93cc32ec3dfb",
"sampled": true
"sample": {
"transactionId": "99437ee4-08d4-41f5-9b2b-93cc32ec3dfb"
}
},
{
"key": 695208,
"count": 23,
"transactionId": "d327611b-e999-4942-a94f-c60208940180",
"sampled": true
"sample": {
"transactionId": "d327611b-e999-4942-a94f-c60208940180"
}
},
{
"key": 811076,
"count": 49,
"transactionId": "99c50a5b-44b4-4289-a3d1-a2815d128192",
"sampled": true
"sample": {
"transactionId": "99c50a5b-44b4-4289-a3d1-a2815d128192"
}
},
{
"key": 926944,
@ -36,8 +39,9 @@
{
"key": 1158680,
"count": 13,
"transactionId": "8486d3e2-7f15-48df-aa37-6ee9955adbd2",
"sampled": true
"sample": {
"transactionId": "8486d3e2-7f15-48df-aa37-6ee9955adbd2"
}
},
{
"key": 1274548,

View file

@ -28,7 +28,8 @@ export default function AgentMarker({ agentMark, x }) {
<div
style={{
position: 'absolute',
left: px(x - legendWidth / 2)
left: px(x - legendWidth / 2),
bottom: '-6px'
}}
>
<EuiToolTip
@ -37,7 +38,7 @@ export default function AgentMarker({ agentMark, x }) {
content={
<div>
<NameContainer>{agentMark.name}</NameContainer>
<TimeContainer>{asTime(agentMark.timeLabel)}</TimeContainer>
<TimeContainer>{asTime(agentMark.us)}</TimeContainer>
</div>
}
>

View file

@ -18,7 +18,7 @@ import { getTimeFormatter } from '../../../../utils/formatters';
const getXAxisTickValues = (tickValues, xMax) =>
_.last(tickValues) * 1.05 > xMax ? tickValues.slice(0, -1) : tickValues;
function TimelineAxis({ header, plotValues, agentMarks }) {
function TimelineAxis({ plotValues, agentMarks }) {
const { margins, tickValues, width, xDomain, xMax, xScale } = plotValues;
const tickFormat = getTimeFormatter(xMax);
const xAxisTickValues = getXAxisTickValues(tickValues, xMax);
@ -38,13 +38,12 @@ function TimelineAxis({ header, plotValues, agentMarks }) {
...style
}}
>
{header}
<XYPlot
dontCheckIfEmpty
width={width}
height={40}
height={margins.top}
margin={{
top: 40,
top: margins.top,
left: margins.left,
right: margins.right
}}
@ -65,9 +64,9 @@ function TimelineAxis({ header, plotValues, agentMarks }) {
{agentMarks.map(agentMark => (
<AgentMarker
key={agentMark.timeAxis}
key={agentMark.name}
agentMark={agentMark}
x={xScale(agentMark.timeAxis)}
x={xScale(agentMark.us)}
/>
))}
</XYPlot>

View file

@ -20,9 +20,7 @@ class VerticalLines extends PureComponent {
xMax
} = this.props.plotValues;
const agentMarkTimes = this.props.agentMarks.map(
({ timeAxis }) => timeAxis
);
const agentMarkTimes = this.props.agentMarks.map(({ us }) => us);
return (
<div

View file

@ -57,21 +57,18 @@ exports[`Timeline should render with data 1`] = `
}
}
>
<div>
Hello - i am a header
</div>
<div
className="rv-xy-plot "
style={
Object {
"height": "40px",
"height": "100px",
"width": "1000px",
}
}
>
<svg
className="rv-xy-plot__inner"
height={40}
height={100}
onClick={[Function]}
onDoubleClick={[Function]}
onMouseDown={[Function]}
@ -94,7 +91,7 @@ exports[`Timeline should render with data 1`] = `
>
<g
className="rv-xy-plot__axis__ticks"
transform="translate(0, 40)"
transform="translate(0, 100)"
>
<g
className="rv-xy-plot__axis__tick"
@ -519,7 +516,7 @@ exports[`Timeline should render with data 1`] = `
</g>
</g>
<g
transform="translate(950, 40)"
transform="translate(950, 100)"
>
<text
dy="0"
@ -533,6 +530,7 @@ exports[`Timeline should render with data 1`] = `
<div
style={
Object {
"bottom": "-6px",
"left": "484.2043232706185px",
"position": "absolute",
}
@ -562,6 +560,7 @@ exports[`Timeline should render with data 1`] = `
<div
style={
Object {
"bottom": "-6px",
"left": "528.1747555976804px",
"position": "absolute",
}
@ -591,6 +590,7 @@ exports[`Timeline should render with data 1`] = `
<div
style={
Object {
"bottom": "-6px",
"left": "879.9382142141751px",
"position": "absolute",
}

View file

@ -10,8 +10,8 @@
},
"animation": null,
"agentMarks": [
{ "timeLabel": 100000, "name": "timeToFirstByte", "timeAxis": 100000 },
{ "timeLabel": 110000, "name": "domInteractive", "timeAxis": 110000 },
{ "timeLabel": 190000, "name": "domComplete", "timeAxis": 190000 }
{ "name": "timeToFirstByte", "us": 100000 },
{ "name": "domInteractive", "us": 110000 },
{ "name": "domComplete", "us": 190000 }
]
}

View file

@ -8,35 +8,22 @@ import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { makeWidthFlexible } from 'react-vis';
import { createSelector } from 'reselect';
import { getPlotValues } from './plotUtils';
import TimelineAxis from './TimelineAxis';
import VerticalLines from './VerticalLines';
class Timeline extends PureComponent {
getPlotValues = createSelector(
state => state.duration,
state => state.height,
state => state.margins,
state => state.width,
getPlotValues
);
render() {
const { width, duration, header, agentMarks } = this.props;
const { width, duration, agentMarks, height, margins } = this.props;
if (duration == null || !width) {
return null;
}
const plotValues = this.getPlotValues(this.props);
const plotValues = getPlotValues({ width, duration, height, margins });
return (
<div>
<TimelineAxis
plotValues={plotValues}
agentMarks={agentMarks}
header={header}
/>
<TimelineAxis plotValues={plotValues} agentMarks={agentMarks} />
<VerticalLines plotValues={plotValues} agentMarks={agentMarks} />
</div>
);

View file

@ -6,7 +6,7 @@
import { scaleLinear } from 'd3-scale';
export function getPlotValues(duration, height, margins, width) {
export function getPlotValues({ width, duration, height, margins }) {
const xMin = 0;
const xMax = duration;
const xScale = scaleLinear()

View file

@ -4,9 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
// @ts-ignore
import { camelizeKeys } from 'humps';
import { isEmpty } from 'lodash';
import { ServiceResponse } from 'x-pack/plugins/apm/server/lib/services/get_service';
import { ServiceListItemResponse } from 'x-pack/plugins/apm/server/lib/services/get_services';
import { IDistributionResponse } from 'x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution';
import { Span } from 'x-pack/plugins/apm/typings/Span';
import { Transaction } from 'x-pack/plugins/apm/typings/Transaction';
import { ITransactionGroup } from 'x-pack/plugins/apm/typings/TransactionGroup';
import { WaterfallResponse } from 'x-pack/plugins/apm/typings/waterfall';
import { IUrlParams } from '../../store/urlParams';
// @ts-ignore
import { convertKueryToEsQuery } from '../kuery';
// @ts-ignore
import { callApi } from './callApi';
// @ts-ignore
import { getAPMIndexPattern } from './savedObjects';
export async function loadLicense() {
@ -27,7 +40,7 @@ export async function loadAgentStatus() {
});
}
export async function getEncodedEsQuery(kuery) {
export async function getEncodedEsQuery(kuery?: string) {
if (!kuery) {
return;
}
@ -42,7 +55,11 @@ export async function getEncodedEsQuery(kuery) {
return encodeURIComponent(JSON.stringify(esFilterQuery));
}
export async function loadServiceList({ start, end, kuery }) {
export async function loadServiceList({
start,
end,
kuery
}: IUrlParams): Promise<ServiceListItemResponse> {
return callApi({
pathname: `/api/apm/services`,
query: {
@ -53,7 +70,12 @@ export async function loadServiceList({ start, end, kuery }) {
});
}
export async function loadServiceDetails({ serviceName, start, end, kuery }) {
export async function loadServiceDetails({
serviceName,
start,
end,
kuery
}: IUrlParams): Promise<ServiceResponse> {
return callApi({
pathname: `/api/apm/services/${serviceName}`,
query: {
@ -64,14 +86,34 @@ export async function loadServiceDetails({ serviceName, start, end, kuery }) {
});
}
export async function loadTraceList({
start,
end,
kuery
}: IUrlParams): Promise<ITransactionGroup[]> {
const groups: ITransactionGroup[] = await callApi({
pathname: '/api/apm/traces',
query: {
start,
end,
esFilterQuery: await getEncodedEsQuery(kuery)
}
});
return groups.map(group => {
group.sample = addVersion(group.sample);
return group;
});
}
export async function loadTransactionList({
serviceName,
start,
end,
kuery,
transactionType
}) {
return callApi({
}: IUrlParams): Promise<ITransactionGroup[]> {
const groups: ITransactionGroup[] = await callApi({
pathname: `/api/apm/services/${serviceName}/transactions`,
query: {
start,
@ -80,6 +122,11 @@ export async function loadTransactionList({
transaction_type: transactionType
}
});
return groups.map(group => {
group.sample = addVersion(group.sample);
return group;
});
}
export async function loadTransactionDistribution({
@ -88,7 +135,7 @@ export async function loadTransactionDistribution({
end,
transactionName,
kuery
}) {
}: IUrlParams): Promise<IDistributionResponse> {
return callApi({
pathname: `/api/apm/services/${serviceName}/transactions/distribution`,
query: {
@ -100,14 +147,54 @@ export async function loadTransactionDistribution({
});
}
export async function loadSpans({ serviceName, start, end, transactionId }) {
return callApi({
function addVersion<T extends Span | Transaction>(item: T): T {
if (!isEmpty(item)) {
item.version = item.hasOwnProperty('trace') ? 'v2' : 'v1';
}
return item;
}
function addSpanId(hit: Span, i: number) {
if (!hit.span.id) {
hit.span.id = i;
}
return hit;
}
export async function loadSpans({
serviceName,
start,
end,
transactionId
}: IUrlParams): Promise<Span[]> {
const hits: Span[] = await callApi({
pathname: `/api/apm/services/${serviceName}/transactions/${transactionId}/spans`,
query: {
start,
end
}
});
return hits.map(addVersion).map(addSpanId);
}
export async function loadTrace({ traceId, start, end }: IUrlParams) {
const result: WaterfallResponse = await callApi(
{
pathname: `/api/apm/traces/${traceId}`,
query: {
start,
end
}
},
{
camelcase: false
}
);
result.hits = result.hits.map(addVersion);
return result;
}
export async function loadTransaction({
@ -115,12 +202,14 @@ export async function loadTransaction({
start,
end,
transactionId,
traceId,
kuery
}) {
const res = await callApi(
}: IUrlParams) {
const result: Transaction = await callApi(
{
pathname: `/api/apm/services/${serviceName}/transactions/${transactionId}`,
query: {
traceId,
start,
end,
esFilterQuery: await getEncodedEsQuery(kuery)
@ -130,11 +219,8 @@ export async function loadTransaction({
camelcase: false
}
);
const camelizedRes = camelizeKeys(res);
if (res.context) {
camelizedRes.context = res.context;
}
return camelizedRes;
return addVersion(result);
}
export async function loadCharts({
@ -144,7 +230,7 @@ export async function loadCharts({
kuery,
transactionType,
transactionName
}) {
}: IUrlParams) {
return callApi({
pathname: `/api/apm/services/${serviceName}/transactions/charts`,
query: {
@ -157,6 +243,12 @@ export async function loadCharts({
});
}
interface ErrorGroupListParams extends IUrlParams {
size: number;
sortField: string;
sortDirection: string;
}
export async function loadErrorGroupList({
serviceName,
start,
@ -165,7 +257,7 @@ export async function loadErrorGroupList({
size,
sortField,
sortDirection
}) {
}: ErrorGroupListParams) {
return callApi({
pathname: `/api/apm/services/${serviceName}/errors`,
query: {
@ -185,7 +277,7 @@ export async function loadErrorGroupDetails({
end,
kuery,
errorGroupId
}) {
}: IUrlParams) {
const res = await callApi(
{
pathname: `/api/apm/services/${serviceName}/errors/${errorGroupId}`,
@ -212,7 +304,7 @@ export async function loadErrorDistribution({
end,
kuery,
errorGroupId
}) {
}: IUrlParams) {
return callApi({
pathname: `/api/apm/services/${serviceName}/errors/${errorGroupId}/distribution`,
query: {

View file

@ -4,11 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import reducer from '../rootReducer';
import { rootReducer } from '../rootReducer';
describe('root reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual({
expect(rootReducer(undefined, {})).toEqual({
location: { hash: '', pathname: '', search: '' },
reactReduxRequest: {},
urlParams: {}

View file

@ -4,12 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import reducer, { updateTimePicker } from '../urlParams';
import { urlParamsReducer, updateTimePicker } from '../urlParams';
import { LOCATION_UPDATE } from '../location';
describe('urlParams', () => {
it('should handle LOCATION_UPDATE for transactions section', () => {
const state = reducer(
const state = urlParamsReducer(
{},
{
type: LOCATION_UPDATE,
@ -34,7 +34,7 @@ describe('urlParams', () => {
});
it('should handle LOCATION_UPDATE for error section', () => {
const state = reducer(
const state = urlParamsReducer(
{},
{
type: LOCATION_UPDATE,
@ -56,7 +56,7 @@ describe('urlParams', () => {
});
it('should handle TIMEPICKER_UPDATE', () => {
const state = reducer(
const state = urlParamsReducer(
{},
updateTimePicker({
min: 'minTime',

View file

@ -7,7 +7,7 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import throttle from '../middleware/throttle';
import rootReducer from '../rootReducer';
import { rootReducer } from '../rootReducer';
export default function configureStore(preloadedState) {
const composeEnhancers =

View file

@ -6,7 +6,7 @@
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../rootReducer';
import { rootReducer } from '../rootReducer';
export default function configureStore(preloadedState) {
return createStore(rootReducer, preloadedState, applyMiddleware(thunk));

View file

@ -0,0 +1,30 @@
[
{
"name": "log",
"serviceName": "flask-server",
"averageResponseTime": 1329,
"tracesPerMinute": 3201,
"impact": 70
},
{
"name": "products/item",
"serviceName": "client",
"averageResponseTime": 2301,
"tracesPerMinute": 5432,
"impact": 42
},
{
"name": "billing/payment",
"serviceName": "client",
"averageResponseTime": 789,
"tracesPerMinute": 1201,
"impact": 14
},
{
"name": "user/profile",
"serviceName": "client",
"averageResponseTime": 1212,
"tracesPerMinute": 904,
"impact": 92
}
]

View file

@ -20,7 +20,7 @@ export function getErrorDistribution(state) {
export function ErrorDistributionRequest({ urlParams, render }) {
const { serviceName, start, end, errorGroupId, kuery } = urlParams;
if (!(serviceName, start, end, errorGroupId)) {
if (!(serviceName && start && end && errorGroupId)) {
return null;
}

Some files were not shown because too many files have changed in this diff Show more