Files
openobserve/web/src/composables/useSyntheticResults.ts
T
2026-08-05 10:16:00 +00:00

608 lines
22 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, ref } from "vue";
import type { TranslateFn } from "@/types/i18n";
import { useStore } from "vuex";
import { useLLMStreamQuery } from "@/plugins/traces/composables/useLLMStreamQuery";
import {
aggregateStepStats,
bucketInterval,
buildHistogramSql,
buildP95Sql,
buildRetryAttributionSql,
foldRetryAttribution,
buildLastRunSql,
buildRunsSql,
buildRunsWithStepsSql,
buildStepDefsSql,
foldStepDefs,
buildStepAggregateSql,
buildStepDimensionSql,
buildStepSparklineSql,
foldStepStream,
buildRunDetailSql,
buildProtocolRunDetailSql,
mapHistogram,
deriveKpiFromHistogram,
mapProtocolRunDetail,
mapRun,
mapRunDetail,
SYNTHETIC_RESULTS_STREAM,
SYNTHETIC_STEP_RESULTS_STREAM,
type ProtocolRunDetail,
type StepStatsResult,
type SyntheticBucket,
type SyntheticKpi,
type SyntheticRun,
type SyntheticRunDetail,
} from "@/composables/synthetics/syntheticResultsSchema";
import useStreams from "@/composables/useStreams";
/**
* Row cap on the runs-list query — the one that feeds the timeline, the
* breakdown cards, the table and the errors tab.
*
* Exported because consumers have to be able to say "this is the most recent
* N", not just render a truncated set as if it were everything: the KPI cards
* are computed by a separate aggregate query over the WHOLE window, so past
* this many executions the two legitimately disagree.
*/
export const RUNS_QUERY_LIMIT = 1000;
const EMPTY_KPI: SyntheticKpi = {
uptimePct: 0,
p95Ms: 0,
passedRuns: 0,
warningRuns: 0,
failedRuns: 0,
errorRuns: 0,
totalRuns: 0,
retriedRuns: 0,
flakyExecutions: 0,
degradedExecutions: 0,
lastRunStatus: null,
lastRunAt: null,
};
/**
* Orchestration layer for KPI cards and Response Time chart.
* Runs data is fetched separately via the REST /runs endpoint.
*/
export function useSyntheticResults(t: TranslateFn) {
const store = useStore();
const { getStream } = useStreams(t);
const { executeQuery, cancelAll } = useLLMStreamQuery();
const kpi = ref<SyntheticKpi>({ ...EMPTY_KPI });
const buckets = ref<SyntheticBucket[]>([]);
const runs = ref<SyntheticRun[]>([]);
const runDetail = ref<SyntheticRunDetail | null>(null);
const protocolRunDetail = ref<ProtocolRunDetail | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const hasLoadedOnce = ref(false);
// Per-query loading signals — each section of the UI gates its own
// skeleton independently, so fast queries (KPI, histogram) render
// while the slow runs-list query is still in flight.
const kpiLoading = ref(false);
const histogramLoading = ref(false);
const runsLoading = ref(false);
const kpiHasLoadedOnce = ref(false);
const histogramHasLoadedOnce = ref(false);
const runsHasLoadedOnce = ref(false);
const stepsLoading = ref(false);
const stepsHasLoadedOnce = ref(false);
// Per-group error messages — set when an individual query fails so
// each UI section can surface the error instead of silently rendering
// zeros or empty charts.
const kpiError = ref<string | null>(null);
const histogramError = ref<string | null>(null);
const runsError = ref<string | null>(null);
const stepsError = ref<string | null>(null);
const stepStats = ref<StepStatsResult>({
stepFailures: [],
stepDurations: [],
stepGroups: [],
flakySteps: [],
trendBuckets: [],
failureInstances: [],
// Same shape `emptyStepStats()` returns; the initial value was missed when
// `coverage` was added, and only `tsconfig.app.json` is strict enough to say so.
coverage: { executions: 0, fromMs: 0, toMs: 0, truncated: false },
});
// ── Stream schema fields ─────────────────────────────────────────────────
//
// The stream schema only contains fields some ingested row has carried
// (browser-only fields are absent on protocol-only deployments, `error`
// is absent until a run has failed, …) and the search API rejects queries
// naming absent fields. Query builders take this set and substitute
// literals for missing columns. getStream caches, so repeat calls are cheap.
/**
* Field names present in the stream schema.
*
* On failure this returns an EMPTY set, which makes every optional column
* select a typed literal instead of its name. That is the only option that
* cannot fail — naming a column the schema lacks is rejected outright by the
* search API, and the schema genuinely lacks `status_reason` until some run
* has been a `warning`.
*
* The cost is that a schema-fetch failure is indistinguishable from a stream
* that has none of these fields: `init_ms` reads 0, `attempts` reads 0 and
* `retry_history` reads '', so the run detail renders with no init chip, no
* queue delay and no attempts strip — a fetch failure presented as a run that
* simply had none of those things.
*
* Hence the log. It is the only signal that the degraded render is a failure
* rather than the data, and it cost real debugging time to work that out once.
*/
async function fetchSchemaFields(): Promise<Set<string>> {
try {
const stream: any = await getStream(SYNTHETIC_RESULTS_STREAM, "logs", true);
const fields = ((stream?.schema ?? []) as { name: string }[]).map((f) => f.name);
if (!fields.length) {
console.warn(
"[synthetics] stream schema returned no fields; optional columns will render as empty",
);
}
return new Set(fields);
} catch (e: unknown) {
console.warn(
"[synthetics] stream schema unavailable — optional columns (init_ms, attempts, " +
"retry_history, …) will render as empty, NOT as absent data:",
e,
);
return new Set();
}
}
/**
* The runs list came back full, so it is the most recent `RUNS_QUERY_LIMIT`
* executions rather than every one in the window.
*
* Reported by the composable, not re-derived by each consumer: the cap is
* this module's decision, and anything built from `runs` (the timeline, the
* breakdown cards) is a partial view whenever this is true — while the KPI
* cards beside them are aggregated server-side over the whole window.
*/
const runsTruncated = computed(
() => runsHasLoadedOnce.value && runs.value.length >= RUNS_QUERY_LIMIT,
);
// ── Effective p95 — falls back to client-side computation from runs ──────
//
// The SQL approx_percentile_cont may return 0 when the DataFusion fork
// can't infer the field type. When that happens and runs data is available,
// compute p95 from the in-memory run durations.
const effectiveP95Ms = computed(() => {
const sqlP95 = kpi.value.p95Ms;
if (sqlP95 > 0) return sqlP95;
const durations = runs.value
.map((r) => r.durationMs)
.filter((d) => d > 0)
.sort((a, b) => a - b);
if (durations.length === 0) return 0;
const idx = Math.ceil(durations.length * 0.95) - 1;
return durations[Math.max(0, Math.min(idx, durations.length - 1))];
});
// ── Steps: fetch via synthetics_results log stream ─────────────────────
//
// Step-level fields (recorded_steps, last_attempt_steps, retry_history)
// are queried directly from the synthetics_results log stream, the same
// way buildRunDetailSql does for per-run detail views. This avoids the
// REST /runs endpoint's 200-row page limit and fetches all runs in the
// time window in a single streaming query.
//
// The retry_history column is conditionally included — it may not exist
// on instances where the probe hasn't written it yet.
/**
* B10 — the Steps tab answered from the step-grain stream.
*
* Three `GROUP BY`s over the WHOLE window, in place of downloading 5000
* execution rows and tallying their JSON blobs in the browser. Measured on the
* busiest check in introspection: 18 079 executions over 7 days, so the 5000-row
* cap covered 4.0 of the 7 days and reported a 56.3% fail rate where the true
* figure over the window was 33.7%. The win is correctness, not speed — the old
* numbers were a recency-biased sample, not a slow-but-right answer.
*
* Returns null when the stream has nothing for this check, and the caller falls
* back to the tally below. That fallback is not scaffolding to delete on a
* timer: the stream starts empty with no backfill and fills only as probe
* fleets take the build that writes it, so cutting it hard would blank the tab
* for every check whose agents have not been upgraded.
*
* While a fleet is mixed, these numbers describe only the executions that came
* from an upgraded agent. `coverage.executions` is the count they were computed
* from, so the tab states what it measured rather than implying it saw
* everything.
*/
async function fetchStepStreamStats(
monitorId: string,
startTime: number,
endTime: number,
): Promise<StepStatsResult | null> {
try {
const stream: any = await getStream(SYNTHETIC_STEP_RESULTS_STREAM, "logs", true);
const schema: { name: string }[] = stream?.schema ?? [];
// Gated on the grain column, not on "the stream resolved". A stream can
// exist by name while predating the columns read below, and naming an
// absent column is rejected outright by the search API — so a weaker check
// would turn a partially-created stream into four failed queries.
if (!schema.some((f) => f.name === "step_id")) return null;
} catch {
return null;
}
try {
const [aggHits, dimHits, sparkHits, defHits] = await Promise.all([
executeQuery(buildStepAggregateSql(monitorId), startTime, endTime, "logs") as Promise<
Record<string, unknown>[]
>,
executeQuery(buildStepDimensionSql(monitorId), startTime, endTime, "logs") as Promise<
Record<string, unknown>[]
>,
executeQuery(buildStepSparklineSql(monitorId), startTime, endTime, "logs") as Promise<
Record<string, unknown>[]
>,
// Step names and selectors still live on the execution record's
// `recorded_steps` — the step stream carries ids, not the journey
// definition, because a definition repeated on every step row would be
// the same blob duplication this change removes.
executeQuery(buildStepDefsSql(monitorId, 100), startTime, endTime, "logs") as Promise<
Record<string, unknown>[]
>,
]);
// No rows means this check has not been run by an upgraded agent yet. Fall
// back rather than render an empty tab over a populated execution stream.
if (!aggHits.length) return null;
return foldStepStream(aggHits, dimHits, sparkHits, foldStepDefs(defHits));
} catch (e: unknown) {
console.warn("[synthetics] step stream query failed, falling back:", e);
return null;
}
}
async function fetchAndAggregateSteps(
monitorId: string,
startTime: number,
endTime: number,
): Promise<StepStatsResult> {
try {
const fromStream = await fetchStepStreamStats(monitorId, startTime, endTime);
if (fromStream) return fromStream;
let hasRetryHistory = false;
let hasRetryAttribution = false;
let hasStatusReason = false;
try {
const stream: any = await getStream(SYNTHETIC_RESULTS_STREAM, "logs", true);
const schema: { name: string }[] = stream?.schema ?? [];
hasRetryHistory = schema.some((f) => f.name === "retry_history");
hasRetryAttribution = schema.some((f) => f.name === "retry_step_ids");
hasStatusReason = schema.some((f) => f.name === "status_reason");
} catch {
// Schema not available — omit retry_history, which is safe.
}
// C7 — once the probe writes `retry_step_ids`, the flaky column is
// answered by three scalars on the rows that actually retried, so the
// ~1 KB-per-attempt `retry_history` blob stops being fetched across all
// 5000 rows. Until then the old path still works, unchanged.
const useAttribution = hasRetryAttribution;
const selectRetryHistory = hasRetryHistory && !useAttribution;
/** Set when the attribution query failed, so its results are not treated
* as "nothing retried". */
let attributionFailed = false;
const STEP_RUNS_LIMIT = 5000;
// Two queries rather than one (P1a): the wide tally without
// `recorded_steps`, and a bounded fetch of the step definitions. Selecting
// the definitions on all 5000 rows shipped the same ~4 KB blob 5000 times
// — roughly 60% of this panel's payload.
const STEP_DEFS_LIMIT = 100;
const [hits, defHits, retryHits] = await Promise.all([
executeQuery(
buildRunsWithStepsSql(monitorId, STEP_RUNS_LIMIT, selectRetryHistory),
startTime,
endTime,
"logs",
) as Promise<Record<string, unknown>[]>,
executeQuery(
buildStepDefsSql(monitorId, STEP_DEFS_LIMIT),
startTime,
endTime,
"logs",
) as Promise<Record<string, unknown>[]>,
useAttribution
? (executeQuery(
buildRetryAttributionSql(monitorId, STEP_RUNS_LIMIT, hasStatusReason),
startTime,
endTime,
"logs",
).catch((e: unknown) => {
// Isolated deliberately. These three queries share a Promise.all,
// so an unhandled rejection here emptied the ENTIRE Steps tab —
// Fail Rate, durations and all — to report one missing column.
// Degrade the flaky column instead, and say so rather than
// rendering a silent zero.
console.warn("[synthetics] retry attribution query failed:", e);
attributionFailed = true;
return [] as Record<string, unknown>[];
}) as Promise<Record<string, unknown>[]>)
: Promise.resolve([] as Record<string, unknown>[]),
]);
const stepDefs = foldStepDefs(defHits);
if (!hits.length) return emptyStepStats();
return aggregateStepStats(
hits,
startTime,
endTime,
stepDefs,
useAttribution && !attributionFailed ? foldRetryAttribution(retryHits) : undefined,
STEP_RUNS_LIMIT,
);
} catch {
return emptyStepStats();
}
}
function emptyStepStats(): StepStatsResult {
return {
stepFailures: [],
stepDurations: [],
stepGroups: [],
flakySteps: [],
trendBuckets: [],
failureInstances: [],
coverage: { executions: 0, fromMs: 0, toMs: 0, truncated: false },
};
}
/**
* Loads everything the Overview tab needs.
*
* Steps are deliberately NOT part of this: the step aggregation reads the
* REST /runs endpoint row by row and is the most expensive query on the page,
* while the Steps tab is the one the fewest visits ever open. Callers drive it
* separately through `fetchSteps` when the tab is actually in view.
*/
async function fetchAll(monitorId: string, startTime: number, endTime: number): Promise<void> {
if (!monitorId || !startTime || !endTime) return;
loading.value = true;
kpiLoading.value = true;
histogramLoading.value = true;
runsLoading.value = true;
error.value = null;
// Clear per-group errors on each fresh fetch so a successful retry
// removes any previously shown error state.
kpiError.value = null;
histogramError.value = null;
runsError.value = null;
try {
const interval = bucketInterval(endTime - startTime);
const schemaFields = await fetchSchemaFields();
const hasAttemptsField = schemaFields.has("attempts");
const hasStatusReasonField = schemaFields.has("status_reason");
// The histogram now feeds BOTH the charts and the KPI tiles.
//
// The tiles used to have their own bare-aggregate query, which could never
// hit O2's result cache (that only engages for time-bucketed queries) and
// so rescanned on every page load: measured 276-658ms against 7ms for the
// cached histogram beside it. Every count tile is a plain sum over the
// buckets, so this is exact rather than an approximation — only p95 needs
// its own (small) query, because percentiles do not sum.
const histogramRowsP = executeQuery(
buildHistogramSql(monitorId, interval, hasAttemptsField, hasStatusReasonField),
startTime,
endTime,
"logs",
);
const kpiPromise = Promise.all([
histogramRowsP,
executeQuery(buildP95Sql(monitorId), startTime, endTime, "logs"),
executeQuery(buildLastRunSql(monitorId), startTime, endTime, "logs"),
])
.then(([histogramRows, p95Rows, lastRunRows]) => {
kpi.value = deriveKpiFromHistogram(
histogramRows as Record<string, unknown>[],
p95Rows[0] ?? null,
lastRunRows[0] ?? null,
);
})
.catch((e: unknown) => {
kpi.value = { ...EMPTY_KPI };
kpiError.value = e instanceof Error ? e.message : String(e ?? "KPI query failed");
})
.finally(() => {
kpiLoading.value = false;
kpiHasLoadedOnce.value = true;
});
// Group 2: the same histogram rows feed the response-time and errors charts.
const histogramPromise = histogramRowsP
.then((histogramRows) => {
buckets.value = mapHistogram(histogramRows, startTime, endTime);
})
.catch((e: unknown) => {
buckets.value = [];
histogramError.value =
e instanceof Error ? e.message : String(e ?? "Histogram query failed");
})
.finally(() => {
histogramLoading.value = false;
histogramHasLoadedOnce.value = true;
});
// Group 3: Runs list — feeds timeline, breakdown cards, table,
// steps tab, and errors tab. Typically the slowest query.
const runsPromise = executeQuery(
buildRunsSql(monitorId, RUNS_QUERY_LIMIT, schemaFields),
startTime,
endTime,
"logs",
)
.then((runsRows) => {
runs.value = runsRows.map(mapRun);
})
.catch((e: unknown) => {
runs.value = [];
runsError.value = e instanceof Error ? e.message : String(e ?? "Runs query failed");
})
.finally(() => {
runsLoading.value = false;
runsHasLoadedOnce.value = true;
});
// Wait for all to settle so callers that await fetchAll still
// get a meaningful completion signal.
await Promise.all([kpiPromise, histogramPromise, runsPromise]);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to load results";
kpi.value = { ...EMPTY_KPI };
buckets.value = [];
} finally {
loading.value = false;
hasLoadedOnce.value = true;
}
}
async function fetchRun(
monitorId: string,
runId: string,
executionId: string,
startTime: number,
endTime: number,
): Promise<void> {
if (!monitorId || !runId || !executionId) return;
loading.value = true;
error.value = null;
runDetail.value = null;
try {
const rows = await executeQuery(
buildRunDetailSql(monitorId, runId, executionId, await fetchSchemaFields()),
startTime,
endTime,
"logs",
);
if (rows.length > 0) {
runDetail.value = mapRunDetail(rows[0]);
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to load run";
runDetail.value = null;
} finally {
loading.value = false;
hasLoadedOnce.value = true;
}
}
/** Detail row for a protocol (http/tcp/tls/ssh) run — no steps/replay. */
async function fetchProtocolRun(
monitorId: string,
runId: string,
executionId: string,
startTime: number,
endTime: number,
): Promise<void> {
if (!monitorId || !runId || !executionId) return;
loading.value = true;
error.value = null;
protocolRunDetail.value = null;
try {
const rows = await executeQuery(
buildProtocolRunDetailSql(monitorId, runId, executionId, await fetchSchemaFields()),
startTime,
endTime,
"logs",
);
if (rows.length > 0) {
protocolRunDetail.value = mapProtocolRunDetail(rows[0]);
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to load run";
protocolRunDetail.value = null;
} finally {
loading.value = false;
hasLoadedOnce.value = true;
}
}
/**
* Step aggregation for the Steps tab — fetched via the REST /runs API because
* the log stream doesn't carry the step-level JSON fields.
*
* Called on its own rather than from `fetchAll`, so the Steps tab pays for
* this only when it is opened or its window changes underneath it.
*/
async function fetchSteps(monitorId: string, startTime: number, endTime: number): Promise<void> {
if (!monitorId || !startTime || !endTime) return;
stepsLoading.value = true;
stepsError.value = null;
try {
stepStats.value = await fetchAndAggregateSteps(monitorId, startTime, endTime);
} catch (e: unknown) {
stepStats.value = emptyStepStats();
stepsError.value = e instanceof Error ? e.message : String(e ?? "Steps query failed");
} finally {
stepsLoading.value = false;
stepsHasLoadedOnce.value = true;
}
}
return {
kpi,
buckets,
runs,
runsTruncated,
runDetail,
protocolRunDetail,
loading,
error,
hasLoadedOnce,
kpiLoading,
histogramLoading,
runsLoading,
kpiHasLoadedOnce,
histogramHasLoadedOnce,
runsHasLoadedOnce,
stepsLoading,
stepsHasLoadedOnce,
stepStats,
kpiError,
histogramError,
runsError,
stepsError,
effectiveP95Ms,
fetchAll,
fetchRun,
fetchProtocolRun,
fetchSteps,
cancelAll,
};
}
export default useSyntheticResults;