Skip to content

Commit dd93765

Browse files
committed
Extract LogContextProvider, add LogContextUI
1 parent ec593c0 commit dd93765

File tree

5 files changed

+416
-96
lines changed

5 files changed

+416
-96
lines changed

src/LogContext/LogContextProvider.ts

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { ReactNode } from 'react';
2+
import { lastValueFrom } from 'rxjs';
3+
import { QuickwitDataSource } from 'datasource';
4+
import { catchError } from 'rxjs/operators';
5+
6+
import {
7+
CoreApp,
8+
DataFrame,
9+
DataQueryError,
10+
DataQueryRequest,
11+
dateTime,
12+
LogRowModel,
13+
rangeUtil,
14+
} from '@grafana/data';
15+
16+
import { ElasticsearchQuery, Logs} from '../types';
17+
18+
import { LogContextUI } from 'LogContext/components/LogContextUI';
19+
20+
export interface LogRowContextOptions {
21+
direction?: LogRowContextQueryDirection;
22+
limit?: number;
23+
}
24+
export enum LogRowContextQueryDirection {
25+
Backward = 'BACKWARD',
26+
Forward = 'FORWARD',
27+
}
28+
29+
function createContextTimeRange(rowTimeEpochMs: number, direction: string) {
30+
const offset = 7;
31+
// For log context, we want to request data from 7 subsequent/previous indices
32+
if (direction === LogRowContextQueryDirection.Forward) {
33+
return {
34+
from: dateTime(rowTimeEpochMs).utc(),
35+
to: dateTime(rowTimeEpochMs).add(offset, 'hours').utc(),
36+
};
37+
} else {
38+
return {
39+
from: dateTime(rowTimeEpochMs).subtract(offset, 'hours').utc(),
40+
to: dateTime(rowTimeEpochMs).utc(),
41+
};
42+
}
43+
}
44+
45+
export class LogContextProvider {
46+
datasource: QuickwitDataSource;
47+
contextQuery: string | null;
48+
49+
constructor(datasource: QuickwitDataSource) {
50+
this.datasource = datasource;
51+
this.contextQuery = null;
52+
}
53+
private makeLogContextDataRequest = (
54+
row: LogRowModel,
55+
options?: LogRowContextOptions,
56+
origQuery?: ElasticsearchQuery
57+
) => {
58+
const direction = options?.direction || LogRowContextQueryDirection.Backward;
59+
const searchAfter = row.dataFrame.fields.find((f) => f.name === 'sort')?.values.get(row.rowIndex) ?? [row.timeEpochNs]
60+
61+
const logQuery: Logs = {
62+
type: 'logs',
63+
id: '1',
64+
settings: {
65+
limit: options?.limit ? options?.limit.toString() : '10',
66+
// Sorting of results in the context query
67+
sortDirection: direction === LogRowContextQueryDirection.Backward ? 'desc' : 'asc',
68+
// Used to get the next log lines before/after the current log line using sort field of selected log line
69+
searchAfter: searchAfter,
70+
},
71+
};
72+
73+
const query: ElasticsearchQuery = {
74+
refId: `log-context-${row.dataFrame.refId}-${direction}`,
75+
metrics: [logQuery],
76+
query: this.contextQuery == null ? origQuery?.query : this.contextQuery,
77+
};
78+
79+
const timeRange = createContextTimeRange(row.timeEpochMs, direction);
80+
const range = {
81+
from: timeRange.from,
82+
to: timeRange.to,
83+
raw: timeRange,
84+
};
85+
86+
const interval = rangeUtil.calculateInterval(range, 1);
87+
88+
const contextRequest: DataQueryRequest<ElasticsearchQuery> = {
89+
requestId: `log-context-request-${row.dataFrame.refId}-${options?.direction}`,
90+
targets: [query],
91+
interval: interval.interval,
92+
intervalMs: interval.intervalMs,
93+
range,
94+
scopedVars: {},
95+
timezone: 'UTC',
96+
app: CoreApp.Explore,
97+
startTime: Date.now(),
98+
hideFromInspector: true,
99+
};
100+
return contextRequest;
101+
};
102+
103+
getLogRowContext = async (
104+
row: LogRowModel,
105+
options?: LogRowContextOptions,
106+
origQuery?: ElasticsearchQuery
107+
): Promise<{ data: DataFrame[] }> => {
108+
const contextRequest = this.makeLogContextDataRequest(row, options, origQuery);
109+
110+
return lastValueFrom(
111+
this.datasource.query(contextRequest).pipe(
112+
catchError((err) => {
113+
const error: DataQueryError = {
114+
message: 'Error during context query. Please check JS console logs.',
115+
status: err.status,
116+
statusText: err.message,
117+
};
118+
throw error;
119+
})
120+
)
121+
);
122+
};
123+
124+
getLogRowContextUi(
125+
row: LogRowModel,
126+
runContextQuery?: (() => void),
127+
origQuery?: ElasticsearchQuery
128+
): ReactNode {
129+
return ( LogContextUI({row, runContextQuery, origQuery, updateQuery: query=>{this.contextQuery=query}, datasource:this.datasource}))
130+
}
131+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import React, { useEffect, useMemo, useState } from "react";
2+
// import { Field } from '@grafana/data';
3+
import { useTheme2, CollapsableSection, Icon } from '@grafana/ui';
4+
import { LogContextProps } from "./LogContextUI";
5+
import { css, cx } from "@emotion/css";
6+
import { LuceneQuery } from "utils/lucene";
7+
import { LuceneQueryBuilder } from '@/QueryBuilder/lucene';
8+
9+
10+
// TODO : define sensible defaults here
11+
const excludedFields = [
12+
'_source',
13+
'sort',
14+
'attributes',
15+
'attributes.message',
16+
'body',
17+
'body.message',
18+
'resource_attributes',
19+
'observed_timestamp_nanos',
20+
'timestamp_nanos',
21+
];
22+
23+
function isPrimitive(valT: any) {
24+
return ['string', 'number', "boolean", "undefined"].includes(valT)
25+
}
26+
27+
type FieldContingency = { [value: string]: {
28+
count: number, pinned: boolean, active?: boolean
29+
}};
30+
type Field = {
31+
name: string,
32+
contingency: FieldContingency
33+
}
34+
35+
function LogContextFieldSection(field: Field) {
36+
const theme = useTheme2()
37+
const hasActiveFilters = Object.entries(field.contingency).map(([_,entry])=>!!entry.active).reduce((a,b)=>a || b, false);
38+
return(
39+
<span className={css({fontSize:theme.typography.body.fontSize, display:"flex", alignItems: "baseline", gap:"0.5rem", width:"100%"})}>
40+
{hasActiveFilters && <Icon name={"filter"} className={css({ color:theme.colors.primary.text })}/>}
41+
<span>{field.name}</span>
42+
</span>
43+
)
44+
}
45+
46+
type FieldItemProps = {
47+
label: any,
48+
contingency: {
49+
count: number,
50+
pinned: boolean
51+
},
52+
active?: boolean,
53+
onClick: () => void
54+
}
55+
56+
function LogContextFieldItem(props: FieldItemProps){
57+
const theme = useTheme2()
58+
const lcAttributeItemStyle = css({
59+
display: "flex",
60+
justifyContent: "space-between",
61+
paddingLeft: "10px",
62+
fontSize: theme.typography.bodySmall.fontSize,
63+
"&[data-active=true]": {
64+
backgroundColor: theme.colors.primary.transparent,
65+
},
66+
"&:hover": {
67+
backgroundColor: theme.colors.secondary.shade,
68+
}
69+
});
70+
71+
const formatLabel = (value: any)=> {
72+
let shouldEmphasize = false;
73+
let label = `${value}`;
74+
75+
if (value === null || value === '' || value === undefined){
76+
shouldEmphasize = true;
77+
}
78+
if (value === '') {
79+
label = '<empty string>'
80+
}
81+
return (shouldEmphasize ? <em>{label}</em> : label);
82+
}
83+
84+
return (
85+
<a className={lcAttributeItemStyle} onClick={props.onClick} data-active={props.active}>
86+
<span className={css`text-overflow:ellipsis; min-width:0; flex:1 1`}>{ formatLabel(props.label) }</span>
87+
<span className={css`flex-grow:0`}>{props.contingency.pinned && <Icon name={"crosshair"}/>}{props.contingency.count}</span>
88+
</a>
89+
)
90+
}
91+
92+
const lcSidebarStyle = css`
93+
width: 300px;
94+
min-width: 300px;
95+
flex-shrink: 0;
96+
overflow-y: scroll;
97+
padding-right: 1rem;
98+
`
99+
100+
type QueryBuilderProps = {
101+
builder: LuceneQueryBuilder,
102+
searchableFields: any[],
103+
updateQuery: (query: LuceneQuery) => void
104+
}
105+
106+
export function LogContextQueryBuilderSidebar(props: LogContextProps & QueryBuilderProps) {
107+
108+
const {row, builder, updateQuery, searchableFields} = props;
109+
const [fields, setFields] = useState<Field[]>([]);
110+
111+
const filteredFields = useMemo(() => {
112+
const searchableFieldsNames = searchableFields.map(f=>f.text);
113+
return row.dataFrame.fields
114+
.filter(f=>searchableFieldsNames.includes(f.name))
115+
// exclude some low-filterability fields
116+
.filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.type))
117+
// sort fields by name
118+
.sort((f1, f2)=> (f1.name>f2.name ? 1 : -1))
119+
}, [row, searchableFields]);
120+
121+
useEffect(() => {
122+
const fields = filteredFields
123+
.map((f) => {
124+
const contingency: FieldContingency = {};
125+
f.values.forEach((value, i) => {
126+
if (!contingency[value]) {
127+
contingency[value] = {
128+
count: 0,
129+
pinned: false,
130+
active: builder.parsedQuery ? !!builder.parsedQuery.findFilter(f.name, `${value}`) : false
131+
}
132+
}
133+
contingency[value].count += 1;
134+
if (i === row.rowIndex) {
135+
contingency[value].pinned = true;
136+
}
137+
});
138+
return { name: f.name, contingency };
139+
})
140+
141+
setFields(fields);
142+
}, [filteredFields, row.rowIndex, builder.parsedQuery]);
143+
144+
145+
const selectQueryFilter = (key: string, value: string): void => {
146+
// Compute mutation to apply to the query and send to parent
147+
// check if that filter is in the query
148+
if (!builder.parsedQuery) { return; }
149+
150+
const newParsedQuery = (
151+
builder.parsedQuery.hasFilter(key, value)
152+
? builder.parsedQuery.removeFilter(key, value)
153+
: builder.parsedQuery.addFilter(key, value)
154+
)
155+
156+
if (newParsedQuery) {
157+
updateQuery(newParsedQuery);
158+
}
159+
}
160+
161+
const renderFieldSection = (field: Field)=>{
162+
return (
163+
<CollapsableSection
164+
label={LogContextFieldSection(field)}
165+
className={css`& > div { flex-grow:1; }` }
166+
isOpen={false} key="log-attribute-field-{field.name}"
167+
contentClassName={cx(css`margin:0; padding:0`)}>
168+
<div className={css`display:flex; flex-direction:column; gap:5px`}>
169+
170+
{field.contingency && Object.entries(field.contingency)
171+
.sort(([na, ca], [nb, cb])=>(cb.count - ca.count))
172+
.map(([fieldValue, contingency], i) => (
173+
<LogContextFieldItem
174+
label={fieldValue} contingency={contingency} key={`field-opt${i}`}
175+
onClick={() => {selectQueryFilter(field.name, fieldValue)}}
176+
active={contingency.active}
177+
/>
178+
))
179+
}
180+
</div>
181+
</CollapsableSection>
182+
)
183+
}
184+
185+
return (
186+
<div className={lcSidebarStyle}>
187+
{fields && fields.map((field) => {
188+
return( renderFieldSection(field) );
189+
}) } </div>
190+
);
191+
}
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { useEffect, useState, useCallback, useMemo } from "react";
2+
import { LogRowModel } from '@grafana/data';
3+
import { ElasticsearchQuery as DataQuery } from '../../types';
4+
import { LuceneQueryEditor } from "../../components/LuceneQueryEditor";
5+
6+
import { css } from "@emotion/css";
7+
import { Button } from "@grafana/ui";
8+
import { useQueryBuilder } from '@/QueryBuilder/lucene';
9+
import { LogContextQueryBuilderSidebar } from "./LogContextQueryBuilderSidebar";
10+
import { DatasourceContext } from "components/QueryEditor/ElasticsearchQueryContext";
11+
import { QuickwitDataSource } from "datasource";
12+
import { useDatasourceFields } from "datasource.utils";
13+
14+
const logContextUiStyle = css`
15+
display: flex;
16+
gap: 1rem;
17+
width: 100%;
18+
height: 200px;
19+
`
20+
21+
export interface LogContextProps {
22+
row: LogRowModel,
23+
runContextQuery?: (() => void)
24+
origQuery?: DataQuery
25+
}
26+
export interface LogContextUIProps extends LogContextProps {
27+
datasource: QuickwitDataSource,
28+
updateQuery: (query: string) => void
29+
}
30+
31+
export function LogContextUI(props: LogContextUIProps ){
32+
const builder = useQueryBuilder();
33+
const {query, parsedQuery, setQuery, setParsedQuery} = builder;
34+
const [canRunQuery, setCanRunQuery] = useState<boolean>(false);
35+
const { origQuery, updateQuery, runContextQuery } = props;
36+
const {fields, getSuggestions} = useDatasourceFields(props.datasource);
37+
38+
useEffect(()=>{
39+
setQuery(origQuery?.query || '')
40+
}, [setQuery, origQuery])
41+
42+
useEffect(()=>{
43+
setCanRunQuery(!parsedQuery.parseError)
44+
}, [parsedQuery, setCanRunQuery])
45+
46+
const runQuery = useCallback(()=>{
47+
if (runContextQuery){
48+
updateQuery(query);
49+
runContextQuery();
50+
}
51+
}, [query, runContextQuery, updateQuery])
52+
53+
const ActionBar = useMemo(()=>(
54+
<div className={css`display:flex; justify-content:end; flex:0 0; gap:0.5rem;`}>
55+
<Button variant="secondary" onClick={()=>setQuery('')}>Clear</Button>
56+
<Button variant="secondary" onClick={()=>setQuery(origQuery?.query || '')}>Reset</Button>
57+
<Button onClick={runQuery} {...canRunQuery ? {} : {disabled:true, tooltip:"Failed to parse query"}} >Run query</Button>
58+
</div>
59+
), [setQuery, canRunQuery, origQuery, runQuery])
60+
61+
return (
62+
<div className={logContextUiStyle}>
63+
<DatasourceContext.Provider value={props.datasource}>
64+
<LogContextQueryBuilderSidebar {...props} builder={builder} updateQuery={setParsedQuery} searchableFields={fields}/>
65+
<div className={css`width:100%; display:flex; flex-direction:column; gap:0.5rem; min-width:0;`}>
66+
{ActionBar}
67+
<LuceneQueryEditor builder={builder} autocompleter={getSuggestions} onChange={builder.setQuery}/>
68+
</div>
69+
</DatasourceContext.Provider>
70+
</div>
71+
);
72+
}

0 commit comments

Comments
 (0)