Debug production issues from telemetry (traces, logs, metrics). Kopai's analytical power — error rate, throughput, latency, group-by, time-series bucketing — lives in the SDK query API, which the CLI does not expose. So lead every investigation by writing a short TypeScript script; the CLI stays as a quick-lookup fallback.
Prerequisites
- Services are sending OpenTelemetry data to Kopai (see the
otel-instrumentationskill). @kopai/sdkis installed in the project:npm i @kopai/sdk(it ships the query API and the.kopaircreader).
Code mode (recommended)
Connect — clientFromConfig() reads .kopairc exactly like the CLI:
import { clientFromConfig } from "@kopai/sdk/node";
// Reads ./.kopairc then ~/.kopairc; defaults to http://localhost:8000.
// Override any field: clientFromConfig({ url, token, configPath, timeout }).
const client = clientFromConfig();
Run — npx tsx rca.mts. Use the .mts extension: it is always an ES module, so
top-level await works without depending on the host project's package.json "type".
(If tsx is missing: node --experimental-strip-types rca.mts.)
Build a query with kq, run it with client.query(q). client.query(q) is the
single clean way to run any built query and is fully typed — rows autocomplete and
typos are compile errors. Aggregate queries return { data }; raw queries return
{ data, nextCursor }. Result rows are fully typed — iterate data directly and never
cast to any/any[]. Aggregate measure values are number (so row.error_rate * 100
just works — no String(...)/cast), grouped dimensions are present (row["service.name"]),
and .timeSeries() rows add bucket_start:
const { data } = await client.query(q); // typed rows — do NOT write `data as any[]`
for (const row of data) {
console.log(
row["service.name"],
`${(row.error_rate * 100).toFixed(1)}%`,
row.rps
);
}
Output — stdout is your result. End with console.log(JSON.stringify(data, null, 2)),
and wrap .build() + the query call in try/catch:
import { kq, KopaiQueryBuildError } from "@kopai/sdk";
try {
const q = kq.traces
.aggregate() /* … */
.build();
const { data } = await client.query(q);
console.log(JSON.stringify(data, null, 2));
} catch (e) {
if (e instanceof KopaiQueryBuildError)
console.error(e.issues); // {path,message}[]
else throw e;
}
Backend caveats & gotchas (read before querying)
- Every query needs a time window —
.timeRelative("1h")or.timeAbsolute(startISO, endISO). Lookback/granularity match^[1-9]\d*[smhdw]$("30s","15m","2h","7d"). StatusCodeis exactly"Unset" | "Ok" | "Error"(title case). Successful spans are usually"Unset", not"Ok". This enum is type-checked: a wrong value like"ERROR"is a compile error, so the casing trap is caught for you. Prefer theerrorRatemeasure (counts errors server-side) over filtering the raw value anyway.- Use
service.name(dotted), neverServiceName.ServiceNameis not a queryable column — filtering on it silently matches nothing. (In result rows the value comes back keyed as you grouped it, e.g.row["service.name"].) - Empty result on a busy system ≈ a wrong column/value, not real absence. Before concluding "no errors / no data", re-check the column name and value casing — silent empties are the #1 way to reach a false "all healthy" conclusion.
- Percentiles (
p50–p999) are ClickHouse-only. On SQLite they fail at query time (KopaiError: Percentile measures … not yet supported on the sqlite backend). Lead latency withavg/maxofDuration; treat percentiles as a ClickHouse upgrade in try/catch. Durationfilters accept duration strings with unitss/m/h/d/w—f.gt("Duration", "1s"),f.lte("Duration", "2h")(alsogte/lt). No sub-second units; for sub-second thresholds pass a nanosecond number. (Result rows still reportDurationin nanoseconds —avg/maxofDurationcome back in ns: 1ms = 1e6, 1s = 1e9.)- Metric queries choose the type up front:
kq.metrics("Gauge")…,kq.metrics("Sum")…(type ∈"Gauge" | "Sum" | "Histogram" | "ExponentialHistogram" | "Summary"). The builder arg auto-pins theMetricType, so no manual.where(f => f.eq("MetricType", …)). Value columns are typed per type: Gauge/Sum →"Value"; Histogram/ExponentialHistogram →"Count" | "Sum" | "Min" | "Max"; Summary →"Count" | "Sum". searchTraces/searchLogs/searchMetricsare async iterables (auto-paginate) — consume withfor await (const row of client.searchLogs({ … })) { … }, do notawaitthem as an array. For a single page usesearchTracesPage/searchLogsPage/searchMetricsPage→{ data, nextCursor }. Limits:kq.limit()caps at 10000;search*filters cap at 1000.
RCA Workflow
-
Find the failing work — rank services by error rate and throughput.
errorRatehandlesStatusCodeserver-side, so you never guess the value:const q = kq.traces .aggregate() .measure((m) => m.errorRate("error_rate")) .measure((m) => m.throughput("rps")) .measure((m) => m.count("spans")) .dimension("service.name") .timeRelative("1h") .summary() .orderByMeasure("error_rate", "desc") .build(); const { data } = await client.query(q);For log-first triage, pull error-level logs by
SeverityNumber >= 17(catches ERROR/FATAL regardless of text casing):client.query(kq.logs.raw().where(f => f.gte("SeverityNumber", 17)).timeRelative("1h").limit(50).build()). Seeworkflow-find-errors. -
Get full trace context —
const spans = await client.getTrace(traceId). InspectDuration(bottlenecks),ParentSpanId(call chain),StatusMessage,SpanKind. Seeworkflow-get-context. -
Correlate logs to the trace —
client.query(kq.logs.raw().where(f => f.eq("TraceId", traceId)).timeRelative("1h").build())(or iterateclient.searchLogs({ traceId })). Look for the earliest error and its stack trace. Seeworkflow-correlate-logs. -
Quantify impact / pinpoint onset — the upgrade the CLI can't do. Bucket the metric over time to see when it started and how wide the blast radius is. Watch the retention floor: if the earliest bucket is already elevated, the true onset is at or before it — say so rather than calling the floor a "step change":
const q = kq.traces .aggregate() .measure((m) => m.errorRate("error_rate")) .measure((m) => m.avg("Duration", "avg_ns")) .measure((m) => m.max("Duration", "max_ns")) .dimension("service.name") .timeRelative("3h") .timeSeries("5m") .orderByMeasure("error_rate", "desc") .build(); const { data } = await client.query(q);When the signal is a metric rather than a trace, choose the type up front and let it auto-pin
MetricType— e.g. bucket a Gauge over time:const q = kq .metrics("Gauge") .aggregate() .measure((m) => m.avg("Value", "avg_value")) .where((f) => f.eq("MetricName", "process.cpu.utilization")) .timeRelative("3h") .timeSeries("5m") .build(); const { data } = await client.query(q);See
workflow-check-metrics. -
Present findings — root cause with evidence (specific TraceIds, log entries, metric deltas), blast radius, and a suggested fix. Offer to build an incident dashboard (see the
create-dashboardskill). Seeworkflow-identify-cause.
Quick Example
// rca-triage.mts — run: npx tsx rca-triage.mts
import { kq, KopaiQueryBuildError } from "@kopai/sdk";
import { clientFromConfig } from "@kopai/sdk/node";
const client = clientFromConfig();
try {
const q = kq.traces
.aggregate()
.measure((m) => m.errorRate("error_rate"))
.measure((m) => m.throughput("rps"))
.measure((m) => m.avg("Duration", "avg_ns"))
.measure((m) => m.max("Duration", "max_ns"))
.measure((m) => m.count("spans"))
.dimension("service.name")
.timeRelative("1h")
.summary()
.orderByMeasure("error_rate", "desc")
.build();
const { data } = await client.query(q); // typed rows
console.log(JSON.stringify(data, null, 2));
} catch (e) {
if (e instanceof KopaiQueryBuildError) console.error(e.issues);
else throw e;
}
Rules
1. Workflow (CRITICAL)
workflow-find-errors- Find error traces and error-level logsworkflow-get-context- Get full trace contextworkflow-correlate-logs- Correlate logs with a traceworkflow-check-metrics- Quantify impact with aggregate/time-series queriesworkflow-identify-cause- Identify root cause & present findings
2. Patterns (HIGH)
pattern-http-errors- HTTP error debuggingpattern-slow-requests- Slow request analysispattern-distributed- Distributed failure tracingpattern-log-driven- Log-driven investigation
Read rules/<rule-name>.md for details.
Tips
- Start from the aggregate (error rate / latency by service), then drill into one trace.
- Prefer the
errorRatemeasure over filteringStatusCode(counted server-side). - Run any built query with
client.query(q)— it is fully typed (rows autocomplete, typos compile-error). - Filter
Durationwith strings (f.gt("Duration", "1s")); result rows still reportDurationin nanoseconds. The earliest error in a trace chain is usually closest to root cause. - Use
.timeSeries(granularity)to find when a regression started — and don't mistake the retention floor for the onset. - For metrics, pick the type up front —
kq.metrics("Gauge")…(auto-pinsMetricType, types the value columns). - On SQLite, latency =
avg/maxofDuration; switch top95/p99on ClickHouse.
CLI fallback (quick one-offs)
The CLI is fine for a single lookup, but has no query/aggregation command. Note the
StatusCode value is the literal "Error".
npx @kopai/cli traces search --status-code Error --limit 20 --json
npx @kopai/cli traces get <traceId> --json
npx @kopai/cli logs search --trace-id <traceId> --severity-min 17 --json
npx @kopai/cli metrics discover --json
References
- trace-filters - Trace columns, filter/measure ops, containers
- log-filters - Log columns, severity model, filter ops
- metric-filters - Metric columns, MetricType pin, aggregations