Backstage Plugin
A Backstage plugin that puts LevelFour cost data in your developer portal. You build both halves:
- Backend plugin - Express router that wraps
LevelFourClientfrom thelevelfournpm package - Frontend plugin - React component that fetches data from the backend and renders it
The backend uses Backstage's new backend system (createBackendPlugin). The frontend reaches it through createApiFactory and discoveryApi.
Backend plugin
Add your API key to the app config
Add your LevelFour API key to app-config.yaml. Create one if you do not have a key yet.
levelfour:
apiKey: ${LEVELFOUR_API_KEY}read key is enough. Key scopes has the difference.Register the router with core services
The plugin entry point registers the HTTP router with Backstage's core services:
import {
createBackendPlugin,
coreServices,
} from "@backstage/backend-plugin-api";
import { createRouter } from "./router";
export const levelfourPlugin = createBackendPlugin({
pluginId: "levelfour",
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
config: coreServices.rootConfig,
},
async init({ httpRouter, logger, config }) {
const router = await createRouter({ logger, config });
httpRouter.use(router);
},
});
},
});Wrap the SDK in an Express router
The router reads the API key from config, creates a LevelFourClient, and exposes endpoints at /api/levelfour/*:
import { Router } from "express";
import { LevelFourClient, LevelFourError } from "levelfour";
import type { LoggerService } from "@backstage/backend-plugin-api";
import type { Config } from "@backstage/config";
interface RouterOptions {
logger: LoggerService;
config: Config;
}
export async function createRouter(options: RouterOptions): Promise<Router> {
const { logger, config } = options;
const router = Router();
const apiKey = config.getOptionalString("levelfour.apiKey");
if (!apiKey) {
logger.warn("levelfour.apiKey not configured in app-config.yaml");
}
const client = new LevelFourClient({ apiKey });
router.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
router.get("/recommendations/summary", async (_req, res) => {
try {
const data = await client.recommendations.getSavingsByProvider();
res.json(data);
} catch (err) {
if (err instanceof LevelFourError) {
logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`);
res.status(err.statusCode ?? 500).json({ error: err.message });
return;
}
throw err;
}
});
router.get("/costs/summary", async (_req, res) => {
try {
const data = await client.costs.getSummary();
res.json(data);
} catch (err) {
if (err instanceof LevelFourError) {
logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`);
res.status(err.statusCode ?? 500).json({ error: err.message });
return;
}
throw err;
}
});
router.get("/recommendations/overview", async (_req, res) => {
try {
const data = await client.recommendations.getOverview();
res.json(data);
} catch (err) {
if (err instanceof LevelFourError) {
logger.error(`LevelFour API error: ${err.statusCode} ${err.message}`);
res.status(err.statusCode ?? 500).json({ error: err.message });
return;
}
throw err;
}
});
return router;
}levelfour.apiKey logs a warning and the plugin starts anyway. The router builds the client without an explicit key, so nothing fails at startup and any gap shows up later, on a request that reaches the LevelFour API.Declare the backend dependencies
{
"dependencies": {
"@backstage/backend-plugin-api": "^1.3.0",
"express": "^4.21.0",
"levelfour": "^0.1.0"
}
}Add the plugin to your backend
In your Backstage backend's src/index.ts:
backend.add(import("@internal/plugin-levelfour-backend"));Frontend plugin
Define the API client
The frontend talks to the backend plugin through a typed client:
import {
createApiRef,
type DiscoveryApi,
type FetchApi,
} from "@backstage/core-plugin-api";
export interface LevelFourApi {
getRecommendationsSummary(): Promise<unknown>;
getCostsSummary(): Promise<unknown>;
getRecommendationsOverview(): Promise<unknown>;
}
export const levelFourApiRef = createApiRef<LevelFourApi>({
id: "plugin.levelfour.service",
});
export class LevelFourApiClient implements LevelFourApi {
private readonly discoveryApi: DiscoveryApi;
private readonly fetchApi: FetchApi;
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) {
this.discoveryApi = options.discoveryApi;
this.fetchApi = options.fetchApi;
}
private async fetch(path: string): Promise<unknown> {
const baseUrl = await this.discoveryApi.getBaseUrl("levelfour");
const response = await this.fetchApi.fetch(`${baseUrl}${path}`);
if (!response.ok) {
throw new Error(`LevelFour API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async getRecommendationsSummary(): Promise<unknown> {
return this.fetch("/recommendations/summary");
}
async getCostsSummary(): Promise<unknown> {
return this.fetch("/costs/summary");
}
async getRecommendationsOverview(): Promise<unknown> {
return this.fetch("/recommendations/overview");
}
}getBaseUrl("levelfour") has to match the backend plugin's pluginId, which is what puts the routes at /api/levelfour/*. Change one without the other and the dashboard fetches a URL that looks right and gets a 404 back.Create the plugin and its page
Register the API factory and routable extension:
import {
createPlugin,
createApiFactory,
createRoutableExtension,
discoveryApiRef,
fetchApiRef,
} from "@backstage/core-plugin-api";
import { levelFourApiRef, LevelFourApiClient } from "./api";
import { rootRouteRef } from "./routes";
export const levelfourPlugin = createPlugin({
id: "levelfour",
apis: [
createApiFactory({
api: levelFourApiRef,
deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
factory: ({ discoveryApi, fetchApi }) =>
new LevelFourApiClient({ discoveryApi, fetchApi }),
}),
],
routes: {
root: rootRouteRef,
},
});
export const LevelFourPage = levelfourPlugin.provide(
createRoutableExtension({
name: "LevelFourPage",
component: () =>
import("./components/CostDashboard").then((m) => m.CostDashboard),
mountPoint: rootRouteRef,
}),
);Render the dashboard
The dashboard component fetches recommendations and cost data in parallel:
import React, { useEffect, useState } from "react";
import { useApi } from "@backstage/core-plugin-api";
import {
Header,
Page,
Content,
InfoCard,
Progress,
} from "@backstage/core-components";
import { levelFourApiRef } from "../api";
export const CostDashboard = () => {
const api = useApi(levelFourApiRef);
const [recommendations, setRecommendations] = useState<unknown>(null);
const [costs, setCosts] = useState<unknown>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([
api.getRecommendationsSummary(),
api.getCostsSummary(),
])
.then(([recs, costs]) => {
setRecommendations(recs);
setCosts(costs);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [api]);
if (loading) return <Progress />;
if (error) {
return (
<Page themeId="tool">
<Header title="Cloud Cost Optimization" />
<Content>
<InfoCard title="Error">{error}</InfoCard>
</Content>
</Page>
);
}
return (
<Page themeId="tool">
<Header title="Cloud Cost Optimization" subtitle="Powered by LevelFour" />
<Content>
<InfoCard title="Savings by Provider">
<pre>{JSON.stringify(recommendations, null, 2)}</pre>
</InfoCard>
<InfoCard title="Cost Summary">
<pre>{JSON.stringify(costs, null, 2)}</pre>
</InfoCard>
</Content>
</Page>
);
};Add the route to your app
In your Backstage app's App.tsx, add the route:
import { LevelFourPage } from "@internal/plugin-levelfour";
<Route path="/levelfour" element={<LevelFourPage />} />Verify it works
With both plugins registered, check the backend first:
curl http://localhost:7007/api/levelfour/health
curl http://localhost:7007/api/levelfour/recommendations/summary
curl http://localhost:7007/api/levelfour/costs/summary/health answering { "status": "ok" } means the router is mounted. The other two go out to the LevelFour API, so JSON back from /recommendations/summary is the whole path working, config through SDK.{ "error": ... } is the LevelFour API's own failure rather than a Backstage one. The router forwards it with res.status(err.statusCode ?? 500), so you get the upstream status, or a 500 when the error carries none. On a 4xx, check the key and the account. Error handling has the class behind it.Then open http://localhost:3000/levelfour to see the cost dashboard.
Next
- TypeScript SDK is the
levelfourpackage the router imports: installing it, constructing the client and the constructor options - Resources has every method the router calls, with its parameters and response shapes
- Authentication is where the key in
app-config.yamlcomes from, and what areadscope covers - Error handling is the hierarchy behind
LevelFourErrorand itsstatusCode - Backstage Cost Insights plugin follows the same patterns. This one calls the LevelFour API through the
levelfournpm package.
Integrate l4 into your CLI
Shell out to the l4 binary from scripts, Makefiles and CI runners. Machine-readable output through --json and --jq, and stable exit codes to branch on.
Slack Integration
Build a receiver that verifies the signature on every LevelFour webhook event and posts it into a Slack channel, in Python or TypeScript.