Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/common/k8s-api/endpoints/metrics.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export interface MetricData {
};
}

export type MetricsErrorReason = "not-found" | "access-denied" | "error";

export interface MetricsErrorInfo {
reason: MetricsErrorReason;
message: string;
status?: number;
}

export interface MetricResult {
metric: {
[name: string]: string | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type { ClusterPrometheusPreferences } from "../../../common/cluster-types
import type { GetPrometheusProviderByKind } from "../../prometheus/get-by-kind.injectable";
import type { LoadProxyKubeconfig } from "../load-proxy-kubeconfig.injectable";

export const NO_PROMETHEUS_SERVICE_FOUND_MESSAGE = "No Prometheus service found";

export interface PrometheusDetails {
prometheusPath: string;
provider: PrometheusProvider;
Expand Down Expand Up @@ -112,7 +114,7 @@ export const createClusterPrometheusHandler = (...args: [Dependencies, Cluster])
}
}

throw new Error("No Prometheus service found", { cause: errors });
throw new Error(NO_PROMETHEUS_SERVICE_FOUND_MESSAGE, { cause: errors });
};

const getPrometheusDetails: ClusterPrometheusHandler["getPrometheusDetails"] = async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Copyright (c) Freelens Authors. All rights reserved.
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/

import asyncFn from "@async-fn/vitest";
import { loggerInjectionToken } from "@freelensapp/logger";
import { NO_PROMETHEUS_SERVICE_FOUND_MESSAGE } from "../../cluster/prometheus-handler/prometheus-handler";
import prometheusHandlerInjectable from "../../cluster/prometheus-handler/prometheus-handler.injectable";
import getMetricsInjectable from "../../get-metrics.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import addMetricsRouteInjectable from "./add-metrics-route.injectable";

import type { Logger } from "@freelensapp/logger";

import type { AsyncFnMock } from "@async-fn/vitest";
import type { DiContainer } from "@ogre-tools/injectable";
import type { Mocked } from "vitest";

import type { Cluster } from "../../../common/cluster/cluster";
import type { PrometheusDetails } from "../../cluster/prometheus-handler/prometheus-handler";
import type { GetMetrics } from "../../get-metrics.injectable";

class ApiExceptionStub extends Error {
constructor(
public code: number,
message: string,
) {
super(message);
}
}

describe("add-metrics-route", () => {
let di: DiContainer;
let clusterStub: Cluster;
let loggerMock: Mocked<Logger>;
let getPrometheusDetailsMock: AsyncFnMock<() => Promise<PrometheusDetails>>;
let getMetricsMock: AsyncFnMock<GetMetrics>;
let callRoute: (payload: unknown) => Promise<unknown>;

beforeEach(() => {
di = getDiForUnitTesting();

loggerMock = {
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
silly: vi.fn(),
};
di.override(loggerInjectionToken, () => loggerMock);

getPrometheusDetailsMock = asyncFn();
di.override(prometheusHandlerInjectable, () => ({
setupPrometheus: () => {},
getPrometheusDetails: getPrometheusDetailsMock,
}));

getMetricsMock = asyncFn();
di.override(getMetricsInjectable, () => getMetricsMock);

clusterStub = {
id: "some-cluster-id",
preferences: {},
metadata: {},
} as unknown as Cluster;

const route = di.inject(addMetricsRouteInjectable);

callRoute = (payload: unknown) =>
Promise.resolve(
(route.handler as (request: unknown) => unknown)({
cluster: clusterStub,
params: {},
path: route.path,
payload,
query: new URLSearchParams(),
raw: { req: {}, res: {} },
}),
);
});

it("logs the classification reason and the serialized cause when Prometheus detection fails", async () => {
const resultPromise = callRoute({});

await getPrometheusDetailsMock.reject(
new Error(NO_PROMETHEUS_SERVICE_FOUND_MESSAGE, {
cause: [
new Error('Failed to find Prometheus provider for "lens"', { cause: new ApiExceptionStub(403, "Forbidden") }),
new Error('Failed to find Prometheus provider for "helm"', { cause: new ApiExceptionStub(500, "boom") }),
],
}),
);

await resultPromise;

expect(loggerMock.warn).toHaveBeenCalledTimes(1);

const [message, meta] = loggerMock.warn.mock.calls[0] as [string, Record<string, unknown>];

expect(message).toBe(
`[METRICS-ROUTE]: failed to get metrics for clusterId=some-cluster-id: ${new Error(NO_PROMETHEUS_SERVICE_FOUND_MESSAGE)}`,
);
expect(meta.reason).toBe("not-found");
expect(meta.cause).not.toBeInstanceOf(Error);
expect(meta.cause).toMatchObject({
message: NO_PROMETHEUS_SERVICE_FOUND_MESSAGE,
cause: [
{ message: 'Failed to find Prometheus provider for "lens"', cause: { message: "Forbidden" } },
{ message: 'Failed to find Prometheus provider for "helm"', cause: { message: "boom" } },
],
});
});

it("returns a 503 with a not-found error when Prometheus detection fails", async () => {
const resultPromise = callRoute({});

await getPrometheusDetailsMock.reject(
new Error(NO_PROMETHEUS_SERVICE_FOUND_MESSAGE, {
cause: [
new Error('Failed to find Prometheus provider for "lens"', { cause: new ApiExceptionStub(500, "boom") }),
],
}),
);

await expect(resultPromise).resolves.toEqual({
statusCode: 503,
error: { reason: "not-found", message: NO_PROMETHEUS_SERVICE_FOUND_MESSAGE },
});
});

it("returns a 403 with an access-denied error when detection fails due to authorization", async () => {
const resultPromise = callRoute({});

await getPrometheusDetailsMock.reject(
new Error(NO_PROMETHEUS_SERVICE_FOUND_MESSAGE, {
cause: [
new Error('Failed to find Prometheus provider for "lens"', { cause: new ApiExceptionStub(403, "Forbidden") }),
],
}),
);

await expect(resultPromise).resolves.toEqual({
statusCode: 403,
error: { reason: "access-denied", message: NO_PROMETHEUS_SERVICE_FOUND_MESSAGE, status: 403 },
});
});

it("returns a 503 with a not-found error when no Prometheus service could be located", async () => {
const resultPromise = callRoute({});

await getPrometheusDetailsMock.resolve({ prometheusPath: "", provider: undefined as never });

await expect(resultPromise).resolves.toEqual({
statusCode: 503,
error: { reason: "not-found", message: NO_PROMETHEUS_SERVICE_FOUND_MESSAGE },
});
});

it("returns the response unchanged on success", async () => {
const resultPromise = callRoute("up");

await getPrometheusDetailsMock.resolve({ prometheusPath: "/api/v1/prometheus", provider: undefined as never });
await getMetricsMock.resolve({ status: "success", data: { resultType: "vector", result: [] } });

await expect(resultPromise).resolves.toEqual({
response: { status: "success", data: { resultType: "vector", result: [] } },
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,28 @@ import { isObject } from "es-toolkit/compat";
import { runInAction } from "mobx";
import { ClusterMetadataKey, initialFilesystemMountpoints } from "../../../common/cluster-types";
import { apiPrefix } from "../../../common/vars";
import { NO_PROMETHEUS_SERVICE_FOUND_MESSAGE } from "../../cluster/prometheus-handler/prometheus-handler";
import prometheusHandlerInjectable from "../../cluster/prometheus-handler/prometheus-handler.injectable";
import getMetricsInjectable from "../../get-metrics.injectable";
import { clusterRoute } from "../../router/route";
import { getRouteInjectable } from "../../router/router.injectable";
import {
classifyMetricsRouteError,
describeError,
METRICS_NOT_AVAILABLE_MESSAGE,
serializeErrorForLogging,
statusCodeFor,
} from "./metrics-error-classification";

import type { Cluster } from "../../../common/cluster/cluster";
import type { ClusterPrometheusMetadata } from "../../../common/cluster-types";
import type { MetricsErrorInfo } from "../../../common/k8s-api/endpoints/metrics.api";
import type { GetMetrics } from "../../get-metrics.injectable";
import type { MetricsErrorDescription } from "./metrics-error-classification";

// This is used for backoff retry tracking.
const ATTEMPTS = [false, false, false, false, true];

interface MetricsErrorDescription {
query: string;
status?: number;
response?: string;
message?: string;
}

async function describeError(query: string, error: unknown): Promise<MetricsErrorDescription> {
if (!(error instanceof Error)) {
return { query, message: String(error) };
}

const cause = error.cause as any;

// Duck-type check for a Response
const looksLikeResponse = cause && typeof cause.text === "function" && typeof cause.status === "number";

if (looksLikeResponse) {
try {
const bodyText = await cause.text();

if (bodyText) {
return {
query,
status: cause.status,
response: bodyText.trim(),
};
}
} catch {
// body already consumed or unreadable — fall through
}
}

return { query, message: error.message };
}

const loadMetricsFor =
(getMetrics: GetMetrics) =>
async (
Expand Down Expand Up @@ -85,7 +59,7 @@ const loadMetricsFor =
error.statusCode < 500)
) {
const description = await describeError(query, error);
throw new Error("Metrics not available", { cause: description });
throw new Error(METRICS_NOT_AVAILABLE_MESSAGE, { cause: description });
}

await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 1000)); // add delay before repeating request
Expand Down Expand Up @@ -125,7 +99,10 @@ const addMetricsRouteInjectable = getRouteInjectable({
if (!prometheusPath) {
prometheusMetadata.success = false;

return { response: {} };
return {
statusCode: 503,
error: { reason: "not-found", message: NO_PROMETHEUS_SERVICE_FOUND_MESSAGE } satisfies MetricsErrorInfo,
};
}

// return data in same structure as query
Expand Down Expand Up @@ -158,15 +135,21 @@ const addMetricsRouteInjectable = getRouteInjectable({
return { response: {} };
} catch (error) {
prometheusMetadata.success = false;
const description = error instanceof Error ? (error.cause as MetricsErrorDescription | undefined) : undefined;

if (description?.status === 422) {
const info = classifyMetricsRouteError(error);

if (info.status === 422) {
const description = error instanceof Error ? (error.cause as MetricsErrorDescription | undefined) : undefined;

logger.warn(`[METRICS-ROUTE]: query failed for clusterId=${cluster.id}`, description);
} else {
logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}: ${error}`);
logger.warn(`[METRICS-ROUTE]: failed to get metrics for clusterId=${cluster.id}: ${error}`, {
reason: info.reason,
cause: serializeErrorForLogging(error),
});
}

return { response: {} };
return { statusCode: statusCodeFor(info), error: info };
} finally {
runInAction(() => {
cluster.metadata[ClusterMetadataKey.PROMETHEUS] = prometheusMetadata;
Expand Down
Loading
Loading