-
Notifications
You must be signed in to change notification settings - Fork 49
feat: add Delivery Insights (DORA metrics) page to the sidebar #729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
1632bb1
a0a350c
fe6b016
826de56
475639f
983dc6c
c947160
d8ad0c2
11da8a3
fee9ce7
7672c49
e8bf913
fad90af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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);
}
})();
JSRepository: 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.tsRepository: openchoreo/backstage-plugins Length of output: 11199 Enforce one observer URL per namespace, or aggregate cross-environment calls.
🤖 Prompt for AI Agents |
||
| return this.resolver.resolveForEnvironment( | ||
| namespaceName, | ||
| environmentName, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.