Skip to content

Commit a358aba

Browse files
committed
fix(comms): refactor the wsLogaccess GetLogs filter creation
* refactored the WsLogaccessService filter creation * added basic tests and a mocked response fixture for the ELK stack Signed-off-by: Jeremy Clements <79224539+jeclrsg@users.noreply.github.com>
1 parent eab3a48 commit a358aba

File tree

3 files changed

+533
-155
lines changed

3 files changed

+533
-155
lines changed

packages/comms/src/services/wsLogaccess.ts

Lines changed: 155 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const enum TargetAudience {
4040
Audit = "ADT"
4141
}
4242

43-
//properties here are "LogType" values in Ws_logaccess.GetLogAccessInfo
43+
// properties here are "LogType" values in Ws_logaccess.GetLogAccessInfo
4444
export interface LogLine {
4545
audience?: string;
4646
class?: string;
@@ -59,6 +59,142 @@ export interface GetLogsExResponse {
5959
total: number,
6060
}
6161

62+
const knownLogManagerTypes = new Set(["azureloganalyticscurl", "elasticstack", "grafanacurl"]);
63+
const logColumnTypeValues = new Set(Object.values(WsLogaccess.LogColumnType));
64+
65+
function getLogCategory(searchField: string): WsLogaccess.LogAccessType {
66+
switch (searchField) {
67+
case WsLogaccess.LogColumnType.workunits:
68+
case "hpcc.log.jobid":
69+
return WsLogaccess.LogAccessType.ByJobID;
70+
case WsLogaccess.LogColumnType.audience:
71+
case "hpcc.log.audience":
72+
return WsLogaccess.LogAccessType.ByTargetAudience;
73+
case WsLogaccess.LogColumnType.class:
74+
case "hpcc.log.class":
75+
return WsLogaccess.LogAccessType.ByLogType;
76+
case WsLogaccess.LogColumnType.components:
77+
case "kubernetes.container.name":
78+
return WsLogaccess.LogAccessType.ByComponent;
79+
default:
80+
return WsLogaccess.LogAccessType.ByFieldName;
81+
}
82+
}
83+
84+
// Explicit list of filter-bearing keys on GetLogsExRequest.
85+
// Using an allowlist avoids accidentally treating control fields (StartDate, LogLineLimit, etc.)
86+
// as log filters if the server ever returns a column whose name collides with them.
87+
const FILTER_KEYS = ["audience", "class", "workunits", "message", "processid", "logid", "threadid", "timestamp", "components", "instance"];
88+
89+
function buildFilters(request: GetLogsExRequest, columnMap: Record<string, string>): WsLogaccess.leftFilter[] {
90+
const filters: WsLogaccess.leftFilter[] = [];
91+
for (const key of FILTER_KEYS) {
92+
const value = request[key];
93+
if (value == null || value === "" || (Array.isArray(value) && value.length === 0)) {
94+
continue;
95+
}
96+
if (!(key in columnMap)) continue;
97+
98+
const isKnownLogType = logColumnTypeValues.has(key as WsLogaccess.LogColumnType);
99+
let searchField: string = isKnownLogType ? key : columnMap[key];
100+
const logCategory = getLogCategory(searchField);
101+
if (logCategory === WsLogaccess.LogAccessType.ByFieldName) {
102+
searchField = columnMap[key];
103+
}
104+
105+
const appendWildcard = logCategory === WsLogaccess.LogAccessType.ByComponent;
106+
const rawValues: string[] = Array.isArray(value) ? value : [value as string];
107+
for (const raw of rawValues) {
108+
filters.push({
109+
LogCategory: logCategory,
110+
SearchField: searchField,
111+
// append wildcard to end of search value to include ephemeral
112+
// containers that aren't listed in ECL Watch's filters
113+
SearchByValue: appendWildcard ? raw + "*" : raw
114+
});
115+
}
116+
}
117+
return filters;
118+
}
119+
120+
// Builds a left-leaning OR chain from filters that share the same SearchField.
121+
function buildOrGroup(group: WsLogaccess.leftFilter[]): WsLogaccess.BinaryLogFilter {
122+
const root: WsLogaccess.BinaryLogFilter = { leftFilter: group[0] } as WsLogaccess.BinaryLogFilter;
123+
let node = root;
124+
for (let i = 1; i < group.length; i++) {
125+
node.Operator = WsLogaccess.LogAccessFilterOperator.OR;
126+
if (i === group.length - 1) {
127+
node.rightFilter = group[i] as WsLogaccess.rightFilter;
128+
} else {
129+
node.rightBinaryFilter = { BinaryLogFilter: [{ leftFilter: group[i] } as WsLogaccess.BinaryLogFilter] };
130+
node = node.rightBinaryFilter.BinaryLogFilter[0];
131+
}
132+
}
133+
return root;
134+
}
135+
136+
// Recursively AND-chains two or more groups into a BinaryLogFilter (used for nesting beyond depth 1).
137+
function buildAndChain(groups: WsLogaccess.leftFilter[][]): WsLogaccess.BinaryLogFilter {
138+
const [firstGroup, ...remainingGroups] = groups;
139+
const node: WsLogaccess.BinaryLogFilter = {} as WsLogaccess.BinaryLogFilter;
140+
if (firstGroup.length === 1) {
141+
node.leftFilter = firstGroup[0];
142+
} else {
143+
node.leftBinaryFilter = { BinaryLogFilter: [buildOrGroup(firstGroup)] };
144+
}
145+
if (remainingGroups.length === 0) return node;
146+
node.Operator = WsLogaccess.LogAccessFilterOperator.AND;
147+
if (remainingGroups.length === 1) {
148+
const [secondGroup] = remainingGroups;
149+
if (secondGroup.length === 1) {
150+
node.rightFilter = secondGroup[0] as WsLogaccess.rightFilter;
151+
} else {
152+
node.rightBinaryFilter = { BinaryLogFilter: [buildOrGroup(secondGroup)] };
153+
}
154+
} else {
155+
node.rightBinaryFilter = { BinaryLogFilter: [buildAndChain(remainingGroups)] };
156+
}
157+
return node;
158+
}
159+
160+
// Groups filters by SearchField, OR-chains each group, then AND-chains the groups together.
161+
// This ensures e.g. [class_INF, class_ERR, audience_USR] always produces
162+
// (class_INF OR class_ERR) AND audience_USR regardless of input order.
163+
function buildFilterTree(filters: WsLogaccess.leftFilter[]): WsLogaccess.Filter {
164+
const groupMap = new Map<string, WsLogaccess.leftFilter[]>();
165+
for (const f of filters) {
166+
const existing = groupMap.get(f.SearchField);
167+
if (existing) existing.push(f); else groupMap.set(f.SearchField, [f]);
168+
}
169+
const groups = [...groupMap.values()];
170+
171+
if (groups.length === 0) {
172+
return { leftFilter: { LogCategory: WsLogaccess.LogAccessType.All } as WsLogaccess.leftFilter };
173+
}
174+
175+
const [firstGroup, ...remainingGroups] = groups;
176+
const filter: WsLogaccess.Filter = {};
177+
if (firstGroup.length === 1) {
178+
filter.leftFilter = firstGroup[0];
179+
} else {
180+
filter.leftBinaryFilter = { BinaryLogFilter: [buildOrGroup(firstGroup)] };
181+
}
182+
183+
if (remainingGroups.length === 0) return filter;
184+
filter.Operator = WsLogaccess.LogAccessFilterOperator.AND;
185+
if (remainingGroups.length === 1) {
186+
const [secondGroup] = remainingGroups;
187+
if (secondGroup.length === 1) {
188+
filter.rightFilter = secondGroup[0] as WsLogaccess.rightFilter;
189+
} else {
190+
filter.rightBinaryFilter = { BinaryLogFilter: [buildOrGroup(secondGroup)] };
191+
}
192+
} else {
193+
filter.rightBinaryFilter = { BinaryLogFilter: [buildAndChain(remainingGroups)] };
194+
}
195+
return filter;
196+
}
197+
62198
export class LogaccessService extends LogaccessServiceBase {
63199

64200
protected _logAccessInfo: Promise<WsLogaccess.GetLogAccessInfoResponse>;
@@ -74,35 +210,27 @@ export class LogaccessService extends LogaccessServiceBase {
74210
return super.GetLogs(request);
75211
}
76212

213+
private convertLogLine(columnMap: Record<string, string>, line: any): LogLine {
214+
const retVal: LogLine = {};
215+
const fields = line?.fields ? Object.assign({}, ...line.fields) : null;
216+
for (const key in columnMap) {
217+
retVal[key] = fields ? fields[columnMap[key]] ?? "" : "";
218+
}
219+
return retVal;
220+
}
221+
77222
async GetLogsEx(request: GetLogsExRequest): Promise<GetLogsExResponse> {
78223
const logInfo = await this.GetLogAccessInfo();
79-
const columnMap = {};
224+
const columnMap: Record<string, string> = {};
80225
logInfo.Columns.Column.forEach(column => columnMap[column.LogType] = column.Name);
81226

82-
const convertLogLine = (line: any) => {
83-
const retVal: LogLine = {};
84-
for (const key in columnMap) {
85-
if (line?.fields) {
86-
retVal[key] = Object.assign({}, ...line.fields)[columnMap[key]] ?? "";
87-
} else {
88-
retVal[key] = "";
89-
}
90-
}
91-
return retVal;
92-
};
227+
const filters = buildFilters(request, columnMap);
93228

94229
const getLogsRequest: WsLogaccess.GetLogsRequest = {
95-
Filter: {
96-
leftBinaryFilter: {
97-
BinaryLogFilter: [{
98-
leftFilter: {
99-
LogCategory: WsLogaccess.LogAccessType.All,
100-
},
101-
} as WsLogaccess.BinaryLogFilter]
102-
}
103-
},
230+
Filter: buildFilterTree(filters),
104231
Range: {
105-
StartDate: new Date(0).toISOString(),
232+
StartDate: request.StartDate instanceof Date ? request.StartDate.toISOString() : new Date(0).toISOString(),
233+
EndDate: request.EndDate instanceof Date ? request.EndDate.toISOString() : undefined
106234
},
107235
LogLineStartFrom: request.LogLineStartFrom ?? 0,
108236
LogLineLimit: request.LogLineLimit ?? 100,
@@ -117,142 +245,14 @@ export class LogaccessService extends LogaccessServiceBase {
117245
}
118246
};
119247

120-
const filters: WsLogaccess.leftFilter[] = [];
121-
const logTypes = Object.values(WsLogaccess.LogColumnType);
122-
for (const key in request) {
123-
if (request[key] == null || request[key] === "" || (Array.isArray(request[key]) && request[key].length === 0)) {
124-
continue;
125-
}
126-
let searchField;
127-
if (key in columnMap) {
128-
if (logTypes.includes(key as WsLogaccess.LogColumnType)) {
129-
searchField = key;
130-
} else {
131-
searchField = columnMap[key];
132-
}
133-
}
134-
let logCategory;
135-
if (searchField) {
136-
switch (searchField) {
137-
case WsLogaccess.LogColumnType.workunits:
138-
case "hpcc.log.jobid":
139-
logCategory = WsLogaccess.LogAccessType.ByJobID;
140-
break;
141-
case WsLogaccess.LogColumnType.audience:
142-
case "hpcc.log.audience":
143-
logCategory = WsLogaccess.LogAccessType.ByTargetAudience;
144-
break;
145-
case WsLogaccess.LogColumnType.class:
146-
case "hpcc.log.class":
147-
logCategory = WsLogaccess.LogAccessType.ByLogType;
148-
break;
149-
case WsLogaccess.LogColumnType.components:
150-
case "kubernetes.container.name":
151-
logCategory = WsLogaccess.LogAccessType.ByComponent;
152-
break;
153-
default:
154-
logCategory = WsLogaccess.LogAccessType.ByFieldName;
155-
searchField = columnMap[key];
156-
}
157-
if (Array.isArray(request[key])) {
158-
request[key].forEach(value => {
159-
if (logCategory === WsLogaccess.LogAccessType.ByComponent) {
160-
value += "*";
161-
}
162-
filters.push({
163-
LogCategory: logCategory,
164-
SearchField: searchField,
165-
SearchByValue: value
166-
});
167-
});
168-
} else {
169-
let value = request[key];
170-
if (logCategory === WsLogaccess.LogAccessType.ByComponent) {
171-
// append wildcard to end of search value to include ephemeral
172-
// containers that aren't listed in ECL Watch's filters
173-
value += "*";
174-
}
175-
filters.push({
176-
LogCategory: logCategory,
177-
SearchField: searchField,
178-
SearchByValue: value
179-
});
180-
}
181-
}
182-
}
183-
184-
if (filters.length > 2) {
185-
let binaryLogFilter = getLogsRequest.Filter.leftBinaryFilter.BinaryLogFilter[0];
186-
filters.forEach((filter, i) => {
187-
let operator = WsLogaccess.LogAccessFilterOperator.AND;
188-
if (i > 0) {
189-
if (filters[i - 1].SearchField === filter.SearchField) {
190-
operator = WsLogaccess.LogAccessFilterOperator.OR;
191-
}
192-
if (i === filters.length - 1) {
193-
binaryLogFilter.Operator = operator;
194-
binaryLogFilter.rightFilter = filter as WsLogaccess.rightFilter;
195-
} else {
196-
binaryLogFilter.Operator = operator;
197-
binaryLogFilter.rightBinaryFilter = {
198-
BinaryLogFilter: [{
199-
leftFilter: filter
200-
} as WsLogaccess.BinaryLogFilter]
201-
};
202-
binaryLogFilter = binaryLogFilter.rightBinaryFilter.BinaryLogFilter[0];
203-
}
204-
} else {
205-
binaryLogFilter.leftFilter = filter as WsLogaccess.leftFilter;
206-
}
207-
});
208-
} else {
209-
delete getLogsRequest.Filter.leftBinaryFilter;
210-
getLogsRequest.Filter.leftFilter = {
211-
LogCategory: WsLogaccess.LogAccessType.All
212-
} as WsLogaccess.leftFilter;
213-
if (filters[0]?.SearchField) {
214-
getLogsRequest.Filter.leftFilter = {
215-
LogCategory: filters[0]?.LogCategory,
216-
SearchField: filters[0]?.SearchField,
217-
SearchByValue: filters[0]?.SearchByValue
218-
};
219-
}
220-
if (filters[1]?.SearchField) {
221-
getLogsRequest.Filter.Operator = WsLogaccess.LogAccessFilterOperator.AND;
222-
if (filters[0].SearchField === filters[1].SearchField) {
223-
getLogsRequest.Filter.Operator = WsLogaccess.LogAccessFilterOperator.OR;
224-
}
225-
getLogsRequest.Filter.rightFilter = {
226-
LogCategory: filters[1]?.LogCategory,
227-
SearchField: filters[1]?.SearchField,
228-
SearchByValue: filters[1]?.SearchByValue
229-
};
230-
}
231-
}
232-
233-
if (request.StartDate) {
234-
getLogsRequest.Range.StartDate = request.StartDate.toISOString();
235-
}
236-
if (request.EndDate) {
237-
getLogsRequest.Range.EndDate = request.EndDate.toISOString();
238-
}
239-
240248
return this.GetLogs(getLogsRequest).then(response => {
241249
try {
242250
const logLines = JSON.parse(response.LogLines);
243-
let lines = [];
244-
switch (logInfo.RemoteLogManagerType) {
245-
case "azureloganalyticscurl":
246-
case "elasticstack":
247-
case "grafanacurl":
248-
lines = logLines.lines?.map(convertLogLine) ?? [];
249-
break;
250-
default:
251-
logger.warning(`Unknown RemoteLogManagerType: ${logInfo.RemoteLogManagerType}`);
252-
lines = [];
253-
}
251+
const lines = knownLogManagerTypes.has(logInfo.RemoteLogManagerType)
252+
? (logLines.lines?.map((line: any) => this.convertLogLine(columnMap, line)) ?? [])
253+
: (logger.warning(`Unknown RemoteLogManagerType: ${logInfo.RemoteLogManagerType}`), []);
254254
return {
255-
lines: lines,
255+
lines,
256256
total: response.TotalLogLinesAvailable ?? 10000
257257
};
258258
} catch (e: any) {

0 commit comments

Comments
 (0)