Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cute-pandas-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kopai/ui": minor
"@kopai/sqlite-datasource": patch
---

Use kopai SDK to get dashboards; preserve NULL histogram/summary Sum on write path
6 changes: 6 additions & 0 deletions .changeset/fine-eyes-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kopai/sqlite-datasource": minor
"@kopai/ui": minor
---

Fix metrics rendering and sqlite data parsing
19 changes: 17 additions & 2 deletions packages/sqlite-datasource/src/datasource-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,8 @@ describe("OptimizedDatasource", () => {

describe("writeLogs", () => {
let testConnection: DatabaseSync;
let ds: datasource.WriteTelemetryDatasource;
let ds: datasource.WriteTelemetryDatasource &
datasource.ReadTelemetryDatasource;

beforeEach(async () => {
testConnection = initializeDatabase(":memory:");
Expand Down Expand Up @@ -881,7 +882,7 @@ describe("OptimizedDatasource", () => {
TraceFlags: 0n,
SeverityText: "",
SeverityNumber: 0n,
Body: "null",
Body: "",
LogAttributes: "{}",
ResourceAttributes: "{}",
ResourceSchemaUrl: "",
Expand All @@ -891,6 +892,20 @@ describe("OptimizedDatasource", () => {
ScopeAttributes: "{}",
ScopeSchemaUrl: "",
});

const readResult = await ds.getLogs({ limit: 10 });
expect(readResult.data).toHaveLength(1);
const log = readResult.data[0];
expect(log).toMatchObject({
Timestamp: "1000000000",
TraceId: "",
SpanId: "",
SeverityText: "",
SeverityNumber: 0,
Body: "",
ServiceName: "",
ScopeName: "minimal-scope",
});
});
});
});
35 changes: 22 additions & 13 deletions packages/sqlite-datasource/src/db-datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,7 @@ function toHistogramRow(
StartTimeUnix: nanosToSqlite(dataPoint.startTimeUnixNano),
TimeUnix: nanosToSqlite(dataPoint.timeUnixNano),
Count: Number(dataPoint.count ?? 0),
Sum: dataPoint.sum ?? 0,
Sum: dataPoint.sum ?? null,
BucketCounts: JSON.stringify(dataPoint.bucketCounts ?? []),
ExplicitBounds: JSON.stringify(dataPoint.explicitBounds ?? []),
Min: dataPoint.min ?? null,
Expand Down Expand Up @@ -1166,7 +1166,7 @@ function toExpHistogramRow(
StartTimeUnix: nanosToSqlite(dataPoint.startTimeUnixNano),
TimeUnix: nanosToSqlite(dataPoint.timeUnixNano),
Count: Number(dataPoint.count ?? 0),
Sum: dataPoint.sum ?? 0,
Sum: dataPoint.sum ?? null,
Scale: dataPoint.scale ?? 0,
ZeroCount: Number(dataPoint.zeroCount ?? 0),
PositiveOffset: dataPoint.positive?.offset ?? 0,
Expand Down Expand Up @@ -1224,7 +1224,7 @@ function toSummaryRow(
StartTimeUnix: nanosToSqlite(dataPoint.startTimeUnixNano),
TimeUnix: nanosToSqlite(dataPoint.timeUnixNano),
Count: Number(dataPoint.count ?? 0),
Sum: dataPoint.sum ?? 0,
Sum: dataPoint.sum ?? null,
"ValueAtQuantiles.Quantile": JSON.stringify(
quantileValues.map(
(q: otlpMetrics.SummaryDataPoint_ValueAtQuantile) => q.quantile ?? 0
Expand All @@ -1246,7 +1246,7 @@ function aggTemporalityToString(
}

function anyValueToSimple(value: otlp.AnyValue | undefined): unknown {
if (!value) return null;
if (!value) return undefined;
if (value.stringValue !== undefined) return value.stringValue;
if (value.boolValue !== undefined) return value.boolValue;
if (value.intValue !== undefined) return value.intValue;
Expand All @@ -1262,12 +1262,13 @@ function anyValueToSimple(value: otlp.AnyValue | undefined): unknown {
}
return obj;
}
return null;
return undefined;
}

function anyValueToBodyString(value: otlp.AnyValue | undefined): string {
const simple = anyValueToSimple(value);
if (typeof simple === "string") return simple;
if (simple === undefined || simple === null) return "";
return JSON.stringify(simple);
}

Expand Down Expand Up @@ -1345,7 +1346,15 @@ function parseJsonField(
): Record<string, AttributeValue> | undefined {
if (typeof value !== "string") return undefined;
try {
return JSON.parse(value);
const parsed = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null) return undefined;
// Strip null values — OTLP attributes with unrecognized AnyValue types
// were previously stored as null, which fails Zod attributeValue validation
const result: Record<string, AttributeValue> = {};
for (const [k, v] of Object.entries(parsed)) {
if (v != null) result[k] = v as AttributeValue;
}
return result;
Comment on lines +1349 to +1357
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filter non-primitive attribute values here too.

This only removes null; arrays and nested objects still get cast into AttributeValue even though the local contract is string | number | boolean. Because anyValueToSimple() can emit arrays/objects for arrayValue and kvlistValue, the read path can still return values that violate the declared type/schema.

Possible fix
-    const parsed = JSON.parse(value);
-    if (typeof parsed !== "object" || parsed === null) return undefined;
+    const parsed = JSON.parse(value);
+    if (
+      typeof parsed !== "object" ||
+      parsed === null ||
+      Array.isArray(parsed)
+    ) {
+      return undefined;
+    }

     // Strip null values — OTLP attributes with unrecognized AnyValue types
     // were previously stored as null, which fails Zod attributeValue validation
     const result: Record<string, AttributeValue> = {};
     for (const [k, v] of Object.entries(parsed)) {
-      if (v != null) result[k] = v as AttributeValue;
+      if (
+        typeof v === "string" ||
+        typeof v === "number" ||
+        typeof v === "boolean"
+      ) {
+        result[k] = v;
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsed = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null) return undefined;
// Strip null values — OTLP attributes with unrecognized AnyValue types
// were previously stored as null, which fails Zod attributeValue validation
const result: Record<string, AttributeValue> = {};
for (const [k, v] of Object.entries(parsed)) {
if (v != null) result[k] = v as AttributeValue;
}
return result;
const parsed = JSON.parse(value);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return undefined;
}
// Strip null values — OTLP attributes with unrecognized AnyValue types
// were previously stored as null, which fails Zod attributeValue validation
const result: Record<string, AttributeValue> = {};
for (const [k, v] of Object.entries(parsed)) {
if (
typeof v === "string" ||
typeof v === "number" ||
typeof v === "boolean"
) {
result[k] = v;
}
}
return result;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sqlite-datasource/src/db-datasource.ts` around lines 1349 - 1357,
The deserialization currently only strips nulls but still accepts arrays and
objects into AttributeValue, violating the expected simple type contract; when
building the result Record in db-datasource.ts (the parsed -> result loop),
filter values to only allow primitives (typeof v === "string" || typeof v ===
"number" || typeof v === "boolean") and skip arrays/objects (and nulls) so
returned AttributeValue values conform to the local schema and downstream zod
validation; adjust the loop that iterates Object.entries(parsed) (and any
related anyValueToSimple usage) to perform this primitive-type check before
assigning to result.

} catch {
return undefined;
}
Expand Down Expand Up @@ -1474,9 +1483,9 @@ function mapRowToOtelMetrics(
...base,
MetricType: "Histogram" as const,
Count: toNumber(row.Count),
Sum: row.Sum as number | undefined,
Min: row.Min as number | null | undefined,
Max: row.Max as number | null | undefined,
Sum: toNumber(row.Sum),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Min: row.Min == null ? (row.Min as null | undefined) : toNumber(row.Min),
Max: row.Max == null ? (row.Max as null | undefined) : toNumber(row.Max),
BucketCounts: parseNumberArrayField(row.BucketCounts),
ExplicitBounds: parseNumberArrayField(row.ExplicitBounds),
AggregationTemporality: row.AggregationTemporality as string | undefined,
Expand All @@ -1488,9 +1497,9 @@ function mapRowToOtelMetrics(
...base,
MetricType: "ExponentialHistogram" as const,
Count: toNumber(row.Count),
Sum: row.Sum as number | undefined,
Min: row.Min as number | null | undefined,
Max: row.Max as number | null | undefined,
Sum: toNumber(row.Sum),
Min: row.Min == null ? (row.Min as null | undefined) : toNumber(row.Min),
Max: row.Max == null ? (row.Max as null | undefined) : toNumber(row.Max),
Scale: toNumber(row.Scale),
ZeroCount: toNumber(row.ZeroCount),
PositiveOffset: toNumber(row.PositiveOffset),
Expand All @@ -1506,7 +1515,7 @@ function mapRowToOtelMetrics(
...base,
MetricType: "Summary" as const,
Count: toNumber(row.Count),
Sum: row.Sum as number | undefined,
Sum: toNumber(row.Sum),
"ValueAtQuantiles.Quantile": parseNumberArrayField(
row["ValueAtQuantiles.Quantile"]
),
Expand Down
6 changes: 3 additions & 3 deletions packages/sqlite-datasource/src/db-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface OtelMetricsExponentialHistogram {
ScopeVersion: Generated<string>;
ServiceName: Generated<string>;
StartTimeUnix: bigint;
Sum: Generated<number>;
Sum: number | null;
TimeUnix: bigint;
ZeroCount: Generated<number>;
ZeroThreshold: Generated<number>;
Expand Down Expand Up @@ -111,7 +111,7 @@ export interface OtelMetricsHistogram {
ScopeVersion: Generated<string>;
ServiceName: Generated<string>;
StartTimeUnix: bigint;
Sum: Generated<number>;
Sum: number | null;
TimeUnix: bigint;
}

Expand Down Expand Up @@ -156,7 +156,7 @@ export interface OtelMetricsSummary {
ScopeVersion: Generated<string>;
ServiceName: Generated<string>;
StartTimeUnix: bigint;
Sum: Generated<number>;
Sum: number | null;
TimeUnix: bigint;
"ValueAtQuantiles.Quantile": Generated<string>;
"ValueAtQuantiles.Value": Generated<string>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,26 @@ function buildMetrics(rows: OtelMetricsRow[]): ParsedMetricGroup[] {
for (const row of rows) {
const name = row.MetricName ?? "unknown";
const type = row.MetricType;
if (

// Extract scalar value depending on metric type
let value: number | undefined;
if (type === "Gauge" || type === "Sum") {
value = "Value" in row ? row.Value : undefined;
} else if (
type === "Histogram" ||
type === "ExponentialHistogram" ||
type === "Summary"
)
continue; // TimeSeries only handles Gauge/Sum
) {
// Use mean (Sum/Count) for distribution metrics
const sum = "Sum" in row ? (row as { Sum?: number }).Sum : undefined;
const count =
"Count" in row ? (row as { Count?: number }).Count : undefined;
if (sum != null && count != null && count > 0) {
value = sum / count;
}
}

if (value === undefined) continue;

if (!metricMap.has(name)) metricMap.set(name, new Map());
if (!metricMeta.has(name))
Expand Down Expand Up @@ -136,8 +150,6 @@ function buildMetrics(rows: OtelMetricsRow[]): ParsedMetricGroup[] {
});
}

if (!("Value" in row)) continue;
const value = row.Value;
const timestamp = parseInt(row.TimeUnix, 10) / 1e6;
seriesMap.get(seriesKey)!.dataPoints.push({ timestamp, value });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ export function OtelLogTimeline(props: Props) {

const response = props.data as { data?: OtelLogsRow[] } | null;

const height = props.element.props.height ?? 600;

return (
<LogTimeline
rows={response?.data ?? []}
isLoading={props.loading}
error={props.error ?? undefined}
/>
<div style={{ height }} className="flex flex-col min-h-0">
<LogTimeline
rows={response?.data ?? []}
isLoading={props.loading}
error={props.error ?? undefined}
/>
</div>
);
}
1 change: 1 addition & 0 deletions packages/ui/src/hooks/use-kopai-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const createMockClient = () => ({
searchMetricsPage: vi.fn(),
getTrace: vi.fn(),
discoverMetrics: vi.fn(),
getDashboard: vi.fn(),
});

type MockClient = ReturnType<typeof createMockClient>;
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/hooks/use-live-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const createMockClient = () => ({
searchMetricsPage: vi.fn(),
getTrace: vi.fn(),
discoverMetrics: vi.fn(),
getDashboard: vi.fn(),
});

function wrapper(client: KopaiClient) {
Expand Down
23 changes: 11 additions & 12 deletions packages/ui/src/pages/observability.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,7 @@ describe("useDashboardTree validation", () => {
}

it("renders DynamicDashboard when API returns a valid uiTree", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ uiTree: VALID_TREE }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
mockClient.getDashboard.mockResolvedValueOnce({ uiTree: VALID_TREE });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

setURL("?tab=metrics&dashboardId=abc");

Expand All @@ -96,6 +91,10 @@ describe("useDashboardTree validation", () => {
expect(screen.getByText("Test Dashboard")).toBeTruthy();
});

expect(mockClient.getDashboard).toHaveBeenCalledWith(
"abc",
expect.anything()
);
expect(screen.queryByText(/invalid layout/i)).toBeNull();
});

Expand All @@ -107,12 +106,7 @@ describe("useDashboardTree validation", () => {
},
};

vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ uiTree: invalidTree }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
mockClient.getDashboard.mockResolvedValueOnce({ uiTree: invalidTree });

setURL("?tab=metrics&dashboardId=def");

Expand All @@ -125,5 +119,10 @@ describe("useDashboardTree validation", () => {
await waitFor(() => {
expect(screen.getByText(/invalid layout/i)).toBeTruthy();
});

expect(mockClient.getDashboard).toHaveBeenCalledWith(
"def",
expect.anything()
);
});
});
19 changes: 9 additions & 10 deletions packages/ui/src/pages/observability.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -663,8 +663,6 @@ function ServicesTab({
// Metrics tab — DynamicDashboard
// ---------------------------------------------------------------------------

const DASHBOARDS_API_BASE = "/dashboards";

const METRICS_TREE = {
root: "root",
elements: {
Expand Down Expand Up @@ -715,16 +713,17 @@ const METRICS_TREE = {
},
};

function useDashboardTree(dashboardId: string | null) {
function useDashboardTree(
client: Pick<KopaiClient, "getDashboard">,
dashboardId: string | null
) {
const { data, isFetching, error } = useQuery<UITree, Error>({
queryKey: ["dashboard-tree", dashboardId],
queryFn: async ({ signal }) => {
const res = await fetch(`${DASHBOARDS_API_BASE}/${dashboardId}`, {
signal,
});
if (!res.ok) throw new Error(`Failed to load dashboard: ${res.status}`);
const json = await res.json();
const parsed = observabilityCatalog.uiTreeSchema.safeParse(json.uiTree);
const dashboard = await client.getDashboard(dashboardId!, { signal });
const parsed = observabilityCatalog.uiTreeSchema.safeParse(
dashboard.uiTree
);
if (!parsed.success) {
const issue = parsed.error.issues[0];
const path = issue?.path.length ? issue.path.join(".") + ": " : "";
Expand All @@ -747,7 +746,7 @@ function useDashboardTree(dashboardId: string | null) {
function MetricsTab() {
const kopaiClient = useKopaiSDK();
const { dashboardId } = useURLState();
const { loading, error, tree } = useDashboardTree(dashboardId);
const { loading, error, tree } = useDashboardTree(kopaiClient, dashboardId);

if (loading)
return (
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/providers/kopai-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type KopaiClient = Pick<
| "searchMetricsPage"
| "getTrace"
| "discoverMetrics"
| "getDashboard"
>;

interface KopaiSDKContextValue {
Expand Down
2 changes: 1 addition & 1 deletion skills/create-dashboard/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: create-dashboard
description: Create a dashboard with valid uiTree using the component schema. Use when building observability dashboards or metric visualizations.
description: Create observability dashboards from OTEL metrics, logs, and traces using Kopai. Use when building metric visualizations, monitoring views, KPI panels, or when the user wants to see their telemetry data in a dashboard — even if they don't say "dashboard" explicitly. Also use when other skills or workflows need to present telemetry data visually (e.g. after root cause analysis).
license: Apache-2.0
metadata:
author: kopai
Expand Down
Loading