import { scopedLogger } from "@hpcc-js/util"; import { LogaccessServiceBase, WsLogaccess } from "./wsdl/ws_logaccess/v1.08/ws_logaccess.ts"; const logger = scopedLogger("@hpcc-js/comms/services/wsLogaccess.ts"); export { WsLogaccess }; export interface GetLogsExRequest { audience?: string; class?: string[]; workunits?: string; message?: string; processid?: string; logid?: string; threadid?: string; timestamp?: string; components?: string; instance?: string; StartDate?: Date; EndDate?: Date; LogLineStartFrom: number, LogLineLimit: number } export const enum LogType { Disaster = "DIS", Error = "ERR", Warning = "WRN", Information = "INF", Progress = "PRO", Metric = "MET" } export const enum TargetAudience { Operator = "OPR", User = "USR", Programmer = "PRO", Audit = "ADT" } // properties here are "LogType" values in Ws_logaccess.GetLogAccessInfo export interface LogLine { audience?: string; class?: string; workunits?: string; message?: string; processid?: number; logid?: string; threadid?: number; timestamp?: string; components?: string; instance?: string; } export interface GetLogsExResponse { lines: LogLine[], total: number, } const knownLogManagerTypes = new Set(["azureloganalyticscurl", "elasticstack", "grafanacurl"]); const logColumnTypeValues = new Set(Object.values(WsLogaccess.LogColumnType)); function getLogCategory(searchField: string): WsLogaccess.LogAccessType { switch (searchField) { case WsLogaccess.LogColumnType.workunits: case "hpcc.log.jobid": return WsLogaccess.LogAccessType.ByJobID; case WsLogaccess.LogColumnType.audience: case "hpcc.log.audience": return WsLogaccess.LogAccessType.ByTargetAudience; case WsLogaccess.LogColumnType.class: case "hpcc.log.class": return WsLogaccess.LogAccessType.ByLogType; case WsLogaccess.LogColumnType.components: case "kubernetes.container.name": return WsLogaccess.LogAccessType.ByComponent; default: return WsLogaccess.LogAccessType.ByFieldName; } } // Explicit list of filter-bearing keys on GetLogsExRequest. // Using an allowlist avoids accidentally treating control fields (StartDate, LogLineLimit, etc.) // as log filters if the server ever returns a column whose name collides with them. const FILTER_KEYS = ["audience", "class", "workunits", "message", "processid", "logid", "threadid", "timestamp", "components", "instance"] as const; function buildFilters(request: GetLogsExRequest, columnMap: Record): WsLogaccess.leftFilter[] { const filters: WsLogaccess.leftFilter[] = []; for (const key of FILTER_KEYS) { const value = request[key]; if (value == null || value === "" || (Array.isArray(value) && value.length === 0)) { continue; } if (!(key in columnMap)) continue; const isKnownLogType = logColumnTypeValues.has(key as WsLogaccess.LogColumnType); let searchField: string = isKnownLogType ? key : columnMap[key]; const logCategory = getLogCategory(searchField); if (logCategory === WsLogaccess.LogAccessType.ByFieldName) { searchField = columnMap[key]; } const appendWildcard = logCategory === WsLogaccess.LogAccessType.ByComponent; const rawValues: string[] = Array.isArray(value) ? value : [value as string]; for (const raw of rawValues) { filters.push({ LogCategory: logCategory, SearchField: searchField, // append wildcard to end of search value to include ephemeral // containers that aren't listed in ECL Watch's filters SearchByValue: appendWildcard ? raw + "*" : raw }); } } return filters; } // Builds a left-leaning OR chain from filters that share the same SearchField. function buildOrGroup(group: WsLogaccess.leftFilter[]): WsLogaccess.BinaryLogFilter { const root: WsLogaccess.BinaryLogFilter = { leftFilter: group[0] } as WsLogaccess.BinaryLogFilter; let node = root; for (let i = 1; i < group.length; i++) { node.Operator = WsLogaccess.LogAccessFilterOperator.OR; if (i === group.length - 1) { node.rightFilter = group[i] as WsLogaccess.rightFilter; } else { node.rightBinaryFilter = { BinaryLogFilter: [{ leftFilter: group[i] } as WsLogaccess.BinaryLogFilter] }; node = node.rightBinaryFilter.BinaryLogFilter[0]; } } return root; } // Recursively AND-chains two or more groups into a BinaryLogFilter (used for nesting beyond depth 1). function buildAndChain(groups: WsLogaccess.leftFilter[][]): WsLogaccess.BinaryLogFilter { const [firstGroup, ...remainingGroups] = groups; const node: WsLogaccess.BinaryLogFilter = {} as WsLogaccess.BinaryLogFilter; if (firstGroup.length === 1) { node.leftFilter = firstGroup[0]; } else { node.leftBinaryFilter = { BinaryLogFilter: [buildOrGroup(firstGroup)] }; } if (remainingGroups.length === 0) return node; node.Operator = WsLogaccess.LogAccessFilterOperator.AND; if (remainingGroups.length === 1) { const [secondGroup] = remainingGroups; if (secondGroup.length === 1) { node.rightFilter = secondGroup[0] as WsLogaccess.rightFilter; } else { node.rightBinaryFilter = { BinaryLogFilter: [buildOrGroup(secondGroup)] }; } } else { node.rightBinaryFilter = { BinaryLogFilter: [buildAndChain(remainingGroups)] }; } return node; } // Groups filters by SearchField, OR-chains each group, then AND-chains the groups together. // This ensures e.g. [class_INF, class_ERR, audience_USR] always produces // (class_INF OR class_ERR) AND audience_USR regardless of input order. function buildFilterTree(filters: WsLogaccess.leftFilter[]): WsLogaccess.Filter { const groupMap = new Map(); for (const f of filters) { const existing = groupMap.get(f.SearchField); if (existing) existing.push(f); else groupMap.set(f.SearchField, [f]); } const groups = [...groupMap.values()]; if (groups.length === 0) { return { leftFilter: { LogCategory: WsLogaccess.LogAccessType.All } as WsLogaccess.leftFilter }; } const [firstGroup, ...remainingGroups] = groups; const filter: WsLogaccess.Filter = {}; if (firstGroup.length === 1) { filter.leftFilter = firstGroup[0]; } else { filter.leftBinaryFilter = { BinaryLogFilter: [buildOrGroup(firstGroup)] }; } if (remainingGroups.length === 0) return filter; filter.Operator = WsLogaccess.LogAccessFilterOperator.AND; if (remainingGroups.length === 1) { const [secondGroup] = remainingGroups; if (secondGroup.length === 1) { filter.rightFilter = secondGroup[0] as WsLogaccess.rightFilter; } else { filter.rightBinaryFilter = { BinaryLogFilter: [buildOrGroup(secondGroup)] }; } } else { filter.rightBinaryFilter = { BinaryLogFilter: [buildAndChain(remainingGroups)] }; } return filter; } export class LogaccessService extends LogaccessServiceBase { protected _logAccessInfo: Promise; GetLogAccessInfo(request: WsLogaccess.GetLogAccessInfoRequest = {}): Promise { if (!this._logAccessInfo) { this._logAccessInfo = super.GetLogAccessInfo(request); } return this._logAccessInfo; } GetLogs(request: WsLogaccess.GetLogsRequest): Promise { return super.GetLogs(request); } private convertLogLine(columnMap: Record, line: any): LogLine { const retVal: LogLine = {}; const fields = line?.fields ? Object.assign({}, ...line.fields) : null; for (const key in columnMap) { retVal[key] = fields ? fields[columnMap[key]] ?? "" : ""; } return retVal; } async GetLogsEx(request: GetLogsExRequest): Promise { const logInfo = await this.GetLogAccessInfo(); const columnMap: Record = {}; logInfo.Columns.Column.forEach(column => columnMap[column.LogType] = column.Name); const filters = buildFilters(request, columnMap); const range: Record = { StartDate: request.StartDate instanceof Date ? request.StartDate.toISOString() : new Date(0).toISOString() }; if (request.EndDate instanceof Date) { range.EndDate = request.EndDate.toISOString(); } const getLogsRequest: WsLogaccess.GetLogsRequest = { Filter: buildFilterTree(filters), Range: range, LogLineStartFrom: request.LogLineStartFrom ?? 0, LogLineLimit: request.LogLineLimit ?? 100, SelectColumnMode: WsLogaccess.LogSelectColumnMode.DEFAULT, Format: "JSON", SortBy: { SortCondition: [{ BySortType: WsLogaccess.SortColumType.ByDate, ColumnName: "", Direction: 0 }] } }; return this.GetLogs(getLogsRequest).then(response => { try { const logLines = JSON.parse(response.LogLines); const lines = knownLogManagerTypes.has(logInfo.RemoteLogManagerType) ? (logLines.lines?.map((line: any) => this.convertLogLine(columnMap, line)) ?? []) : (logger.warning(`Unknown RemoteLogManagerType: ${logInfo.RemoteLogManagerType}`), []); return { lines, total: response.TotalLogLinesAvailable ?? 10000 }; } catch (e: any) { logger.error(e.message ?? e); } return { lines: [], total: 0 }; }); } }