diff --git a/web/src/__tests__/otellog.spec.ts b/web/src/__tests__/otellog.spec.ts new file mode 100644 index 0000000..a55f597 --- /dev/null +++ b/web/src/__tests__/otellog.spec.ts @@ -0,0 +1,115 @@ +import { OtellogDomain } from '../korrel8r/otellog'; +import { Constraint, Query, URIRef } from '../korrel8r/types'; + +describe('OtellogDomain.fromURL', () => { + it.each([ + { + url: `monitoring/logs?q=${encodeURIComponent( + '{kubernetes_namespace_name="default",kubernetes_pod_name="foo"}', + )}&tenant=infrastructure`, + query: + `otellog:infrastructure:{kubernetes_namespace_name="default"` + + `,kubernetes_pod_name="foo"}`, + }, + { + url: `monitoring/logs?q=${encodeURIComponent( + '{kubernetes_namespace_name="default",' + + 'kubernetes_pod_name="foo",log_type="infrastructure"}', + )}`, + query: + `otellog:infrastructure:{kubernetes_namespace_name="default",` + + `kubernetes_pod_name="foo",log_type="infrastructure"}`, + }, + { + url: `monitoring/logs?q=${encodeURIComponent( + '{kubernetes_namespace_name="default",kubernetes_pod_name="foo"}', + )}&tenant=infrastructure`, + query: + `otellog:infrastructure:{kubernetes_namespace_name="default",` + + `kubernetes_pod_name="foo"}`, + }, + { + url: `monitoring/logs?q=${encodeURIComponent( + '{kubernetes_namespace_name="default",kubernetes_pod_name="foo",log_type="infrastructure"}', + )}&tenant=infrastructure`, + query: + `otellog:infrastructure:{kubernetes_namespace_name="default",` + + `kubernetes_pod_name="foo",log_type="infrastructure"}`, + }, + { + url: `/k8s/ns/foo/pods/bar/aggregated-logs`, + query: `otellog:application:{kubernetes_namespace_name="foo",kubernetes_pod_name="bar"}`, + }, + { + url: `/k8s/ns/kube/pods/bar/aggregated-logs`, + query: `otellog:infrastructure:{kubernetes_namespace_name="kube",kubernetes_pod_name="bar"}`, + }, + { + url: '/monitoring/logs?q=%7Bkubernetes_namespace_name%3D%22openshift-image-registry%22%7D%7Cjson%7Ckubernetes_labels_docker_registry%3D%22default%22&tenant=infrastructure', + query: + 'otellog:infrastructure:{kubernetes_namespace_name="openshift-image-registry"}|json|kubernetes_labels_docker_registry="default"', + }, + ])('$url', ({ url, query }) => + expect(new OtellogDomain().linkToQuery(new URIRef(url))).toEqual(Query.parse(query)), + ); +}); + +describe('OtellogDomain.fromQuery', () => { + it.each([ + { + url: `monitoring/logs?q=${encodeURIComponent( + '{kubernetes_namespace_name="default",kubernetes_pod_name="foo"}', + )}&tenant=infrastructure&start=1742896800000&end=1742940000000`, + // eslint-disable-next-line max-len + query: `otellog:infrastructure:{kubernetes_namespace_name="default",kubernetes_pod_name="foo"}`, + constraint: Constraint.fromAPI({ + start: '2025-03-25T10:00:00.000Z', + end: '2025-03-25T22:00:00.000Z', + }), + }, + { + url: `monitoring/logs?q=${encodeURIComponent( + '{kubernetes_namespace_name="default",log_type="infrastructure"}', + )}&tenant=infrastructure&start=1742896800000&end=1742940000000`, + query: + 'otellog:infrastructure:{kubernetes_namespace_name="default",log_type="infrastructure"}', + constraint: Constraint.fromAPI({ + start: '2025-03-25T10:00:00.000Z', + end: '2025-03-25T22:00:00.000Z', + }), + }, + ])('$query', ({ url, query, constraint }) => + expect(new OtellogDomain().queryToLink(Query.parse(query), constraint)).toEqual( + new URIRef(url), + ), + ); +}); + +describe('expected errors', () => { + it.each([ + { + url: 'monitoring/log', + expected: 'domain otellog: invalid link: monitoring/log', + }, + { + url: 'monitoring/logs?q={kubernetes_namespace_name="default",kubernetes_pod_name="foo"}', + expected: + 'domain otellog: invalid link: monitoring/logs?q=%7Bkubernetes_namespace_name%3D%22default%22%2Ckubernetes_pod_name%3D%22foo%22%7D', + }, + ])('error from url: $url', ({ url, expected }) => { + expect(() => new OtellogDomain().linkToQuery(new URIRef(url))).toThrow(expected); + }); + + it.each([ + { + query: 'foo:bar:baz', + expected: 'domain otellog: invalid query, unknown class: foo:bar:baz', + }, + { + query: 'log:incorrect:{}', + expected: 'domain otellog: invalid query, unknown class: log:incorrect:{}', + }, + ])('error from query: $query', ({ query, expected }) => { + expect(() => new OtellogDomain().queryToLink(Query.parse(query))).toThrow(expected); + }); +}); diff --git a/web/src/korrel8r/all-domains.ts b/web/src/korrel8r/all-domains.ts index d20ff99..f5cce9d 100644 --- a/web/src/korrel8r/all-domains.ts +++ b/web/src/korrel8r/all-domains.ts @@ -1,6 +1,7 @@ import { AlertDomain } from './alert'; import { K8sDomain } from './k8s'; import { LogDomain } from './log'; +import { OtellogDomain } from './otellog'; import { MetricDomain } from './metric'; import { NetflowDomain } from './netflow'; import { TraceDomain } from './trace'; @@ -10,6 +11,7 @@ export const allDomains = [ new AlertDomain(), new K8sDomain(), new LogDomain(), + new OtellogDomain(), new MetricDomain(), new NetflowDomain(), new TraceDomain(), diff --git a/web/src/korrel8r/otellog.ts b/web/src/korrel8r/otellog.ts new file mode 100644 index 0000000..94ed45f --- /dev/null +++ b/web/src/korrel8r/otellog.ts @@ -0,0 +1,52 @@ +import { Class, Constraint, Domain, Query, unixMilliseconds, URIRef } from './types'; + +enum LogClass { + application = 'application', + infrastructure = 'infrastructure', + audit = 'audit', +} + +export class OtellogDomain extends Domain { + constructor() { + super('otellog'); + } + + class(name: string): Class { + if (!LogClass[name]) throw this.badClass(name); + return new Class(this.name, name); + } + + // There are 2 types of URL: pod logs, and log search. + linkToQuery(link: URIRef): Query { + // First check for aggregated pod logs URL + const [, namespace, name] = + link.pathname.match(/k8s\/ns\/([^/]+)\/pods\/([^/]+)\/aggregated-logs/) || []; + if (namespace && name) { + const logClass = namespace.match(/^kube|^openshift-/) + ? LogClass.infrastructure + : LogClass.application; + return new Query( + this.class(logClass), + `{kubernetes_namespace_name="${namespace}",kubernetes_pod_name="${name}"}`, + ); + } + // Assume this is a search URL + const logQL = link.searchParams.get('q'); + const logClassStr = + link.searchParams.get('tenant') || logQL?.match(/{[^}]*log_type(?:=~?)"([^"]+)"/)?.at(1); + const logClass = LogClass[logClassStr as keyof typeof LogClass]; + if (!logClass) throw this.badLink(link); + return this.class(logClass).query(logQL); + } + + queryToLink(query: Query, constraint?: Constraint): URIRef { + const logClass = LogClass[query.class.name as keyof typeof LogClass]; + if (!logClass) throw this.badQuery(query, 'unknown class'); + return new URIRef('monitoring/logs', { + q: query.selector, + tenant: logClass, + start: unixMilliseconds(constraint?.start), + end: unixMilliseconds(constraint?.end), + }); + } +}