mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
## What & why
Begins consolidating the **correlation (service-streams) e2e suite**
onto the standard `tests/ui-testing` framework. Today those specs live
in a self-contained parallel harness (own configs, own auth, own `O2_*`
env vars, per-test orgs with no teardown, non-standard names) and are
**not registered in any CI matrix** — so they never run automatically.
This is **Phase 0**: land the shared standard-shaped API helper and
convert the first spec as the **reference pattern** for review, before
batch-converting the rest.
## Changes
- **`utils/correlation-api-helpers.js`** (new) — single source of truth
for correlation API plumbing. Auth via `authedRequest`/`getAuthHeaders`
(framework session + 401/403 self-heal), per-test **fresh-org create +
teardown**. No `test`/`expect`.
- **`Correlation/correlation-discovery.spec.js`** (new) — converted from
`journeyA`: `enhanced-baseFixtures`, `@correlation`/`@P0` tags,
Copyright + JSDoc header, `testLogger.testStart`, standard `ZO_*` env
and naming. Assertions ported **1:1** (DISC-01/02/03).
- Remove `Correlation/journeyA-discovery.api.spec.js` (superseded).
## Deliberate deviation (documented)
Correlation discovery / identity config / `_reset` are **org-global and
destructive**, so each test provisions a **fresh org** and tears it down
in `afterEach`. This keeps the isolation the feature genuinely needs
while fixing the real defect in the old suite: **orgs were created but
never deleted** (leak).
## Validation
- `node --check` clean; requires resolve.
- Collects cleanly under `playwright.config.js` (`--list` → 3 tests,
tagged).
- ⚠️ **Green run pending an enterprise env** (service_streams 403s on
OSS). Not wired into CI yet, so `main` stays green. **Draft** until
validated.
## Follow-ups (later phases)
Batch-convert remaining 4 API + 3 UI specs · revive
`correlationSettings.spec.js` against the new UI · register a dedicated
**Correlation** shard in the ENT `ci_matrix.ent.json` · delete
`corr-*.config.js`.
449 lines
14 KiB
JavaScript
449 lines
14 KiB
JavaScript
// Copyright 2026 OpenObserve Inc.
|
|
|
|
/**
|
|
* Shared API plumbing for the Correlation (service-streams) e2e specs.
|
|
*
|
|
* The correlation specs all drive the same enterprise `service_streams`
|
|
* endpoints (discovery, `_correlate`, `config/identity`, `_analytics`,
|
|
* `_reset`), the same semantic-groups endpoint, and the same ingest paths.
|
|
* This module is the single source of truth for that plumbing so every
|
|
* correlation spec stays in lockstep. It deliberately contains NO test/expect
|
|
* calls — assertions belong in the spec files.
|
|
*
|
|
* AUTH: routes through `authedRequest`/`getAuthHeaders` (cloud-auth.js) so it
|
|
* shares the framework's session (global-setup storageState) and 401/403
|
|
* self-heal — NOT a bespoke Basic-auth context.
|
|
*
|
|
* ORG ISOLATION (deliberate deviation from the shared-org norm): correlation
|
|
* discovery, identity config, semantic groups and `_reset` are ORG-GLOBAL and
|
|
* DESTRUCTIVE — one test's saveIdentity/reset would corrupt any test sharing
|
|
* the org. So each test provisions a FRESH org via `createCorrelationOrg` and
|
|
* tears it down in afterEach via `deleteOrg`. (The pre-standardization suite
|
|
* created orgs but never deleted them — this fixes that leak.)
|
|
*/
|
|
|
|
const {
|
|
getAuthHeaders,
|
|
isCloudEnvironment,
|
|
authedRequest,
|
|
} = require("./cloud-auth.js");
|
|
|
|
const BASE = process.env.ZO_BASE_URL || "http://localhost:5080";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Temporal contract (f(env) of the backend under test):
|
|
// ZO_MAX_FILE_RETENTION_TIME=10, ZO_FILE_PUSH_INTERVAL=10,
|
|
// O2_SERVICE_STREAMS_BATCH_FLUSH_INTERVAL_SECS=5, SAMPLE_RATE=1.
|
|
// Measured end-to-end discovery latency is ~2min (WAL move-job cadence
|
|
// dominates). First flush after a cold start ~3.3min. 300s covers it with
|
|
// margin; deployed envs can raise it via env without a code change.
|
|
// ---------------------------------------------------------------------------
|
|
const DISCOVERY_DEADLINE_MS = Number(
|
|
process.env.O2_CORR_DISCOVERY_DEADLINE_MS || 300_000,
|
|
);
|
|
const POLL_INTERVAL_MS = 3_000;
|
|
const MAX_STREAMS_PER_TYPE = Number(
|
|
process.env.O2_SERVICE_STREAMS_MAX_STREAMS_PER_SERVICE || 50,
|
|
);
|
|
|
|
/** Unique, human-readable org prefix so parallel/repeat runs never collide. */
|
|
const uniqOrg = (p) =>
|
|
`${p}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
function nowMicros() {
|
|
return Date.now() * 1000;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
/** Poll `fn` until it returns a truthy value or the deadline passes. */
|
|
async function pollUntil(
|
|
fn,
|
|
{
|
|
deadlineMs = DISCOVERY_DEADLINE_MS,
|
|
intervalMs = POLL_INTERVAL_MS,
|
|
label = "condition",
|
|
} = {},
|
|
) {
|
|
const start = Date.now();
|
|
let last;
|
|
while (Date.now() - start < deadlineMs) {
|
|
last = await fn();
|
|
if (last) return last;
|
|
await sleep(intervalMs);
|
|
}
|
|
throw new Error(
|
|
`pollUntil: timed out after ${deadlineMs}ms waiting for ${label} (last=${JSON.stringify(
|
|
last,
|
|
)})`,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-org auth. On local ENT the root Basic header (getAuthHeaders) authorizes
|
|
// every org, including freshly created ones. On cloud, ingest into a fresh org
|
|
// needs THAT org's passcode (per-org tokens), so we mint it via the page's live
|
|
// session cookie. Management/correlate calls go through authedRequest (which
|
|
// self-heals) and don't need this.
|
|
// ---------------------------------------------------------------------------
|
|
async function ingestHeaders(page, org) {
|
|
if (!isCloudEnvironment()) return getAuthHeaders();
|
|
try {
|
|
const pc = await page.evaluate(async (o) => {
|
|
const r = await fetch(`/api/${o}/passcode`);
|
|
return r.ok ? await r.json() : null;
|
|
}, org);
|
|
if (pc && pc.data && pc.data.passcode) {
|
|
const basic = Buffer.from(`${pc.data.user}:${pc.data.passcode}`).toString(
|
|
"base64",
|
|
);
|
|
return { Authorization: `Basic ${basic}`, "Content-Type": "application/json" };
|
|
}
|
|
} catch {
|
|
/* fall through to root headers; caller surfaces any 401 */
|
|
}
|
|
return getAuthHeaders();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Org lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a fresh org for one test's isolation. Returns its identifier.
|
|
* Names are NOT idempotent — always unique, underscores only.
|
|
*/
|
|
async function createCorrelationOrg(page, prefix = "corr") {
|
|
const name = uniqOrg(prefix);
|
|
const res = await authedRequest(page, "post", `${BASE}/api/organizations`, {
|
|
data: { name },
|
|
});
|
|
if (!res.ok()) {
|
|
throw new Error(`org create failed: ${res.status()} ${await res.text()}`);
|
|
}
|
|
const body = await res.json();
|
|
const org = body.identifier || (body.data && body.data.identifier);
|
|
if (!org)
|
|
throw new Error(`org create: no identifier in ${JSON.stringify(body)}`);
|
|
return org;
|
|
}
|
|
|
|
/** Best-effort org teardown — never throws (cleanup must not fail a test). */
|
|
async function deleteOrg(page, org) {
|
|
if (!org) return;
|
|
try {
|
|
await authedRequest(page, "delete", `${BASE}/api/organizations/${org}`);
|
|
} catch {
|
|
/* leak-avoidance is best-effort; a failed delete must not fail the test */
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ingest
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** records: array of flat objects; _timestamp (micros) added if missing. */
|
|
async function ingestLogs(page, org, stream, records) {
|
|
const ts = nowMicros();
|
|
const data = records.map((r) => ({ _timestamp: ts, ...r }));
|
|
const res = await page.request.post(`${BASE}/api/${org}/${stream}/_json`, {
|
|
headers: await ingestHeaders(page, org),
|
|
data,
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok() || body.code !== 200) {
|
|
throw new Error(
|
|
`ingestLogs(${stream}) failed: ${res.status()} ${JSON.stringify(body)}`,
|
|
);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/** records: [{__name__, value, ...labels}]; _timestamp (millis) added if missing. */
|
|
async function ingestMetrics(page, org, records) {
|
|
const ts = Date.now();
|
|
const data = records.map((r) => ({
|
|
__type__: "gauge",
|
|
_timestamp: ts,
|
|
value: 1,
|
|
...r,
|
|
}));
|
|
const res = await page.request.post(
|
|
`${BASE}/api/${org}/ingest/metrics/_json`,
|
|
{ headers: await ingestHeaders(page, org), data },
|
|
);
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok() || body.code !== 200) {
|
|
throw new Error(`ingestMetrics failed: ${res.status()} ${JSON.stringify(body)}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Minimal OTLP-JSON trace ingest. `attrs` are flat key->string maps placed as
|
|
* resource attributes (keys without dots flatten to themselves in the stored
|
|
* record). Emits one parent + `childCount` children.
|
|
*/
|
|
async function ingestTraces(page, org, serviceName, attrs, childCount = 1) {
|
|
const nowNs = BigInt(Date.now()) * 1_000_000n;
|
|
const traceId =
|
|
"a".repeat(16) + Math.random().toString(16).slice(2, 18).padEnd(16, "0");
|
|
const parentId = Math.random().toString(16).slice(2, 18).padEnd(16, "0");
|
|
const mkAttr = (k, v) => ({ key: k, value: { stringValue: String(v) } });
|
|
const resourceAttrs = [
|
|
mkAttr("service.name", serviceName),
|
|
...Object.entries(attrs).map(([k, v]) => mkAttr(k, v)),
|
|
];
|
|
const spans = [
|
|
{
|
|
traceId,
|
|
spanId: parentId,
|
|
name: "parent-op",
|
|
kind: 2,
|
|
startTimeUnixNano: String(nowNs - 5_000_000n),
|
|
endTimeUnixNano: String(nowNs),
|
|
attributes: [],
|
|
status: {},
|
|
},
|
|
];
|
|
for (let i = 0; i < childCount; i++) {
|
|
spans.push({
|
|
traceId,
|
|
spanId: Math.random().toString(16).slice(2, 18).padEnd(16, "0"),
|
|
parentSpanId: parentId,
|
|
name: `child-op-${i}`,
|
|
kind: 3,
|
|
startTimeUnixNano: String(nowNs - 4_000_000n),
|
|
endTimeUnixNano: String(nowNs - 1_000_000n),
|
|
attributes: [],
|
|
status: {},
|
|
});
|
|
}
|
|
const data = {
|
|
resourceSpans: [
|
|
{
|
|
resource: { attributes: resourceAttrs },
|
|
scopeSpans: [{ scope: { name: "corr-e2e" }, spans }],
|
|
},
|
|
],
|
|
};
|
|
const res = await page.request.post(`${BASE}/api/${org}/v1/traces`, {
|
|
headers: await ingestHeaders(page, org),
|
|
data,
|
|
});
|
|
if (!res.ok()) {
|
|
throw new Error(`ingestTraces failed: ${res.status()} ${await res.text()}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// service_streams API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function listServices(page, org) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"get",
|
|
`${BASE}/api/${org}/service_streams`,
|
|
);
|
|
if (!res.ok()) throw new Error(`listServices failed: ${res.status()}`);
|
|
return res.json();
|
|
}
|
|
|
|
/** Returns {status, body} — body is null on 200-null no-match. */
|
|
async function correlate(
|
|
page,
|
|
org,
|
|
availableDimensions,
|
|
{ sourceStream = "unknown", sourceType = "logs" } = {},
|
|
) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"post",
|
|
`${BASE}/api/${org}/service_streams/_correlate`,
|
|
{
|
|
data: {
|
|
source_stream: sourceStream,
|
|
source_type: sourceType,
|
|
available_dimensions: availableDimensions,
|
|
},
|
|
},
|
|
);
|
|
const text = await res.text();
|
|
let body = null;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} catch {
|
|
body = text;
|
|
}
|
|
return { status: res.status(), body };
|
|
}
|
|
|
|
async function getIdentity(page, org) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"get",
|
|
`${BASE}/api/${org}/service_streams/config/identity`,
|
|
);
|
|
if (!res.ok()) throw new Error(`getIdentity failed: ${res.status()}`);
|
|
return res.json();
|
|
}
|
|
|
|
/** Returns {status, body} so 400-path tests can assert the message. */
|
|
async function saveIdentity(page, org, cfg) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"put",
|
|
`${BASE}/api/${org}/service_streams/config/identity`,
|
|
{ data: cfg },
|
|
);
|
|
const body = await res.json().catch(() => null);
|
|
return { status: res.status(), body };
|
|
}
|
|
|
|
async function getAnalytics(page, org) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"get",
|
|
`${BASE}/api/${org}/service_streams/_analytics`,
|
|
);
|
|
if (!res.ok()) throw new Error(`getAnalytics failed: ${res.status()}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function reset(page, org) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"delete",
|
|
`${BASE}/api/${org}/service_streams/_reset`,
|
|
);
|
|
if (!res.ok())
|
|
throw new Error(`reset failed: ${res.status()} ${await res.text()}`);
|
|
return res.json().catch(() => ({}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Semantic groups (Field Mappings)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getSemanticGroups(page, org) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"get",
|
|
`${BASE}/api/${org}/alerts/deduplication/semantic-groups`,
|
|
);
|
|
if (!res.ok()) throw new Error(`getSemanticGroups failed: ${res.status()}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function putSemanticGroups(page, org, groups) {
|
|
const res = await authedRequest(
|
|
page,
|
|
"put",
|
|
`${BASE}/api/${org}/alerts/deduplication/semantic-groups`,
|
|
{ data: groups },
|
|
);
|
|
const body = await res.json().catch(() => null);
|
|
return { status: res.status(), body };
|
|
}
|
|
|
|
/** Append a custom group to the org's current groups. */
|
|
async function addSemanticGroup(page, org, group) {
|
|
const current = await getSemanticGroups(page, org);
|
|
const groups = Array.isArray(current) ? current : current.groups || [];
|
|
const next = groups.filter((g) => g.id !== group.id).concat([group]);
|
|
const res = await putSemanticGroups(page, org, next);
|
|
if (res.status !== 200) {
|
|
throw new Error(
|
|
`addSemanticGroup failed: ${res.status} ${JSON.stringify(res.body)}`,
|
|
);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/** Remove a group by id. */
|
|
async function removeSemanticGroup(page, org, groupId) {
|
|
const current = await getSemanticGroups(page, org);
|
|
const groups = Array.isArray(current) ? current : current.groups || [];
|
|
const res = await putSemanticGroups(
|
|
page,
|
|
org,
|
|
groups.filter((g) => g.id !== groupId),
|
|
);
|
|
if (res.status !== 200) {
|
|
throw new Error(
|
|
`removeSemanticGroup failed: ${res.status} ${JSON.stringify(res.body)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search (for zero-row / F1 verification)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Run SQL against logs; returns hits array. Window: last 30 min. */
|
|
async function searchLogs(page, org, sql) {
|
|
const end = nowMicros();
|
|
const start = end - 30 * 60 * 1_000_000;
|
|
const res = await authedRequest(
|
|
page,
|
|
"post",
|
|
`${BASE}/api/${org}/_search?type=logs`,
|
|
{ data: { query: { sql, start_time: start, end_time: end, from: 0, size: 100 } } },
|
|
);
|
|
if (!res.ok()) {
|
|
throw new Error(`searchLogs failed: ${res.status()} ${await res.text()}`);
|
|
}
|
|
const body = await res.json();
|
|
return body.hits || [];
|
|
}
|
|
|
|
/** Build `SELECT * FROM "stream" WHERE f1='v1' AND ...` from a filters map. */
|
|
function sqlForFilters(stream, filters) {
|
|
const where = Object.entries(filters)
|
|
.map(([k, v]) => `${k} = '${String(v).replace(/'/g, "''")}'`)
|
|
.join(" AND ");
|
|
return `SELECT * FROM "${stream}"${where ? ` WHERE ${where}` : ""}`;
|
|
}
|
|
|
|
/** Poll listServices until `pred(rows)` is truthy; returns the rows snapshot. */
|
|
async function waitForServices(page, org, pred, label = "services") {
|
|
return pollUntil(
|
|
async () => {
|
|
const rows = await listServices(page, org);
|
|
return pred(rows) ? rows : null;
|
|
},
|
|
{ label },
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
BASE,
|
|
DISCOVERY_DEADLINE_MS,
|
|
MAX_STREAMS_PER_TYPE,
|
|
nowMicros,
|
|
sleep,
|
|
pollUntil,
|
|
createCorrelationOrg,
|
|
deleteOrg,
|
|
ingestLogs,
|
|
ingestMetrics,
|
|
ingestTraces,
|
|
listServices,
|
|
correlate,
|
|
getIdentity,
|
|
saveIdentity,
|
|
getAnalytics,
|
|
reset,
|
|
getSemanticGroups,
|
|
putSemanticGroups,
|
|
addSemanticGroup,
|
|
removeSemanticGroup,
|
|
searchLogs,
|
|
sqlForFilters,
|
|
waitForServices,
|
|
};
|