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
37 changes: 37 additions & 0 deletions .changeset/delivery-insights-dora-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@openchoreo/backstage-plugin-openchoreo-observability': minor
'@openchoreo/backstage-plugin-openchoreo-observability-backend': minor
'@openchoreo/openchoreo-client-node': minor
'@openchoreo/backstage-portal-app': minor
---

Add a **Delivery Insights** sidebar page showing the four DORA metrics, scoped
by breadcrumb (Namespace → Project → Component). It sits alongside Cost
Insights in the sidebar rather than on entity pages, since the audience is
delivery leadership looking across an organisation rather than a developer
working on one component.

- **Metrics**: Deployment Frequency, Lead Time for Changes, Change Failure Rate
and MTTR as KPI tiles with DORA classification, delta vs the previous equal
window, and sparklines; a trend chart per metric at daily/weekly/monthly
granularity (lead time shows p50/p75/p95).
- **Drill-down**: a one-level-down breakdown table (namespace → projects,
project → components, component → environments) sorted by deployment
frequency, where each row carries its own metrics and an overall DORA rating
(the scope's weakest tier). Project/component rows narrow the page scope;
environment rows apply the environment filter.
- **Per-environment cards** for the current scope, plus an environment filter
and a "how these metrics are calculated" footnote.
- **Bookmarkable views**: scope, range, granularity and environment all live in
the URL, so a particular view can be shared or saved.
- **Data layer**: `ObservabilityClient` gains `getDoraMetrics` /
`getDoraDeployments` against the observer's
`POST /api/v1alpha1/insights/dora/query` and
`.../insights/dora/deployments/query`, called directly like the other
observability APIs.
- **URL resolution** gains namespace-level support: `/resolve-urls` now works
without an `environmentName` by resolving through the namespace's
environments (new `resolveForNamespace` in the client-node observability URL
resolver), which is what the org-wide scope needs.
- The namespace/project/component breadcrumb is now a shared `ScopeBreadcrumb`
component used by both Delivery Insights and Cost Insights.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ObservabilityUrlResolver } from './observability-url-resolver';
import { createOpenChoreoApiClient } from './factory';

jest.mock('./factory', () => ({
createOpenChoreoApiClient: jest.fn(),
}));

const mockedCreateClient = createOpenChoreoApiClient as jest.MockedFunction<
typeof createOpenChoreoApiClient
>;

function ok(data: unknown) {
return { data, error: undefined, response: { ok: true, status: 200 } };
}

describe('ObservabilityUrlResolver.resolveForNamespace', () => {
beforeEach(() => {
mockedCreateClient.mockReset();
});

it('resolves through the namespace environments and caches the result', async () => {
const get = jest
.fn()
.mockResolvedValueOnce(ok({ items: [{ metadata: { name: 'dev' } }] }))
.mockResolvedValueOnce(ok({ spec: { dataPlaneRef: undefined } }))
.mockResolvedValueOnce(ok({ spec: { observabilityPlaneRef: undefined } }))
.mockResolvedValueOnce(
ok({ spec: { observerURL: 'https://observer.example.com' } }),
);
mockedCreateClient.mockReturnValue({ GET: get } as any);

const resolver = new ObservabilityUrlResolver({
baseUrl: 'https://api.example.com',
});

const first = await resolver.resolveForNamespace('org-1', 'user-a-token');
expect(first.observerUrl).toBe('https://observer.example.com');
expect(get).toHaveBeenCalledTimes(4);

// Second call for the *same* token should hit the cache: no new HTTP calls.
const second = await resolver.resolveForNamespace('org-1', 'user-a-token');
expect(second).toEqual(first);
expect(get).toHaveBeenCalledTimes(4);
});

it('does not leak a cached result across callers with different tokens', async () => {
// resolveForNamespace creates its own client for listing environments,
// then resolveForEnvironment creates another one internally — route each
// by token rather than assuming a fixed call count/order.
const getA = jest
.fn()
.mockResolvedValueOnce(ok({ items: [{ metadata: { name: 'dev' } }] }))
.mockResolvedValueOnce(ok({ spec: { dataPlaneRef: undefined } }))
.mockResolvedValueOnce(ok({ spec: { observabilityPlaneRef: undefined } }))
.mockResolvedValueOnce(
ok({ spec: { observerURL: 'https://observer.example.com' } }),
);

// User B has no visible environments in the same namespace (e.g. RBAC
// scopes them out) and must not receive user A's cached URL.
const getB = jest.fn().mockResolvedValue(ok({ items: [] }));

mockedCreateClient.mockImplementation(
config => ({ GET: config.token === 'user-a-token' ? getA : getB } as any),
);

const resolver = new ObservabilityUrlResolver({
baseUrl: 'https://api.example.com',
});

const forUserA = await resolver.resolveForNamespace(
'org-1',
'user-a-token',
);
expect(forUserA.observerUrl).toBe('https://observer.example.com');

await expect(
resolver.resolveForNamespace('org-1', 'user-b-token'),
).rejects.toThrow(/No environments found in namespace 'org-1'/);

// User B's request must have gone through its own client, not reused
// user A's cached result.
expect(getB).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,86 @@ export class ObservabilityUrlResolver {
return result;
}

/**
* Resolve observability URLs for a namespace without a specific environment —
* used by scopes that aggregate across environments (e.g. the Insights pages at
* namespace/project level). Lists the namespace's environments and returns the
* first one that resolves to an observability plane.
*
* This assumes every environment in a namespace reports to the same
* observability plane, which is how a namespace is expected to be configured.
* If a namespace ever spans several planes, a namespace-wide query resolves to
* whichever plane its first environment uses and would therefore only see that
* plane's data; aggregating across planes would need a different shape than a
* single resolved URL.
*/
async resolveForNamespace(
namespaceName: string,
token?: string,
): Promise<ObservabilityUrlsResult> {
// Keyed by token because which environments this caller can list decides
// which plane is chosen (see below). Note this only partitions the
// namespace-level entry — `resolveForEnvironment` keeps its own
// longstanding cache keyed by namespace/environment alone, so a plane URL
// it has already cached is shared across callers.
const cacheKey = `ns:${namespaceName}:${token ?? ''}`;
const cached = this.getFromCache(cacheKey);
if (cached) return cached;

const client = this.createClient(token);

const {
data: envList,
error: envListError,
response: envListResp,
} = await client.GET('/api/v1/namespaces/{namespaceName}/environments', {
params: { path: { namespaceName } },
});
if (envListError || !envListResp.ok) {
throw new Error(
`Failed to list environments in namespace '${namespaceName}': ${envListResp.status} ${envListResp.statusText}`,
);
}

const items: Array<{ metadata?: { name?: string } }> =
(envList as any)?.items ?? [];
const envNames = items
.map(item => item?.metadata?.name)
.filter((name): name is string => Boolean(name));
if (envNames.length === 0) {
throw new Error(
`No environments found in namespace '${namespaceName}' to resolve observability URLs through`,
);
}

let lastError: Error | undefined;
for (const envName of envNames) {
try {
const result = await this.resolveForEnvironment(
namespaceName,
envName,
token,
);
if (result.observerUrl) {
this.putInCache(cacheKey, result);
return result;
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
this.logger?.debug(
`Failed to resolve observability URLs via environment '${envName}' in namespace '${namespaceName}': ${lastError.message}`,
);
}
}

throw (
lastError ??
new Error(
`No environment in namespace '${namespaceName}' resolved to an observability plane`,
)
);
}

/**
* Resolve observability URLs for build logs.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/portal-app/src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import CategoryIcon from '@material-ui/icons/Category';
import BubbleChartIcon from '@material-ui/icons/BubbleChart';
import MonetizationOnIcon from '@material-ui/icons/MonetizationOn';
import SpeedIcon from '@material-ui/icons/Speed';
import { AssistantDrawerProvider } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant';
// This app composes some OpenChoreo entity tabs itself via legacy
// `EntityLayout.Route` JSX (see EntityPage.tsx), so they render OUTSIDE the
Expand Down Expand Up @@ -232,6 +233,11 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
to="platform-overview"
text="Platform"
/>
<SidebarItem
icon={SpeedIcon}
to="delivery-insights"
text="Delivery Insights"
/>
<SidebarItem
icon={MonetizationOnIcon}
to="cost-insights"
Expand Down
6 changes: 5 additions & 1 deletion packages/portal-app/src/createPortalApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import { HomePage } from './components/Home';
import { CustomGraphNode } from '@openchoreo/backstage-plugin-react';
import { PageLoader } from '@openchoreo/backstage-design-system';
import { PlatformOverviewPage } from './components/platformOverview';
import { CostInsightsPage } from '@openchoreo/backstage-plugin-openchoreo-observability';
import {
CostInsightsPage,
DeliveryInsightsPage,
} from '@openchoreo/backstage-plugin-openchoreo-observability';

import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
import { createApp } from '@backstage/frontend-defaults';
Expand Down Expand Up @@ -167,6 +170,7 @@ const routes = (
/>
<Route path="/platform-overview" element={<PlatformOverviewPage />} />
<Route path="/cost-insights" element={<CostInsightsPage />} />
<Route path="/delivery-insights" element={<DeliveryInsightsPage />} />
{/*
Standalone full-window exec terminal, opened in a new browser tab from the
resource drawer. The page renders a fixed viewport overlay over the app
Expand Down
22 changes: 20 additions & 2 deletions plugins/openchoreo-observability-backend/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,33 @@ describe('createRouter', () => {
});
});

it('should return 400 when resolve-urls is missing parameters', async () => {
it('should return 400 when resolve-urls is missing namespaceName', async () => {
const response = await request(app).get('/resolve-urls').query({});

expect(response.status).toBe(400);
expect(response.body).toMatchObject({
error: 'namespaceName and environmentName are required',
error: 'namespaceName is required',
});
});

it('should resolve observer URLs at namespace level when environmentName is omitted', async () => {
observabilityService.resolveUrls.mockResolvedValue({
observerUrl: 'https://observer.example.com',
rcaAgentUrl: 'https://rca.example.com',
});

const response = await request(app)
.get('/resolve-urls')
.query({ namespaceName: 'org-1' });

expect(response.status).toBe(200);
expect(observabilityService.resolveUrls).toHaveBeenCalledWith(
'org-1',
'',
undefined,
);
});

it('should not allow unauthenticated requests to resolve-urls', async () => {
const response = await request(app)
.get('/resolve-urls')
Expand Down
10 changes: 5 additions & 5 deletions plugins/openchoreo-observability-backend/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ export async function createRouter({
if (authEnabled) {
await httpAuth.credentials(req, { allow: ['user'] });
}
// environmentName is optional: when absent, URLs resolve at namespace level
// (used by cross-environment scopes such as the Insights pages).
const { namespaceName, environmentName } = req.query;
if (!namespaceName || !environmentName) {
return res
.status(400)
.json({ error: 'namespaceName and environmentName are required' });
if (!namespaceName) {
return res.status(400).json({ error: 'namespaceName is required' });
}
const userToken = getUserTokenFromRequest(req);
try {
const urls = await observabilityService.resolveUrls(
namespaceName as string,
environmentName as string,
(environmentName as string | undefined) ?? '',
userToken,
);
return res.status(200).json(urls);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export class ObservabilityService {
/**
* Resolves the observer, RCA agent, and FinOps agent URLs for a given namespace and environment.
* Used by the frontend to make direct calls to observer/RCA/FinOps APIs.
*
* When `environmentName` is empty, resolves at namespace level (first environment
* that reaches an observability plane) — used by cross-environment scopes such as
* the Insights pages.
*/
async resolveUrls(
namespaceName: string,
Expand All @@ -48,6 +52,9 @@ export class ObservabilityService {
rcaAgentUrl?: string;
finopsAgentUrl?: string;
}> {
if (!environmentName) {
return this.resolver.resolveForNamespace(namespaceName, userToken);
}
Comment on lines +55 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline packages/openchoreo-client-node/src/observability-url-resolver.ts --items all

rg -n --type ts -C 8 \
  '\bresolveForNamespace\b|\bresolveForEnvironment\b|\bobserverUrl\b' \
  packages/openchoreo-client-node/src/observability-url-resolver.ts \
  packages/openchoreo-client-node/src/observability-url-resolver.test.ts

rg -n --type ts -C 6 \
  '\bgetDoraMetrics\b|\bresolveUrls\b|\bobserverUrl\b' \
  plugins/openchoreo-observability/src \
  plugins/openchoreo-observability-backend/src

Repository: openchoreo/backstage-plugins

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Relevant source slices"
sed -n '40,75p' plugins/openchoreo-observability-backend/src/services/ObservabilityService.ts
sed -n '148,215p' packages/openchoreo-client-node/src/observability-url-resolver.ts
sed -n '240,365p' packages/openchoreo-client-node/src/observability-url-resolver.ts

echo
echo "## Behavioral probe of resolver loop semantics"
node - <<'JS'
async function fakeResolveForEnvironment(namespaces) {
  return (input) => new Promise((resolve) => {
    const ns = input.url.match(/namespaces\/([^/]+)\b/)?.[1];
    const env = input.url.match(/\benvironments\/([^?]+)/)?.[1];
    const planes = namespaces[ns]?.planes?.[env] ?? {};
    resolve({
      ok: true,
      status: 200,
      response: {},
      data: planes
    });
  });
}

async function resolveForNamespace(namespaces, envOrder) {
  let lastError;
  for (const envName of envOrder) {
    try {
      const result = await fakeResolveForEnvironment(namespaces)({ url: `/namespaces/n1/environments/${envName}` });
      if (result.data.observabilityPlaneRef) {
        const op = await fakeResolveForEnvironment(namespaces)({ url: `/namespaces/n1/observabilityPlanes/${result.data.observabilityPlaneRef.name}` });
        if (op.data?.spec?.observerURL) {
          return { observerUrl: op.data.spec.observerURL };
        }
      }
    } catch (error) {
      lastError = new Error(String(error));
    }
  }
  throw lastError || new Error('No observer found');
}

(async () => {
  const cases = [
    {
      name: 'environments share one observer',
      namespaces: { n1: { planes: { dev: { observabilityPlaneRef: { name: 'obs-common' } }, prod: { observabilityPlaneRef: { name: 'obs-common' } } } } },
    },
    {
      name: 'environments point to different observers',
      namespaces: { n1: { planes: { dev: { observabilityPlaneRef: { name: 'obs-dev' } }, prod: { observabilityPlaneRef: { name: 'obs-prod' } } } } },
    },
  ];
  for (const c of cases) {
    let first, second;
    try {
      first = await resolveForNamespace(c.namespaces, ['dev', 'prod']);
      try { second = await resolveForNamespace(c.namespaces, ['prod', 'dev']); } catch (e) {}
    } catch (e) {
      first = e.message;
    }
    console.log(c.name, 'first=', first.observerUrl || first, 'second=', second.observerUrl || second);
  }
})();
JS

Repository: openchoreo/backstage-plugins

Length of output: 7507


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Behavioral probe of resolver loop semantics"
node - <<'JS'
function flattenEnvOrder(envs) {
  // Mirrors the resolver code: first pick an environment, then pick an observer plane.
  const order = [];
  for (const environment of envs) {
    for (const observerEnv of environment) {
      order.push({ environment, observerEnv });
    }
  }
  return order;
}

async function simulateResolve(namespaces, envOrder) {
  for (const { environment, observerEnv } of envOrder) {
    const observerUrl = namespaces[environment]?.observerURLForEnvironment?.[observerEnv];
    if (observerUrl) {
      return { observerUrl };
    }
  }
  throw new Error('No observer found');
}

(async () => {
  const namespaces = {
    n1: {
      observerURLForEnvironment: {
        dev: 'https://observer-dev.example.com',
        prod: 'https://observer-prod.example.com',
      },
    },
  };
  for (const inputOrder of [
    [['dev'], ['prod']],
    [['prod'], ['dev']],
  ]) {
    const order = flattenEnvOrder(inputOrder);
    console.log('input order:', JSON.stringify(inputOrder));
    for (const e of order) {
      console.log('attempt', e.environment, '<-', e.observerEnv, '=>', namespaces.n1.observerURLForEnvironment[e.observerEnv]);
    }
    try {
      const first = await simulateResolve(namespaces, order);
      console.log('resolved observer=', first.observerUrl);
      console.log('metrics from another environment appear missing when request targets', order[order.length - 1].environment);
    } catch (error) {
      console.log('resolved observer=', error.message);
    }
  }
})();
JS

echo
echo "## Additional search for multi-entry behavior / tests"
rg -n --type ts -C 4 "environments?:|environment?:|observerURLForEnvironment|ObservabilityPlaneRef|observabilityPlaneRef" packages/openchoreo-client-node/src/observability-url-resolver.ts packages/openchoreo-client-node/src/observability-url-resolver.test.ts

Repository: openchoreo/backstage-plugins

Length of output: 11199


Enforce one observer URL per namespace, or aggregate cross-environment calls.

resolveForNamespace returns the first environment’s observer URL it can resolve. If environments in the same namespace can point to different observers, namespace-scoped requests may call an observer that does not have the requested environment’s metrics. Add invariant validation and a test for this case, or aggregate calls across all namespace observers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/openchoreo-observability-backend/src/services/ObservabilityService.ts`
around lines 55 - 57, Update the namespace-scoped branch in ObservabilityService
to avoid returning only the first observer URL when environments in the
namespace may use different observers: either validate and enforce a single
observer URL per namespace, or aggregate requests across every namespace
observer. Add coverage for multiple environments resolving to different
observers and preserve the existing environment-specific behavior.

return this.resolver.resolveForEnvironment(
namespaceName,
environmentName,
Expand Down
Loading
Loading