Skip to content

Commit 4e2f2e6

Browse files
shahzad31cursoragentmiguelmartin-elastickibanamachine
authored
[Synthetics] Fix monitor health API for monitors in non-default spaces (elastic#270540)
## Release note When using Kibana Spaces, the Synthetics monitor health endpoint could incorrectly report monitors as unhealthy — showing errors such as "missing location", "missing agent policy", or "missing package policy" — even when everything was properly configured. This happened because the health check was only looking for monitors, private locations, and Fleet policies in the current space, missing resources that existed in other spaces. These issues are now fixed: the health check correctly resolves monitors, private locations, package policies, and agent policies across all relevant spaces, giving an accurate health status regardless of how resources are distributed across your Kibana Spaces. ## Summary Closes elastic#270477. `POST /internal/synthetics/monitors/_health` returned wrong results when monitors lived outside the request's space — `missing_package_policy` errors when called from the monitor's space, and 404s when called from `default`. Two independent space-scoping bugs: 1. **Package policy lookup ignored space.** `getExistingPackagePoliciesMap` called Fleet's `packagePolicyService.getByIDs` with `createInternalRepository()`, which is scoped to the default namespace. Package policies created for monitors in another space were therefore invisible. 2. **Monitor saved-object lookup was space-scoped.** `MonitorConfigRepository.get` used the request-scoped saved-objects client, restricted to the request's space. Calling `_health` from `default` for a monitor that lives elsewhere returned a 404. ## What changed - **`PackagePolicyService.getByIds`** — accepts a new optional `additionalSpaceIds`, so the wrapper's per-space scoped-client fan-out can broaden beyond `[spaceId, default]`. Existing callers keep their old behavior. - **`MonitorConfigRepository.getAcrossSpaces(id, namespaces, soClient?)`** — new method that resolves a monitor across an arbitrary list of spaces. Uses the multi-space type's per-object `namespaces` array in one bulkGet entry, plus one entry per namespace for the `namespaceType: 'single'` legacy type. Accepts an injected `soClient` so the health API can pass `createInternalRepository()` and bypass the request's space restriction. - **`MonitorIntegrationHealthApi`**: - Computes `allSpaces = { requestSpace, ...allSpacesWithMonitors }` once, up-front. - `fetchMonitors` calls `monitorConfigRepository.getAcrossSpaces` with the internal repository → fixes bug elastic#2. - `getExistingPackagePoliciesMap` uses the `PackagePolicyService` wrapper with `additionalSpaceIds` → fixes bug #1. ## Test plan - [x] `node scripts/jest` on the three affected suites — **77/77 passing** (includes new cross-space coverage and a new `getAcrossSpaces` test block). - [x] `node scripts/type_check --project x-pack/solutions/observability/plugins/synthetics/tsconfig.json` — clean. - [x] `node scripts/eslint` on the changed files — clean. - [ ] Manual: create a monitor with a private location in a non-default space, then call `POST /internal/synthetics/monitors/_health` both from that space and from `default`. Verify each call reports the monitor accurately instead of `missing_package_policy` / 404. ## Related - elastic#270137 — related health API fix (project monitor policy ID + infinite polling). Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Miguel Martín <miguel.martin@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 1ad6159 commit 4e2f2e6

5 files changed

Lines changed: 563 additions & 117 deletions

File tree

x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,101 @@ describe('MonitorConfigRepository', () => {
8585
});
8686
});
8787

88+
describe('getAcrossSpaces', () => {
89+
it('issues a single multi-space lookup and one legacy lookup per namespace', async () => {
90+
const id = 'test-id';
91+
const namespaces = ['default', 'space-two'];
92+
const mockMonitor = {
93+
id,
94+
attributes: { name: 'Test Monitor' },
95+
type: syntheticsMonitorSavedObjectType,
96+
references: [],
97+
};
98+
soClient.bulkGet.mockResolvedValue({ saved_objects: [mockMonitor] });
99+
100+
const result = await repository.getAcrossSpaces(id, namespaces);
101+
102+
expect(soClient.bulkGet).toHaveBeenCalledWith([
103+
{ type: syntheticsMonitorSavedObjectType, id, namespaces: ['default', 'space-two'] },
104+
{ type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['default'] },
105+
{ type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['space-two'] },
106+
]);
107+
expect(result).toBe(mockMonitor);
108+
});
109+
110+
it('returns the first saved object that has attributes and no error', async () => {
111+
const id = 'test-id';
112+
const errored = {
113+
id,
114+
type: syntheticsMonitorSavedObjectType,
115+
attributes: {},
116+
references: [],
117+
error: { statusCode: 404, error: 'Not Found', message: 'not found' },
118+
};
119+
const found = {
120+
id,
121+
type: legacySyntheticsMonitorTypeSingle,
122+
attributes: { name: 'Legacy' },
123+
references: [],
124+
};
125+
soClient.bulkGet.mockResolvedValue({ saved_objects: [errored as any, found] });
126+
127+
const result = await repository.getAcrossSpaces(id, ['default']);
128+
129+
expect(result).toBe(found);
130+
});
131+
132+
it('throws not-found when no namespace has the monitor', async () => {
133+
const id = 'missing-id';
134+
soClient.bulkGet.mockResolvedValue({
135+
saved_objects: [
136+
{
137+
id,
138+
type: syntheticsMonitorSavedObjectType,
139+
error: { statusCode: 404, error: 'Not Found', message: 'not found' },
140+
},
141+
],
142+
} as any);
143+
144+
await expect(repository.getAcrossSpaces(id, ['default'])).rejects.toMatchObject({
145+
output: { statusCode: 404 },
146+
});
147+
});
148+
149+
it('deduplicates the namespaces array', async () => {
150+
const id = 'dup-id';
151+
soClient.bulkGet.mockResolvedValue({
152+
saved_objects: [
153+
{ id, type: syntheticsMonitorSavedObjectType, attributes: {}, references: [] },
154+
],
155+
} as any);
156+
157+
await repository.getAcrossSpaces(id, ['default', 'default', 'space-two']);
158+
159+
const calledWith = soClient.bulkGet.mock.calls[0][0];
160+
expect(calledWith).toEqual([
161+
{ type: syntheticsMonitorSavedObjectType, id, namespaces: ['default', 'space-two'] },
162+
{ type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['default'] },
163+
{ type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['space-two'] },
164+
]);
165+
});
166+
167+
it('uses the supplied saved objects client when provided', async () => {
168+
const id = 'test-id';
169+
const altClient = savedObjectsClientMock.create();
170+
altClient.bulkGet.mockResolvedValue({
171+
saved_objects: [
172+
{ id, type: syntheticsMonitorSavedObjectType, attributes: {}, references: [] },
173+
],
174+
} as any);
175+
176+
await repository.getAcrossSpaces(id, ['default'], altClient);
177+
178+
expect(altClient.bulkGet).toHaveBeenCalledTimes(1);
179+
expect(soClient.bulkGet).not.toHaveBeenCalled();
180+
});
181+
});
182+
88183
describe('getDecrypted', () => {
89184
it('should get and decrypt a monitor by id and space', async () => {
90185
const id = 'test-id';

x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
*/
77

88
import type {
9+
ISavedObjectsRepository,
910
SavedObject,
1011
SavedObjectReference,
12+
SavedObjectsBulkGetObject,
1113
SavedObjectsClientContract,
1214
SavedObjectsFindOptions,
1315
SavedObjectsFindResult,
@@ -75,6 +77,46 @@ export class MonitorConfigRepository {
7577
return resolved;
7678
}
7779

80+
/**
81+
* Look up a monitor by id across the supplied spaces.
82+
*
83+
* Required for cross-space callers (e.g. the monitor health API) because
84+
* `get` is bound to the request-scoped saved objects client and therefore
85+
* only ever sees the request's space — see Kibana issue #270477.
86+
*
87+
* The multi-space type (`syntheticsMonitorSavedObjectType`,
88+
* `namespaceType: 'multiple'`) supports a per-object `namespaces` array, so
89+
* a single entry covers all spaces. The legacy type
90+
* (`legacySyntheticsMonitorTypeSingle`, `namespaceType: 'single'`) only
91+
* accepts one namespace per object, so we add one entry per space.
92+
*/
93+
async getAcrossSpaces(
94+
id: string,
95+
namespaces: string[],
96+
soClient: SavedObjectsClientContract | ISavedObjectsRepository = this.soClient
97+
): Promise<SavedObject<EncryptedSyntheticsMonitorAttributes>> {
98+
const uniqueNamespaces = [...new Set(namespaces)];
99+
const bulkObjects: SavedObjectsBulkGetObject[] = [
100+
{ type: syntheticsMonitorSavedObjectType, id, namespaces: uniqueNamespaces },
101+
...uniqueNamespaces.map((namespace) => ({
102+
type: legacySyntheticsMonitorTypeSingle,
103+
id,
104+
namespaces: [namespace],
105+
})),
106+
];
107+
const { saved_objects: results } = await soClient.bulkGet<EncryptedSyntheticsMonitorAttributes>(
108+
bulkObjects
109+
);
110+
const resolved = results.find((obj) => obj?.attributes && !obj.error);
111+
if (!resolved) {
112+
throw SavedObjectsErrorHelpers.createGenericNotFoundError(
113+
syntheticsMonitorSavedObjectType,
114+
id
115+
);
116+
}
117+
return resolved;
118+
}
119+
78120
async getDecrypted(
79121
id: string,
80122
spaceId: string

0 commit comments

Comments
 (0)