Build Kopai observability dashboards from OpenTelemetry metrics, logs, and traces by composing tiles — time-series charts, stat/KPI numbers, histograms, log timelines, and trace-detail views — into a dashboard via the Kopai SDK. Use this skill whenever the user wants to visualize, chart, monitor, or 'keep an eye on' telemetry — e.g. 'make a dashboard', 'monitoring view', 'KPI panel', 'chart request latency and throughput', 'an incident board with error logs and a trace viewer' — even if they don't say the word 'dashboard' — and when another workflow needs to present telemetry visually (e.g. after a root-cause analysis). This creates Kopai dashboards specifically. Do NOT use it to investigate or root-cause an issue (use root-cause-analysis), to add instrumentation (use otel-instrumentation), to build Grafana/Prometheus dashboards, or to code React/UI chart components.
77
97%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Code mode: build each leaf tile's data with kq, embed it as dataSource: { method: "query", params: <builtQuery> }, then create the dashboard with client.createDashboard(...). Run with npx tsx <name>.mts (.mts so top-level await works regardless of host module type).
Discover metrics — const { metrics } = await client.discoverMetrics(); Each entry is { name, type, unit, description, attributes, resourceAttributes }. Pass the metric's type as the kq.metrics("<Type>") builder argument; set the tile unit prop from the metric's unit.
Build each tile's query — data tiles render raw rows, so build with kq.<signal>.raw()…build() → a KopaiQuery held in a variable (metrics: kq.metrics("<Type>").raw()…build()). Every query needs a time window (.timeRelative("1h") / .timeAbsolute(startISO, endISO)). (Aggregates — .aggregate()…summary()/.timeSeries(granularity) — are for analysis, not tile data; tile renderers reject aggregate/summary/timeSeries rows.)
Assemble the uiTree — embed the built query: dataSource: { method: "query", params: builtQuery }.
Create — wrap in try/catch:
import { kq, KopaiQueryBuildError } from "@kopai/sdk";
import { clientFromConfig } from "@kopai/sdk/node";
const client = clientFromConfig();
const { metrics } = await client.discoverMetrics();
// Leaf tile data — RAW query; MetricType is the builder arg (auto-pins it).
const cpuSeries = kq
.metrics("Gauge")
.raw()
.where((f) => f.eq("MetricName", "system.cpu.utilization"))
.timeRelative("1h")
.limit(500)
.build();
const uiTree = {
root: "stack-1",
elements: {
"stack-1": {
key: "stack-1",
type: "Stack",
props: { direction: "vertical", gap: "md", align: null },
children: ["card-1"],
parentKey: "",
},
"card-1": {
key: "card-1",
type: "Card",
props: { title: "CPU Usage", description: null, padding: null },
children: ["ts-1"],
parentKey: "stack-1",
},
"ts-1": {
key: "ts-1",
type: "MetricTimeSeries",
props: { height: 300, showBrush: null, yAxisLabel: null, unit: "1" }, // unit from the metric's OTEL unit; null for unused nullable props
children: [],
parentKey: "card-1",
dataSource: { method: "query", params: cpuSeries },
},
},
};
try {
const dash = await client.createDashboard({
name: "CPU Dashboard",
uiTreeVersion: "0.14.0", // SDK field is uiTreeVersion (CLI flag is --tree-version)
uiTree,
metadata: {},
});
console.log(dash.id);
} catch (err) {
// On KopaiValidationError/KopaiError: re-run discoverMetrics() to recheck
// names + types, fix the tree, retry. KopaiQueryBuildError = bad kq query.
console.error(err);
}View — <baseUrl>/?tab=metrics&dashboardId=<dash.id>.
key, type, children array, parentKey, propsparentKey is ""elementsparentKey must reference an existing elementchildren array must list keys of child elementsdataSource; layout components (Stack/Grid/Card) do not.dataSource.method must be valid. Prefer "query" with a built kq query as params. Legacy methods still work as fallback: searchMetricsPage, searchAggregatedMetrics, searchLogsPage, searchTracesPage.dataSource.params must match the method: a kq KopaiQuery for "query", or the method's own param schema for legacy methods.kq query with kq.<signal>.raw()…build(), not .aggregate(). The renderers reject aggregate/summary/timeSeries rows ("this panel displays raw metric rows … use a raw metric query").MetricType as the builder argument — kq.metrics("Gauge").raw() — which auto-pins it. Use the exact type from discoverMetrics() (Gauge | Sum | Histogram | ExponentialHistogram | Summary); also pin MetricName to a real name from discover. Add a time window and .limit(...).name — dashboard display nameuiTreeVersion — semver string, "0.14.0" (the SDK field; the CLI flag is --tree-version)uiTree — the component tree object with root and elementsmetadata — pass {} if unusedunit on MetricTimeSeries and MetricHistogram to the raw OTEL unit from discoverMetrics() (e.g. "By", "s", "ms", "1", "{requests}")unit + data rangeyAxisLabel is an optional override — only set when the auto-derived label isn't descriptive enoughStack with direction: "vertical" as root for simple dashboardsGrid with columns: 2 or columns: 3 for metric gridsCard with a descriptive titleMetricStat for KPI overview, MetricTimeSeries for trendsMetricHistogram only for Histogram/ExponentialHistogram metric typesheight: 600 on LogTimeline — smaller values collapse the log content to a count badgeheight: 300 on MetricTimeSeries and MetricHistogramkq.metrics("Sum").raw().where(f => f.eq("MetricName", NAME)).timeRelative("1h").limit(500).build()
— or legacy method: "searchAggregatedMetrics" with aggregate: "sum" (also "avg"/"min"/"max"/"count"). Do NOT group by a dimension with MetricStat (use MetricTable for grouped results).Always check the metric's type from discoverMetrics(). Mismatched types render empty or show "--".
For Histogram metrics: use MetricHistogram for distribution views, or MetricTimeSeries for trends over time (renders mean = Sum/Count). MetricStat is NOT compatible with Histogram.
If discoverMetrics() returns an empty metrics array, telemetry hasn't reached Kopai yet.
discoverMetrics() (or client.getServices()) means it's reachable.Wrap the create in try/catch. On KopaiValidationError/KopaiError, re-run discoverMetrics() to recheck names and types, fix the tree, and retry. KopaiQueryBuildError means the kq query is malformed — fix it before reassembling the tree. Common messages:
kq.metrics("<Type>") builder argument doesn't match the actual type from discoverMetrics(). Use the exact type value.npx @kopai/cli dashboards schema).parentKey references a non-existent key. Verify all parent-child relationships.root doesn't match any key in elements.Do not guess — always validate against the schema and discoverMetrics() output.
Display the URL to the user:
<baseUrl>/?tab=metrics&dashboardId=<id><id> — the id field on the returned Dashboard<baseUrl> — the backend URL from .kopairc (default http://localhost:8000)Common pitfalls:
SeverityNumber >= 17: kq.logs.raw().where(f => f.gte("SeverityNumber", 17)).timeRelative("1h").limit(100).build().)"Error" (also "Unset"/"Ok"); successful spans are usually "Unset".p50–p999) are ClickHouse-only and hard-fail on SQLite at query time. Lead latency tiles with avg/max of Duration (nanoseconds: 1ms = 1e6, 1s = 1e9).The CLI takes the tree on stdin and uses --tree-version (the SDK field is uiTreeVersion). It cannot build kq queries, so tiles use the legacy searchMetricsPage/searchLogsPage form:
echo '{"uiTree":{"root":"stack-1","elements":{"stack-1":{"key":"stack-1","type":"Stack","props":{"direction":"vertical","gap":"md","align":null},"children":["card-1"],"parentKey":""},"card-1":{"key":"card-1","type":"Card","props":{"title":"CPU Usage","description":null,"padding":null},"children":["ts-1"],"parentKey":"stack-1"},"ts-1":{"key":"ts-1","type":"MetricTimeSeries","props":{"height":300,"showBrush":null,"yAxisLabel":null,"unit":"1"},"children":[],"parentKey":"card-1","dataSource":{"method":"searchMetricsPage","params":{"metricType":"Gauge","metricName":"system.cpu.utilization"}}}}},"metadata":{}}' | npx @kopai/cli dashboards create --name "<name>" --tree-version "0.14.0" --json