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

Improve Log context exploration support #45

Merged
merged 6 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
347 changes: 347 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@grafana/runtime": "10.0.3",
"@grafana/ui": "10.0.3",
"@reduxjs/toolkit": "^1.9.5",
"@uiw/react-codemirror": "^4.21.21",
"lucene": "^2.1.1",
"react": "17.0.2",
"react-dom": "17.0.2",
Expand Down
131 changes: 131 additions & 0 deletions src/LogContext/LogContextProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ReactNode } from 'react';
import { lastValueFrom } from 'rxjs';
import { QuickwitDataSource } from 'datasource';
import { catchError } from 'rxjs/operators';

import {
CoreApp,
DataFrame,
DataQueryError,
DataQueryRequest,
dateTime,
LogRowModel,
rangeUtil,
} from '@grafana/data';

import { ElasticsearchQuery, Logs} from '../types';

import { LogContextUI } from 'LogContext/components/LogContextUI';

export interface LogRowContextOptions {
direction?: LogRowContextQueryDirection;
limit?: number;
}
export enum LogRowContextQueryDirection {
Backward = 'BACKWARD',
Forward = 'FORWARD',
}

function createContextTimeRange(rowTimeEpochMs: number, direction: string) {
const offset = 7;
// For log context, we want to request data from 7 subsequent/previous indices
if (direction === LogRowContextQueryDirection.Forward) {
return {
from: dateTime(rowTimeEpochMs).utc(),
to: dateTime(rowTimeEpochMs).add(offset, 'hours').utc(),
};
} else {
return {
from: dateTime(rowTimeEpochMs).subtract(offset, 'hours').utc(),
to: dateTime(rowTimeEpochMs).utc(),
};
}
}

export class LogContextProvider {
datasource: QuickwitDataSource;
contextQuery: string | null;

constructor(datasource: QuickwitDataSource) {
this.datasource = datasource;
this.contextQuery = null;
}
private makeLogContextDataRequest = (
row: LogRowModel,
options?: LogRowContextOptions,
origQuery?: ElasticsearchQuery
) => {
const direction = options?.direction || LogRowContextQueryDirection.Backward;
const searchAfter = row.dataFrame.fields.find((f) => f.name === 'sort')?.values.get(row.rowIndex) ?? [row.timeEpochNs]

const logQuery: Logs = {
type: 'logs',
id: '1',
settings: {
limit: options?.limit ? options?.limit.toString() : '10',
// Sorting of results in the context query
sortDirection: direction === LogRowContextQueryDirection.Backward ? 'desc' : 'asc',
// Used to get the next log lines before/after the current log line using sort field of selected log line
searchAfter: searchAfter,
},
};

const query: ElasticsearchQuery = {
refId: `log-context-${row.dataFrame.refId}-${direction}`,
metrics: [logQuery],
query: this.contextQuery == null ? origQuery?.query : this.contextQuery,
};

const timeRange = createContextTimeRange(row.timeEpochMs, direction);
const range = {
from: timeRange.from,
to: timeRange.to,
raw: timeRange,
};

const interval = rangeUtil.calculateInterval(range, 1);

const contextRequest: DataQueryRequest<ElasticsearchQuery> = {
requestId: `log-context-request-${row.dataFrame.refId}-${options?.direction}`,
targets: [query],
interval: interval.interval,
intervalMs: interval.intervalMs,
range,
scopedVars: {},
timezone: 'UTC',
app: CoreApp.Explore,
startTime: Date.now(),
hideFromInspector: true,
};
return contextRequest;
};

getLogRowContext = async (
row: LogRowModel,
options?: LogRowContextOptions,
origQuery?: ElasticsearchQuery
): Promise<{ data: DataFrame[] }> => {
const contextRequest = this.makeLogContextDataRequest(row, options, origQuery);

return lastValueFrom(
this.datasource.query(contextRequest).pipe(
catchError((err) => {
const error: DataQueryError = {
message: 'Error during context query. Please check JS console logs.',
status: err.status,
statusText: err.message,
};
throw error;
})
)
);
};

getLogRowContextUi(
row: LogRowModel,
runContextQuery?: (() => void),
origQuery?: ElasticsearchQuery
): ReactNode {
return ( LogContextUI({row, runContextQuery, origQuery, updateQuery: query=>{this.contextQuery=query}, datasource:this.datasource}))
}
}
191 changes: 191 additions & 0 deletions src/LogContext/components/LogContextQueryBuilderSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useEffect, useMemo, useState } from "react";
// import { Field } from '@grafana/data';
import { useTheme2, CollapsableSection, Icon } from '@grafana/ui';
import { LogContextProps } from "./LogContextUI";
import { css, cx } from "@emotion/css";
import { LuceneQuery } from "utils/lucene";
import { LuceneQueryBuilder } from '@/QueryBuilder/lucene';


// TODO : define sensible defaults here
const excludedFields = [
'_source',
'sort',
'attributes',
Copy link
Contributor

Choose a reason for hiding this comment

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

this is going to be an issue as this works only for otel indexes.

Can you open an issue on that?

'attributes.message',
'body',
'body.message',
'resource_attributes',
'observed_timestamp_nanos',
'timestamp_nanos',
];

function isPrimitive(valT: any) {
return ['string', 'number', "boolean", "undefined"].includes(valT)
}

type FieldContingency = { [value: string]: {
count: number, pinned: boolean, active?: boolean
}};
type Field = {
name: string,
contingency: FieldContingency
}

function LogContextFieldSection(field: Field) {
const theme = useTheme2()
const hasActiveFilters = Object.entries(field.contingency).map(([_,entry])=>!!entry.active).reduce((a,b)=>a || b, false);
return(
<span className={css({fontSize:theme.typography.body.fontSize, display:"flex", alignItems: "baseline", gap:"0.5rem", width:"100%"})}>
{hasActiveFilters && <Icon name={"filter"} className={css({ color:theme.colors.primary.text })}/>}
<span>{field.name}</span>
</span>
)
}

type FieldItemProps = {
label: any,
contingency: {
count: number,
pinned: boolean
},
active?: boolean,
onClick: () => void
}

function LogContextFieldItem(props: FieldItemProps){
const theme = useTheme2()
const lcAttributeItemStyle = css({
display: "flex",
justifyContent: "space-between",
paddingLeft: "10px",
fontSize: theme.typography.bodySmall.fontSize,
"&[data-active=true]": {
backgroundColor: theme.colors.primary.transparent,
},
"&:hover": {
backgroundColor: theme.colors.secondary.shade,
}
});

const formatLabel = (value: any)=> {
let shouldEmphasize = false;
let label = `${value}`;

if (value === null || value === '' || value === undefined){
shouldEmphasize = true;
}
if (value === '') {
label = '<empty string>'
}
return (shouldEmphasize ? <em>{label}</em> : label);
}

return (
<a className={lcAttributeItemStyle} onClick={props.onClick} data-active={props.active}>
<span className={css`text-overflow:ellipsis; min-width:0; flex:1 1`}>{ formatLabel(props.label) }</span>
<span className={css`flex-grow:0`}>{props.contingency.pinned && <Icon name={"crosshair"}/>}{props.contingency.count}</span>
</a>
)
}

const lcSidebarStyle = css`
width: 300px;
min-width: 300px;
flex-shrink: 0;
overflow-y: scroll;
padding-right: 1rem;
`

type QueryBuilderProps = {
builder: LuceneQueryBuilder,
searchableFields: any[],
updateQuery: (query: LuceneQuery) => void
}

export function LogContextQueryBuilderSidebar(props: LogContextProps & QueryBuilderProps) {

const {row, builder, updateQuery, searchableFields} = props;
const [fields, setFields] = useState<Field[]>([]);

const filteredFields = useMemo(() => {
const searchableFieldsNames = searchableFields.map(f=>f.text);
return row.dataFrame.fields
.filter(f=>searchableFieldsNames.includes(f.name))
// exclude some low-filterability fields
.filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.type))
// sort fields by name
.sort((f1, f2)=> (f1.name>f2.name ? 1 : -1))
}, [row, searchableFields]);

useEffect(() => {
const fields = filteredFields
.map((f) => {
const contingency: FieldContingency = {};
f.values.forEach((value, i) => {
if (!contingency[value]) {
contingency[value] = {
count: 0,
pinned: false,
active: builder.parsedQuery ? !!builder.parsedQuery.findFilter(f.name, `${value}`) : false
}
}
contingency[value].count += 1;
if (i === row.rowIndex) {
contingency[value].pinned = true;
}
});
return { name: f.name, contingency };
})

setFields(fields);
}, [filteredFields, row.rowIndex, builder.parsedQuery]);


const selectQueryFilter = (key: string, value: string): void => {
// Compute mutation to apply to the query and send to parent
// check if that filter is in the query
if (!builder.parsedQuery) { return; }

const newParsedQuery = (
builder.parsedQuery.hasFilter(key, value)
? builder.parsedQuery.removeFilter(key, value)
: builder.parsedQuery.addFilter(key, value)
)

if (newParsedQuery) {
updateQuery(newParsedQuery);
}
}

const renderFieldSection = (field: Field)=>{
return (
<CollapsableSection
label={LogContextFieldSection(field)}
className={css`& > div { flex-grow:1; }` }
isOpen={false} key="log-attribute-field-{field.name}"
contentClassName={cx(css`margin:0; padding:0`)}>
<div className={css`display:flex; flex-direction:column; gap:5px`}>

{field.contingency && Object.entries(field.contingency)
.sort(([na, ca], [nb, cb])=>(cb.count - ca.count))
.map(([fieldValue, contingency], i) => (
<LogContextFieldItem
label={fieldValue} contingency={contingency} key={`field-opt${i}`}
onClick={() => {selectQueryFilter(field.name, fieldValue)}}
active={contingency.active}
/>
))
}
</div>
</CollapsableSection>
)
}

return (
<div className={lcSidebarStyle}>
{fields && fields.map((field) => {
return( renderFieldSection(field) );
}) } </div>
);
}