Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Logs UI] Shared <LogStream /> component #76262

Merged
merged 19 commits into from
Sep 7, 2020
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion x-pack/plugins/apm/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"kibanaReact",
"kibanaUtils",
"observability",
"home"
"home",
"infra"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { TransactionMetadata } from '../../../shared/MetadataTable/TransactionMetadata';
import { WaterfallContainer } from './WaterfallContainer';
import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';
import { LogStream } from '../../../../../../infra/public';

const timelineTab = {
key: 'timeline',
Expand All @@ -30,6 +31,13 @@ const metadataTab = {
}),
};

const logsTab = {
key: 'logs',
label: i18n.translate('xpack.apm.propertiesTable.tabs.logsLabel', {
defaultMessage: 'Logs',
}),
};

interface Props {
location: Location;
transaction: Transaction;
Expand All @@ -46,9 +54,9 @@ export function TransactionTabs({
exceedsMax,
}: Props) {
const history = useHistory();
const tabs = [timelineTab, metadataTab];
const tabs = [timelineTab, metadataTab, logsTab];
const currentTab =
urlParams.detailTab === metadataTab.key ? metadataTab : timelineTab;
tabs.find((tab) => tab.key === urlParams.detailTab) ?? timelineTab;

return (
<React.Fragment>
Expand Down Expand Up @@ -83,6 +91,14 @@ export function TransactionTabs({
waterfall={waterfall}
exceedsMax={exceedsMax}
/>
) : currentTab.key === logsTab.key ? (
<div>
<LogStream
startTimestamp={Date.now() - 86400000}
endTimestamp={Date.now()}
query={`trace.id: "${urlParams.traceId}" OR "${urlParams.traceId}"`}
/>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface lgtm 👍 Thanks for doing this

</div>
) : (
<TransactionMetadata transaction={transaction} />
)}
Expand Down
73 changes: 73 additions & 0 deletions x-pack/plugins/infra/public/components/log_stream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Embeddable `<LogStream />` component

The purpose of this component is to allow you, the developer, to have your very own Log Stream in your plugin.

The plugin is exposed through `infra/public`. Since Kibana uses relative paths is up to you to find how to import it (sorry).

```tsx
import { LogStream } from '../../../../../../infra/public';
```

## Prerequisites

To use the component, there are several things you need to ensure in your plugin:

- In your `kibana.json` plugin, you need to either add `"requiredBundles": ["infra"]` or `"requiredPlugins": ["infra"]`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would requiredBundles be enough given that this component uses the infra HTTP API to fetch the data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, good point. I haven't tried what would happen if the infra plugin is disabled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can provide a "is the logs api available?" wrapper alongside that renders an informative message instead of the children? (In a separate PR, though.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked what happens with xpack.infra.enabled: false. The component renders, but it says there's no log data. I will remove the requiredBundles bit from the docs because it's a lie :D.

Copy link
Contributor Author

@afgomez afgomez Sep 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I want to give freedom to plugin developers here. They can either choose to make it a hard requirement (requiredPlugins), or a soft one (optionalPlugins). In the second case, we can advise to wrap our component in a check that tests if the infra plugin is loaded or not, alongside other changes that they want to do in their UI.

function SomeComponent() {
	const { services } = useKibana();
	const hasInfraPlugin = 'infra' in services; // This seems to work

	const tabs = ['Timeline', 'Metadata'];
	if (hasInfraPlugin) {
		tabs.push('Logs');
	}
	// ...
}

We can give examples in the docs. Do you think this is enough, or do you think it's the responsibility of our component to check for this? We can of course also check on our side and raise an exception.


Edit: It's not possible. It needs to be in requiredPlugins if the component is imported

Screenshot 2020-09-04 at 17 15 08

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if it's in optional plugins, then they need to add it to requiredBundles as I understand it.

- The component needs to be mounted inside the hiearchy of a [`kibana-react` provider](https://github.com/elastic/kibana/blob/b2d0aa7b7fae1c89c8f9e8854ae73e71be64e765/src/plugins/kibana_react/README.md#L45).

## Usage

The simplest way to use the component is with a date range, passed with the `startTimestamp` and `endTimestamp` props.

```tsx
const endTimestamp = Date.now();
const startTimestamp = endTimestamp - 15 * 60 * 1000; // 15 minutes

<LogStream startTimestamp={startTimestamp} endTimestamp={endTimestamp} />;
```

This will show a list of log entries between the time range, in ascending order (oldest first), but with the scroll position all the way to the bottom (showing the newest entries)

### Filtering data

You might want to show specific data for the purpose of your plugin. Maybe you want to show log lines from a specific host, or for an APM trace. You can pass a KQL expression via the `query` prop.

```tsx
<LogStream
startTimestamp={startTimestamp}
endTimestamp={endTimestamp}
query="trace.id: 18fabada9384abd4"
/>
```

### Modifying rendering

By default the component will initially load at the bottom of the list, showing the newest entries. You can change what log line is shown in the center via the `center` prop. The prop takes a [`LogEntriesCursor`](https://github.com/elastic/kibana/blob/0a6c748cc837c016901f69ff05d81395aa2d41c8/x-pack/plugins/infra/common/http_api/log_entries/common.ts#L9-L13).

```tsx
<LogStream
startTimestamp={startTimestamp}
endTimestamp={endTimestamp}
center={{ time: ..., tiebreaker: ... }}
/>
```

If you want to highlight a specific log line, you can do so by passing its ID in the `highlight` prop.

```tsx
<LogStream startTimestamp={startTimestamp} endTimestamp={endTimestamp} highlight="abcde12345" />
```

### Source configuration

The infra plugin has the concept of "source configuration" to store settings for the logs UI. The component will use the source configuration to determine which indices to query or what columns to show.

By default the `<LogStream />` uses the `"default"` source confiuration, but if your plugin uses a different one you can specify it via the `sourceId` prop.

```tsx
<LogStream startTimestamp={startTimestamp} endTimestamp={endTimestamp} sourceId="my_source" />
```

### Considerations

As mentioned in the prerequisites, the component relies on `kibana-react` to access kibana's core services. If this is not the case the component will throw an exception when rendering. We advise to use an `<EuiErrorBoundary>` in your component hierarchy to catch this error if necessary.
129 changes: 129 additions & 0 deletions x-pack/plugins/infra/public/components/log_stream/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* 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, { useMemo } from 'react';
import { noop } from 'lodash';
import { useMount } from 'react-use';
import { euiStyled } from '../../../../observability/public';

import { LogEntriesCursor } from '../../../common/http_api';

import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { useLogSource } from '../../containers/logs/log_source';
import { useLogStream } from '../../containers/logs/log_stream';

import { ScrollableLogTextStreamView } from '../logging/log_text_stream';

interface LogStreamProps {
sourceId?: string;
startTimestamp: number;
endTimestamp: number;
query?: string;
center?: LogEntriesCursor;
highlight?: string;
height?: string | number;
}

export const LogStream: React.FC<LogStreamProps> = ({
sourceId = 'default',
startTimestamp,
endTimestamp,
query,
center,
highlight,
height = '400px',
}) => {
// source boilerplate
const { services } = useKibana();
if (!services?.http?.fetch) {
throw new Error(
`<LogStream /> cannot access kibana core services.

Ensure the component is mounted within kibana-react's <KibanaContextProvider> hierarchy.
Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_react/README.md"
`
);
}

const {
sourceConfiguration,
loadSourceConfiguration,
isLoadingSourceConfiguration,
} = useLogSource({
sourceId,
fetch: services.http.fetch,
});

// Internal state
const { loadingState, entries, fetchEntries } = useLogStream({
sourceId,
startTimestamp,
endTimestamp,
query,
center,
});

// Derived state
const isReloading =
isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading';

const columnConfigurations = useMemo(() => {
return sourceConfiguration ? sourceConfiguration.configuration.logColumns : [];
}, [sourceConfiguration]);

const streamItems = useMemo(
() =>
entries.map((entry) => ({
kind: 'logEntry' as const,
logEntry: entry,
highlights: [],
})),
[entries]
);

// Component lifetime
useMount(() => {
loadSourceConfiguration();
fetchEntries();
});

const parsedHeight = typeof height === 'number' ? `${height}px` : height;

return (
<LogStreamContent height={parsedHeight}>
<ScrollableLogTextStreamView
target={center ? center : entries.length ? entries[entries.length - 1].cursor : null}
columnConfigurations={columnConfigurations}
items={streamItems}
scale="medium"
wrap={false}
isReloading={isReloading}
isLoadingMore={false}
hasMoreBeforeStart={false}
hasMoreAfterEnd={false}
isStreaming={false}
lastLoadedTime={null}
jumpToTarget={noop}
reportVisibleInterval={noop}
loadNewerItems={noop}
reloadItems={fetchEntries}
highlightedItem={highlight ?? null}
currentHighlightKey={null}
startDateExpression={''}
endDateExpression={''}
updateDateRange={noop}
startLiveStreaming={noop}
hideScrollbar={false}
/>
</LogStreamContent>
);
};

const LogStreamContent = euiStyled.div<{ height: string }>`
display: flex;
background-color: ${(props) => props.theme.eui.euiColorEmptyShade};
height: ${(props) => props.height};
`;
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface ScrollableLogTextStreamViewProps {
endDateExpression: string;
updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void;
startLiveStreaming: () => void;
hideScrollbar?: boolean;
}

interface ScrollableLogTextStreamViewState {
Expand Down Expand Up @@ -146,6 +147,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
setFlyoutVisibility,
setContextEntry,
} = this.props;
const hideScrollbar = this.props.hideScrollbar ?? true;

const { targetId, items, isScrollLocked } = this.state;
const hasItems = items.length > 0;
Expand Down Expand Up @@ -196,7 +198,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
width={width}
onVisibleChildrenChange={this.handleVisibleChildrenChange}
target={targetId}
hideScrollbar={true}
hideScrollbar={hideScrollbar}
data-test-subj={'logStream'}
isLocked={isScrollLocked}
entriesCount={items.length}
Expand Down
89 changes: 89 additions & 0 deletions x-pack/plugins/infra/public/containers/logs/log_stream/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 { useState, useMemo } from 'react';
import { esKuery } from '../../../../../../../src/plugins/data/public';
import { fetchLogEntries } from '../log_entries/api/fetch_log_entries';
import { useTrackedPromise } from '../../../utils/use_tracked_promise';
import { LogEntry, LogEntriesCursor } from '../../../../common/http_api';

interface LogStreamProps {
sourceId: string;
startTimestamp: number;
endTimestamp: number;
query?: string;
center?: LogEntriesCursor;
}

interface LogStreamState {
entries: LogEntry[];
fetchEntries: () => void;
loadingState: 'uninitialized' | 'loading' | 'success' | 'error';
}

export function useLogStream({
sourceId,
startTimestamp,
endTimestamp,
query,
center,
}: LogStreamProps): LogStreamState {
const [entries, setEntries] = useState<LogStreamState['entries']>([]);

const parsedQuery = useMemo(() => {
return query
? JSON.stringify(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)))
: null;
}, [query]);

// Callbacks
const [entriesPromise, fetchEntries] = useTrackedPromise(
{
cancelPreviousOn: 'creation',
createPromise: () => {
setEntries([]);
const fetchPosition = center ? { center } : { before: 'last' };

return fetchLogEntries({
sourceId,
startTimestamp,
endTimestamp,
query: parsedQuery,
...fetchPosition,
});
},
onResolve: ({ data }) => {
setEntries(data.entries);
},
},
[sourceId, startTimestamp, endTimestamp, query]
);

const loadingState = useMemo(() => convertPromiseStateToLoadingState(entriesPromise.state), [
entriesPromise.state,
]);

return {
entries,
fetchEntries,
loadingState,
};
}

function convertPromiseStateToLoadingState(
state: 'uninitialized' | 'pending' | 'resolved' | 'rejected'
): LogStreamState['loadingState'] {
switch (state) {
case 'uninitialized':
return 'uninitialized';
case 'pending':
return 'loading';
case 'resolved':
return 'success';
case 'rejected':
return 'error';
}
}
Loading