From 7c18cbf690d9a578c58f23d90f5969c1e5161d0b Mon Sep 17 00:00:00 2001 From: Vladimir Adamic Date: Thu, 12 Mar 2026 18:54:56 +0100 Subject: [PATCH 01/14] feat: jaeger-like traces --- packages/api/src/index.test.ts | 18 + packages/api/src/routes/traces.ts | 67 ++- packages/api/src/signals.test.ts | 18 + .../clickhouse-datasource/src/datasource.ts | 241 ++++++++- .../clickhouse-datasource/src/query-traces.ts | 138 +++++ packages/core/src/data-filters-zod.ts | 37 ++ packages/core/src/telemetry-datasource.ts | 25 +- packages/sdk/src/client.ts | 72 +++ packages/sdk/src/index.ts | 2 + packages/sdk/src/types.ts | 3 + .../src/datasource-read.test.ts | 426 +++++++++++++++ .../sqlite-datasource/src/db-datasource.ts | 290 ++++++++++ .../src/optimized-datasource.ts | 19 + .../DynamicDashboard.test.tsx | 5 + .../observability/TraceComparison/index.tsx | 332 ++++++++++++ .../observability/TraceDetail/index.tsx | 4 +- .../observability/TraceSearch/DurationBar.tsx | 37 ++ .../observability/TraceSearch/ScatterPlot.tsx | 135 +++++ .../observability/TraceSearch/SearchForm.tsx | 181 +++++++ .../TraceSearch/SortDropdown.tsx | 32 ++ .../observability/TraceSearch/index.tsx | 440 +++++++-------- .../TraceTimeline/FlamegraphView.tsx | 243 +++++++++ .../observability/TraceTimeline/GraphView.tsx | 319 +++++++++++ .../observability/TraceTimeline/Minimap.tsx | 240 +++++++++ .../observability/TraceTimeline/SpanRow.tsx | 43 +- .../TraceTimeline/SpanSearch.tsx | 76 +++ .../TraceTimeline/StatisticsView.tsx | 227 ++++++++ .../observability/TraceTimeline/TimeRuler.tsx | 54 ++ .../TraceTimeline/TimelineBar.tsx | 20 +- .../TraceTimeline/TraceHeader.tsx | 167 ++++-- .../observability/TraceTimeline/ViewTabs.tsx | 33 ++ .../observability/TraceTimeline/index.tsx | 317 ++++++++--- .../ui/src/components/observability/index.ts | 15 + packages/ui/src/hooks/use-kopai-data.test.ts | 3 + packages/ui/src/hooks/use-kopai-data.ts | 11 + packages/ui/src/hooks/use-live-logs.test.ts | 3 + .../generate-prompt-instructions.test.ts.snap | 2 +- packages/ui/src/lib/component-catalog.ts | 15 + packages/ui/src/pages/observability.test.tsx | 5 + packages/ui/src/pages/observability.tsx | 500 ++++++++++-------- packages/ui/src/providers/kopai-provider.tsx | 3 + 41 files changed, 4237 insertions(+), 581 deletions(-) create mode 100644 packages/ui/src/components/observability/TraceComparison/index.tsx create mode 100644 packages/ui/src/components/observability/TraceSearch/DurationBar.tsx create mode 100644 packages/ui/src/components/observability/TraceSearch/ScatterPlot.tsx create mode 100644 packages/ui/src/components/observability/TraceSearch/SearchForm.tsx create mode 100644 packages/ui/src/components/observability/TraceSearch/SortDropdown.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/FlamegraphView.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/GraphView.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/Minimap.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/SpanSearch.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/StatisticsView.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/TimeRuler.tsx create mode 100644 packages/ui/src/components/observability/TraceTimeline/ViewTabs.tsx diff --git a/packages/api/src/index.test.ts b/packages/api/src/index.test.ts index c4d0c55..2d664c2 100644 --- a/packages/api/src/index.test.ts +++ b/packages/api/src/index.test.ts @@ -23,6 +23,15 @@ describe("apiRoutes", () => { let discoverMetricsSpy: ReturnType< typeof vi.fn >; + let getServicesSpy: ReturnType< + typeof vi.fn + >; + let getOperationsSpy: ReturnType< + typeof vi.fn + >; + let getTraceSummariesSpy: ReturnType< + typeof vi.fn + >; beforeEach(async () => { getTracesSpy = vi.fn(); @@ -30,6 +39,12 @@ describe("apiRoutes", () => { getMetricsSpy = vi.fn(); discoverMetricsSpy = vi.fn(); + getServicesSpy = + vi.fn(); + getOperationsSpy = + vi.fn(); + getTraceSummariesSpy = + vi.fn(); server = Fastify(); await server.register(signalsRoutes, { readTelemetryDatasource: { @@ -37,6 +52,9 @@ describe("apiRoutes", () => { getLogs: getLogsSpy, getMetrics: getMetricsSpy, discoverMetrics: discoverMetricsSpy, + getServices: getServicesSpy, + getOperations: getOperationsSpy, + getTraceSummaries: getTraceSummariesSpy, }, }); await server.ready(); diff --git a/packages/api/src/routes/traces.ts b/packages/api/src/routes/traces.ts index 58e58f8..bde29e8 100644 --- a/packages/api/src/routes/traces.ts +++ b/packages/api/src/routes/traces.ts @@ -8,7 +8,8 @@ import { import { problemDetailsSchema } from "./error-schema-zod.js"; export const tracesRoutes: FastifyPluginAsyncZod<{ - readTracesDatasource: datasource.ReadTracesDatasource; + readTracesDatasource: datasource.ReadTracesDatasource & + datasource.ReadTracesMetaDatasource; }> = async function (fastify, opts) { fastify.route({ method: "GET", @@ -58,4 +59,68 @@ export const tracesRoutes: FastifyPluginAsyncZod<{ res.send(result); }, }); + + fastify.route({ + method: "GET", + url: "/signals/services", + schema: { + description: "List distinct service names", + response: { + 200: z.object({ services: z.array(z.string()) }), + "4xx": problemDetailsSchema, + "5xx": problemDetailsSchema, + }, + }, + handler: async (req, res) => { + const result = await opts.readTracesDatasource.getServices({ + requestContext: req.requestContext, + }); + res.send(result); + }, + }); + + fastify.route({ + method: "GET", + url: "/signals/traces/operations", + schema: { + description: "List distinct operations for a service", + querystring: z.object({ serviceName: z.string() }), + response: { + 200: z.object({ operations: z.array(z.string()) }), + "4xx": problemDetailsSchema, + "5xx": problemDetailsSchema, + }, + }, + handler: async (req, res) => { + const result = await opts.readTracesDatasource.getOperations({ + serviceName: req.query.serviceName, + requestContext: req.requestContext, + }); + res.send(result); + }, + }); + + fastify.route({ + method: "POST", + url: "/signals/traces/summaries", + schema: { + description: "Search trace summaries", + body: dataFilterSchemas.traceSummariesFilterSchema, + response: { + 200: z.object({ + data: z.array(dataFilterSchemas.traceSummaryRowSchema), + nextCursor: z.string().nullable(), + }), + "4xx": problemDetailsSchema, + "5xx": problemDetailsSchema, + }, + }, + handler: async (req, res) => { + const result = await opts.readTracesDatasource.getTraceSummaries({ + ...req.body, + requestContext: req.requestContext, + }); + res.send(result); + }, + }); }; diff --git a/packages/api/src/signals.test.ts b/packages/api/src/signals.test.ts index 8197c54..dc1509a 100644 --- a/packages/api/src/signals.test.ts +++ b/packages/api/src/signals.test.ts @@ -23,6 +23,15 @@ describe("signalsRoutes", () => { let discoverMetricsSpy: ReturnType< typeof vi.fn >; + let getServicesSpy: ReturnType< + typeof vi.fn + >; + let getOperationsSpy: ReturnType< + typeof vi.fn + >; + let getTraceSummariesSpy: ReturnType< + typeof vi.fn + >; beforeEach(async () => { getTracesSpy = vi.fn(); @@ -30,6 +39,12 @@ describe("signalsRoutes", () => { getMetricsSpy = vi.fn(); discoverMetricsSpy = vi.fn(); + getServicesSpy = + vi.fn(); + getOperationsSpy = + vi.fn(); + getTraceSummariesSpy = + vi.fn(); server = Fastify(); await server.register(signalsRoutes, { readTelemetryDatasource: { @@ -37,6 +52,9 @@ describe("signalsRoutes", () => { getLogs: getLogsSpy, getMetrics: getMetricsSpy, discoverMetrics: discoverMetricsSpy, + getServices: getServicesSpy, + getOperations: getOperationsSpy, + getTraceSummaries: getTraceSummariesSpy, }, }); await server.ready(); diff --git a/packages/clickhouse-datasource/src/datasource.ts b/packages/clickhouse-datasource/src/datasource.ts index cf2e500..e31dd13 100644 --- a/packages/clickhouse-datasource/src/datasource.ts +++ b/packages/clickhouse-datasource/src/datasource.ts @@ -11,7 +11,12 @@ import { type Logger, type ClickHouseRequestContext, } from "./types.js"; -import { buildTracesQuery } from "./query-traces.js"; +import { + buildTracesQuery, + buildServicesQuery, + buildOperationsQuery, + buildTraceSummariesQuery, +} from "./query-traces.js"; import { buildLogsQuery } from "./query-logs.js"; import { buildMetricsQuery, @@ -306,6 +311,240 @@ export class ClickHouseReadDatasource return { data, nextCursor }; } + async getServices(opts?: { + requestContext?: unknown; + }): Promise<{ services: string[] }> { + assertClickHouseRequestContext(opts?.requestContext); + const { database, username, password } = opts.requestContext; + const log = getLogger(opts.requestContext); + const start = performance.now(); + + let chNode: string | undefined; + try { + const { query, params } = buildServicesQuery(); + + const resultSet = await this.client.query({ + query, + query_params: params, + format: "JSONEachRow", + auth: { username, password }, + http_headers: { "X-ClickHouse-Database": database }, + }); + chNode = getChNode(resultSet); + + const services: string[] = []; + for await (const batch of resultSet.stream()) { + for (const row of batch) { + const json = row.json() as { ServiceName: string }; + services.push(json.ServiceName); + } + } + + const durationMs = Math.round(performance.now() - start); + log.info( + { + database, + username, + method: "getServices", + durationMs, + rowCount: services.length, + chNode, + }, + "query complete" + ); + return { services }; + } catch (err) { + const durationMs = Math.round(performance.now() - start); + log.error( + { + database, + username, + method: "getServices", + durationMs, + chNode, + err, + }, + "query failed" + ); + throw err; + } + } + + async getOperations(filter: { + serviceName: string; + requestContext?: unknown; + }): Promise<{ operations: string[] }> { + assertClickHouseRequestContext(filter.requestContext); + const { database, username, password } = filter.requestContext; + const log = getLogger(filter.requestContext); + const start = performance.now(); + + let chNode: string | undefined; + try { + const { query, params } = buildOperationsQuery(filter); + + const resultSet = await this.client.query({ + query, + query_params: params, + format: "JSONEachRow", + auth: { username, password }, + http_headers: { "X-ClickHouse-Database": database }, + }); + chNode = getChNode(resultSet); + + const operations: string[] = []; + for await (const batch of resultSet.stream()) { + for (const row of batch) { + const json = row.json() as { SpanName: string }; + operations.push(json.SpanName); + } + } + + const durationMs = Math.round(performance.now() - start); + log.info( + { + database, + username, + method: "getOperations", + durationMs, + rowCount: operations.length, + chNode, + }, + "query complete" + ); + return { operations }; + } catch (err) { + const durationMs = Math.round(performance.now() - start); + log.error( + { + database, + username, + method: "getOperations", + durationMs, + chNode, + err, + }, + "query failed" + ); + throw err; + } + } + + async getTraceSummaries( + filter: dataFilterSchemas.TraceSummariesFilter & { + requestContext?: unknown; + } + ): Promise<{ + data: dataFilterSchemas.TraceSummaryRow[]; + nextCursor: string | null; + }> { + assertClickHouseRequestContext(filter.requestContext); + const { database, username, password } = filter.requestContext; + const log = getLogger(filter.requestContext); + const start = performance.now(); + + let chNode: string | undefined; + try { + const { query, params } = buildTraceSummariesQuery(filter); + + const resultSet = await this.client.query({ + query, + query_params: params, + format: "JSONEachRow", + auth: { username, password }, + http_headers: { "X-ClickHouse-Database": database }, + }); + chNode = getChNode(resultSet); + + const rawRows: Array<{ + TraceId: string; + rootServiceName: string; + rootSpanName: string; + startTimeNs: string; + durationNs: string; + spanCount: number; + errorCount: number; + _serviceData: Array<[string, string]>; + }> = []; + for await (const batch of resultSet.stream()) { + for (const row of batch) { + rawRows.push(row.json() as (typeof rawRows)[number]); + } + } + + const limit = filter.limit ?? 20; + const hasMore = rawRows.length > limit; + const items = hasMore ? rawRows.slice(0, limit) : rawRows; + + const data: dataFilterSchemas.TraceSummaryRow[] = items.map((r) => { + // Aggregate per-service breakdown from _serviceData tuples + const serviceMap = new Map< + string, + { count: number; hasError: boolean } + >(); + for (const [svcName, statusCode] of r._serviceData) { + const existing = serviceMap.get(svcName); + if (existing) { + existing.count++; + if (statusCode === "STATUS_CODE_ERROR") existing.hasError = true; + } else { + serviceMap.set(svcName, { + count: 1, + hasError: statusCode === "STATUS_CODE_ERROR", + }); + } + } + + return { + traceId: r.TraceId, + rootServiceName: r.rootServiceName || "", + rootSpanName: r.rootSpanName || "", + startTimeNs: r.startTimeNs, + durationNs: r.durationNs, + spanCount: r.spanCount, + errorCount: r.errorCount, + services: Array.from(serviceMap.entries()).map(([name, s]) => ({ + name, + count: s.count, + hasError: s.hasError, + })), + }; + }); + + const lastRow = items[items.length - 1]; + const nextCursor = + hasMore && lastRow ? `${lastRow.startTimeNs}:${lastRow.TraceId}` : null; + + const durationMs = Math.round(performance.now() - start); + log.info( + { + database, + username, + method: "getTraceSummaries", + durationMs, + rowCount: rawRows.length, + chNode, + }, + "query complete" + ); + return { data, nextCursor }; + } catch (err) { + const durationMs = Math.round(performance.now() - start); + log.error( + { + database, + username, + method: "getTraceSummaries", + durationMs, + chNode, + err, + }, + "query failed" + ); + throw err; + } + } + async discoverMetrics(options?: { requestContext?: unknown; }): Promise { diff --git a/packages/clickhouse-datasource/src/query-traces.ts b/packages/clickhouse-datasource/src/query-traces.ts index 0efc3a3..92d1ac8 100644 --- a/packages/clickhouse-datasource/src/query-traces.ts +++ b/packages/clickhouse-datasource/src/query-traces.ts @@ -1,6 +1,144 @@ import type { dataFilterSchemas } from "@kopai/core"; import { nanosToDateTime64 } from "./timestamp.js"; +export function buildServicesQuery(): { + query: string; + params: Record; +} { + return { + query: `SELECT DISTINCT ServiceName FROM otel_traces ORDER BY ServiceName`, + params: {}, + }; +} + +export function buildOperationsQuery(filter: { serviceName: string }): { + query: string; + params: Record; +} { + return { + query: `SELECT DISTINCT SpanName FROM otel_traces WHERE ServiceName = {serviceName:String} ORDER BY SpanName`, + params: { serviceName: filter.serviceName }, + }; +} + +export function buildTraceSummariesQuery( + filter: dataFilterSchemas.TraceSummariesFilter +): { + query: string; + params: Record; +} { + const conditions: string[] = []; + const havingConditions: string[] = []; + const params: Record = {}; + const limit = filter.limit ?? 20; + const sortOrder = filter.sortOrder === "ASC" ? "ASC" : "DESC"; + + if (filter.serviceName) { + conditions.push("ServiceName = {serviceName:String}"); + params.serviceName = filter.serviceName; + } + if (filter.spanName) { + conditions.push("SpanName = {spanName:String}"); + params.spanName = filter.spanName; + } + if (filter.timestampMin != null) { + conditions.push("Timestamp >= {tsMin:DateTime64(9)}"); + params.tsMin = nanosToDateTime64(filter.timestampMin); + } + if (filter.timestampMax != null) { + conditions.push("Timestamp <= {tsMax:DateTime64(9)}"); + params.tsMax = nanosToDateTime64(filter.timestampMax); + } + if (filter.durationMin != null) { + conditions.push("Duration >= {durMin:UInt64}"); + params.durMin = filter.durationMin; + } + if (filter.durationMax != null) { + conditions.push("Duration <= {durMax:UInt64}"); + params.durMax = filter.durationMax; + } + + if (filter.spanAttributes) { + let i = 0; + for (const [key, value] of Object.entries(filter.spanAttributes)) { + conditions.push( + `SpanAttributes[{spanAttrKey${String(i)}:String}] = {spanAttrVal${String(i)}:String}` + ); + params[`spanAttrKey${String(i)}`] = key; + params[`spanAttrVal${String(i)}`] = value; + i++; + } + } + if (filter.resourceAttributes) { + let i = 0; + for (const [key, value] of Object.entries(filter.resourceAttributes)) { + conditions.push( + `ResourceAttributes[{resAttrKey${String(i)}:String}] = {resAttrVal${String(i)}:String}` + ); + params[`resAttrKey${String(i)}`] = key; + params[`resAttrVal${String(i)}`] = value; + i++; + } + } + + // Cursor pagination on (startTimeNs, TraceId) — applied as HAVING since startTimeNs is aggregate + if (filter.cursor) { + const colonIdx = filter.cursor.indexOf(":"); + if (colonIdx === -1) { + throw new Error("Invalid cursor format: expected '{timestamp}:{id}'"); + } + const cursorTs = filter.cursor.slice(0, colonIdx); + const cursorTraceId = filter.cursor.slice(colonIdx + 1); + if (!/^\d+$/.test(cursorTs)) { + throw new Error( + `Invalid cursor timestamp: expected numeric string, got '${cursorTs}'` + ); + } + + params.cursorTs = nanosToDateTime64(cursorTs); + params.cursorTraceId = cursorTraceId; + + if (sortOrder === "DESC") { + havingConditions.push( + `(_startTime < {cursorTs:DateTime64(9)} OR (_startTime = {cursorTs:DateTime64(9)} AND TraceId < {cursorTraceId:String}))` + ); + } else { + havingConditions.push( + `(_startTime > {cursorTs:DateTime64(9)} OR (_startTime = {cursorTs:DateTime64(9)} AND TraceId > {cursorTraceId:String}))` + ); + } + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const havingClause = + havingConditions.length > 0 + ? `HAVING ${havingConditions.join(" AND ")}` + : ""; + + const query = ` +SELECT + TraceId, + anyIf(ServiceName, ParentSpanId = '') as rootServiceName, + anyIf(SpanName, ParentSpanId = '') as rootSpanName, + min(Timestamp) as _startTime, + toString(toUnixTimestamp64Nano(min(Timestamp))) as startTimeNs, + toString(dateDiff('nanosecond', min(Timestamp), max(Timestamp + toIntervalNanosecond(Duration)))) as durationNs, + toUInt32(count()) as spanCount, + toUInt32(countIf(StatusCode = 'STATUS_CODE_ERROR')) as errorCount, + groupArray(tuple(ServiceName, StatusCode)) as _serviceData +FROM otel_traces +${whereClause} +GROUP BY TraceId +${havingClause} +ORDER BY _startTime ${sortOrder}, TraceId ${sortOrder} +LIMIT {limit:UInt32}`; + + params.limit = limit + 1; + + return { query, params }; +} + export function buildTracesQuery(filter: dataFilterSchemas.TracesDataFilter): { query: string; params: Record; diff --git a/packages/core/src/data-filters-zod.ts b/packages/core/src/data-filters-zod.ts index 8543623..fb28be9 100644 --- a/packages/core/src/data-filters-zod.ts +++ b/packages/core/src/data-filters-zod.ts @@ -266,3 +266,40 @@ export const metricsDataFilterSchema = z.object({ }); export type MetricsDataFilter = z.infer; + +// --- Trace summaries (Jaeger-like search) --- + +export const traceSummariesFilterSchema = z.object({ + serviceName: z.string().optional(), + spanName: z.string().optional(), + timestampMin: z.string().optional(), + timestampMax: z.string().optional(), + durationMin: z.string().optional(), + durationMax: z.string().optional(), + spanAttributes: z.record(z.string(), z.string()).optional(), + resourceAttributes: z.record(z.string(), z.string()).optional(), + limit: z.number().int().min(1).max(1000).default(20), + cursor: z.string().optional(), + sortOrder: z.enum(["ASC", "DESC"]).default("DESC"), +}); + +export type TraceSummariesFilter = z.infer; + +export const traceSummaryServiceSchema = z.object({ + name: z.string(), + count: z.number(), + hasError: z.boolean(), +}); + +export const traceSummaryRowSchema = z.object({ + traceId: z.string(), + rootServiceName: z.string(), + rootSpanName: z.string(), + startTimeNs: z.string(), + durationNs: z.string(), + spanCount: z.number(), + errorCount: z.number(), + services: z.array(traceSummaryServiceSchema), +}); + +export type TraceSummaryRow = z.infer; diff --git a/packages/core/src/telemetry-datasource.ts b/packages/core/src/telemetry-datasource.ts index 1168565..0c31ba0 100644 --- a/packages/core/src/telemetry-datasource.ts +++ b/packages/core/src/telemetry-datasource.ts @@ -2,6 +2,8 @@ import type { logsDataFilterSchema, metricsDataFilterSchema, tracesDataFilterSchema, + TraceSummariesFilter, + TraceSummaryRow, } from "./data-filters-zod.js"; import { otelLogsSchema, @@ -112,9 +114,30 @@ export interface WriteLogsDatasource { writeLogs(logsData: LogsData): Promise; } +export interface ReadTracesMetaDatasource { + getServices(opts?: { + requestContext?: unknown; + }): Promise<{ services: string[] }>; + + getOperations(filter: { + serviceName: string; + requestContext?: unknown; + }): Promise<{ operations: string[] }>; + + getTraceSummaries( + filter: TraceSummariesFilter & { + requestContext?: unknown; + } + ): Promise<{ + data: TraceSummaryRow[]; + nextCursor: string | null; + }>; +} + export type ReadTelemetryDatasource = ReadTracesDatasource & ReadLogsDatasource & - ReadMetricsDatasource; + ReadMetricsDatasource & + ReadTracesMetaDatasource; export type WriteTelemetryDatasource = WriteMetricsDatasource & WriteTracesDatasource & diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index cd1f648..9bc0f45 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -20,6 +20,8 @@ import type { Dashboard, CreateDashboardParams, SearchDashboardsFilter, + TraceSummariesFilter, + TraceSummaryRow, } from "./types.js"; const DEFAULT_TIMEOUT = 30_000; @@ -47,6 +49,19 @@ const dashboardSearchResponseSchema = z.object({ nextCursor: z.string().nullable(), }); +const servicesResponseSchema = z.object({ + services: z.array(z.string()), +}); + +const operationsResponseSchema = z.object({ + operations: z.array(z.string()), +}); + +const traceSummariesResponseSchema = z.object({ + data: z.array(dataFilterSchemas.traceSummaryRowSchema), + nextCursor: z.string().nullable(), +}); + const metricsDiscoverySchema = z.object({ metrics: z.array( z.object({ @@ -318,4 +333,61 @@ export class KopaiClient { defaultTimeout: this.defaultTimeout, }); } + + /** + * List distinct service names. + */ + async getServices(opts?: RequestOptions): Promise<{ services: string[] }> { + return request(`${this.baseUrl}/signals/services`, servicesResponseSchema, { + method: "GET", + ...opts, + baseHeaders: this.baseHeaders, + fetchFn: this.fetchFn, + defaultTimeout: this.defaultTimeout, + }); + } + + /** + * List distinct operations for a service. + */ + async getOperations( + serviceName: string, + opts?: RequestOptions + ): Promise<{ operations: string[] }> { + const params = new URLSearchParams({ serviceName }); + return request( + `${this.baseUrl}/signals/traces/operations?${params}`, + operationsResponseSchema, + { + method: "GET", + ...opts, + baseHeaders: this.baseHeaders, + fetchFn: this.fetchFn, + defaultTimeout: this.defaultTimeout, + } + ); + } + + /** + * Search trace summaries for a single page. + */ + async searchTraceSummariesPage( + filter: TraceSummariesFilter, + opts?: RequestOptions + ): Promise> { + const validatedFilter = + dataFilterSchemas.traceSummariesFilterSchema.parse(filter); + return request( + `${this.baseUrl}/signals/traces/summaries`, + traceSummariesResponseSchema, + { + method: "POST", + body: validatedFilter, + ...opts, + baseHeaders: this.baseHeaders, + fetchFn: this.fetchFn, + defaultTimeout: this.defaultTimeout, + } + ); + } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e6480c6..f329153 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -18,6 +18,8 @@ export type { Dashboard, CreateDashboardParams, SearchDashboardsFilter, + TraceSummariesFilter, + TraceSummaryRow, } from "./types.js"; // Errors diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index e6e5346..282abb9 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -13,6 +13,9 @@ export type TracesDataFilter = dataFilterSchemas.TracesDataFilter; export type LogsDataFilter = dataFilterSchemas.LogsDataFilter; export type MetricsDataFilter = dataFilterSchemas.MetricsDataFilter; +export type TraceSummariesFilter = dataFilterSchemas.TraceSummariesFilter; +export type TraceSummaryRow = dataFilterSchemas.TraceSummaryRow; + export type OtelTracesRow = denormalizedSignals.OtelTracesRow; export type OtelLogsRow = denormalizedSignals.OtelLogsRow; export type OtelMetricsRow = denormalizedSignals.OtelMetricsRow; diff --git a/packages/sqlite-datasource/src/datasource-read.test.ts b/packages/sqlite-datasource/src/datasource-read.test.ts index 7a61e36..4e05c2d 100644 --- a/packages/sqlite-datasource/src/datasource-read.test.ts +++ b/packages/sqlite-datasource/src/datasource-read.test.ts @@ -2117,6 +2117,430 @@ describe("OptimizedDatasource", () => { expect(tagsValue).not.toBe("[object Object]"); }); }); + + describe("getServices", () => { + let testConnection: DatabaseSync; + let ds: OptimizedDatasource; + let readDs: datasource.ReadTelemetryDatasource; + let insertSpan: ReturnType; + + beforeEach(async () => { + testConnection = initializeDatabase(":memory:"); + ds = createOptimizedDatasource(testConnection); + readDs = ds; + insertSpan = createInsertSpan(ds); + }); + + afterEach(() => { + testConnection.close(); + }); + + it("returns empty array when no traces", async () => { + const result = await readDs.getServices(); + expect(result.services).toEqual([]); + }); + + it("returns distinct service names sorted alphabetically", async () => { + await insertSpan({ + traceId: "t1", + spanId: "s1", + serviceName: "beta-svc", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "t2", + spanId: "s2", + serviceName: "alpha-svc", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + // duplicate service + await insertSpan({ + traceId: "t3", + spanId: "s3", + serviceName: "beta-svc", + startTimeNanos: "3000000000000000", + endTimeNanos: "3001000000000000", + }); + + const result = await readDs.getServices(); + expect(result.services).toEqual(["alpha-svc", "beta-svc"]); + }); + + it("returns services from multiple traces", async () => { + await insertSpan({ + traceId: "t1", + spanId: "s1", + serviceName: "svc-a", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "t1", + spanId: "s2", + serviceName: "svc-b", + parentSpanId: "s1", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "t2", + spanId: "s3", + serviceName: "svc-c", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + + const result = await readDs.getServices(); + expect(result.services).toEqual(["svc-a", "svc-b", "svc-c"]); + }); + }); + + describe("getOperations", () => { + let testConnection: DatabaseSync; + let ds: OptimizedDatasource; + let readDs: datasource.ReadTelemetryDatasource; + let insertSpan: ReturnType; + + beforeEach(async () => { + testConnection = initializeDatabase(":memory:"); + ds = createOptimizedDatasource(testConnection); + readDs = ds; + insertSpan = createInsertSpan(ds); + }); + + afterEach(() => { + testConnection.close(); + }); + + it("returns empty array when service has no spans", async () => { + const result = await readDs.getOperations({ + serviceName: "nonexistent", + }); + expect(result.operations).toEqual([]); + }); + + it("returns distinct span names for a specific service, sorted", async () => { + await insertSpan({ + traceId: "t1", + spanId: "s1", + serviceName: "my-svc", + spanName: "POST /api", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "t2", + spanId: "s2", + serviceName: "my-svc", + spanName: "GET /api", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + // duplicate operation + await insertSpan({ + traceId: "t3", + spanId: "s3", + serviceName: "my-svc", + spanName: "GET /api", + startTimeNanos: "3000000000000000", + endTimeNanos: "3001000000000000", + }); + + const result = await readDs.getOperations({ serviceName: "my-svc" }); + expect(result.operations).toEqual(["GET /api", "POST /api"]); + }); + + it("does not return operations from other services", async () => { + await insertSpan({ + traceId: "t1", + spanId: "s1", + serviceName: "svc-a", + spanName: "op-a", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "t2", + spanId: "s2", + serviceName: "svc-b", + spanName: "op-b", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + + const result = await readDs.getOperations({ serviceName: "svc-a" }); + expect(result.operations).toEqual(["op-a"]); + }); + }); + + describe("getTraceSummaries", () => { + let testConnection: DatabaseSync; + let ds: OptimizedDatasource; + let readDs: datasource.ReadTelemetryDatasource; + let insertSpan: ReturnType; + + beforeEach(async () => { + testConnection = initializeDatabase(":memory:"); + ds = createOptimizedDatasource(testConnection); + readDs = ds; + insertSpan = createInsertSpan(ds); + }); + + afterEach(() => { + testConnection.close(); + }); + + it("returns empty array when no traces match", async () => { + const result = await readDs.getTraceSummaries({ + serviceName: "nonexistent", + limit: 20, + sortOrder: "DESC", + }); + expect(result.data).toEqual([]); + expect(result.nextCursor).toBeNull(); + }); + + it("returns trace summaries with correct aggregation", async () => { + // Root span + await insertSpan({ + traceId: "trace1", + spanId: "root-span", + serviceName: "frontend", + spanName: "GET /page", + startTimeNanos: "1000000000000000", + endTimeNanos: "1000000500000000", // 500ms duration + }); + // Child span - different service + await insertSpan({ + traceId: "trace1", + spanId: "child-span-1", + parentSpanId: "root-span", + serviceName: "backend", + spanName: "db.query", + startTimeNanos: "1000000100000000", + endTimeNanos: "1000000400000000", // 300ms duration + }); + // Child span - error + await insertSpan({ + traceId: "trace1", + spanId: "child-span-2", + parentSpanId: "root-span", + serviceName: "backend", + spanName: "cache.get", + statusCode: otlp.StatusCode.STATUS_CODE_ERROR, + startTimeNanos: "1000000050000000", + endTimeNanos: "1000000150000000", + }); + + const result = await readDs.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + }); + + expect(result.data).toHaveLength(1); + const summary = result.data[0]; + assertDefined(summary); + expect(summary.traceId).toBe("trace1"); + expect(summary.rootServiceName).toBe("frontend"); + expect(summary.rootSpanName).toBe("GET /page"); + expect(summary.spanCount).toBe(3); + expect(summary.errorCount).toBe(1); + // startTimeNs = min timestamp across all spans + expect(summary.startTimeNs).toBe("1000000000000000"); + // durationNs = max(end) - min(start) = 1000000500000000 - 1000000000000000 = 500000000 + expect(summary.durationNs).toBe("500000000"); + // services breakdown + expect(summary.services).toHaveLength(2); + const frontend = summary.services.find((s) => s.name === "frontend"); + assertDefined(frontend); + expect(frontend.count).toBe(1); + expect(frontend.hasError).toBe(false); + const backend = summary.services.find((s) => s.name === "backend"); + assertDefined(backend); + expect(backend.count).toBe(2); + expect(backend.hasError).toBe(true); + }); + + it("filters by serviceName", async () => { + await insertSpan({ + traceId: "trace1", + spanId: "s1", + serviceName: "svc-a", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "trace2", + spanId: "s2", + serviceName: "svc-b", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + + const result = await readDs.getTraceSummaries({ + serviceName: "svc-a", + limit: 20, + sortOrder: "DESC", + }); + + expect(result.data).toHaveLength(1); + const summary = result.data[0]; + assertDefined(summary); + expect(summary.traceId).toBe("trace1"); + }); + + it("filters by spanName", async () => { + await insertSpan({ + traceId: "trace1", + spanId: "s1", + serviceName: "svc", + spanName: "GET /users", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "trace2", + spanId: "s2", + serviceName: "svc", + spanName: "POST /users", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + + const result = await readDs.getTraceSummaries({ + spanName: "GET /users", + limit: 20, + sortOrder: "DESC", + }); + + expect(result.data).toHaveLength(1); + const summary = result.data[0]; + assertDefined(summary); + expect(summary.traceId).toBe("trace1"); + }); + + it("filters by time range (timestampMin/timestampMax)", async () => { + await insertSpan({ + traceId: "trace1", + spanId: "s1", + serviceName: "svc", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "trace2", + spanId: "s2", + serviceName: "svc", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + await insertSpan({ + traceId: "trace3", + spanId: "s3", + serviceName: "svc", + startTimeNanos: "3000000000000000", + endTimeNanos: "3001000000000000", + }); + + const result = await readDs.getTraceSummaries({ + timestampMin: "1500000000000000", + timestampMax: "2500000000000000", + limit: 20, + sortOrder: "DESC", + }); + + expect(result.data).toHaveLength(1); + const summary = result.data[0]; + assertDefined(summary); + expect(summary.traceId).toBe("trace2"); + }); + + it("cursor pagination works (limit + nextCursor + fetching next page)", async () => { + for (let i = 0; i < 5; i++) { + await insertSpan({ + traceId: `trace${i}`, + spanId: `span${i}`, + serviceName: "svc", + startTimeNanos: `${(i + 1) * 1000000000000000}`, + endTimeNanos: `${(i + 1) * 1000000000000000 + 1000000000000}`, + }); + } + + // Page 1 (DESC) + const page1 = await readDs.getTraceSummaries({ + limit: 2, + sortOrder: "DESC", + }); + expect(page1.data).toHaveLength(2); + const p1r0 = page1.data[0]; + assertDefined(p1r0); + expect(p1r0.traceId).toBe("trace4"); + const p1r1 = page1.data[1]; + assertDefined(p1r1); + expect(p1r1.traceId).toBe("trace3"); + expect(page1.nextCursor).not.toBeNull(); + + // Page 2 + assertDefined(page1.nextCursor); + const page2 = await readDs.getTraceSummaries({ + limit: 2, + sortOrder: "DESC", + cursor: page1.nextCursor, + }); + expect(page2.data).toHaveLength(2); + const p2r0 = page2.data[0]; + assertDefined(p2r0); + expect(p2r0.traceId).toBe("trace2"); + const p2r1 = page2.data[1]; + assertDefined(p2r1); + expect(p2r1.traceId).toBe("trace1"); + + // Page 3 — last item + assertDefined(page2.nextCursor); + const page3 = await readDs.getTraceSummaries({ + limit: 2, + sortOrder: "DESC", + cursor: page2.nextCursor, + }); + expect(page3.data).toHaveLength(1); + expect(page3.nextCursor).toBeNull(); + }); + + it("sortOrder ASC/DESC works", async () => { + await insertSpan({ + traceId: "trace-old", + spanId: "s1", + serviceName: "svc", + startTimeNanos: "1000000000000000", + endTimeNanos: "1001000000000000", + }); + await insertSpan({ + traceId: "trace-new", + spanId: "s2", + serviceName: "svc", + startTimeNanos: "2000000000000000", + endTimeNanos: "2001000000000000", + }); + + const descResult = await readDs.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + }); + const d0 = descResult.data[0]; + assertDefined(d0); + expect(d0.traceId).toBe("trace-new"); + + const ascResult = await readDs.getTraceSummaries({ + limit: 20, + sortOrder: "ASC", + }); + const a0 = ascResult.data[0]; + assertDefined(a0); + expect(a0.traceId).toBe("trace-old"); + }); + }); }); function createInsertSpan( @@ -2132,6 +2556,7 @@ function createInsertSpan( scopeName?: string; startTimeNanos: string; endTimeNanos: string; + parentSpanId?: string; spanAttributes?: Record; resourceAttributes?: Record; events?: { name: string; timeUnixNano: string }[]; @@ -2170,6 +2595,7 @@ function createInsertSpan( { traceId: opts.traceId, spanId: opts.spanId, + parentSpanId: opts.parentSpanId, name: opts.spanName ?? "test-span", kind: opts.spanKind, startTimeUnixNano: opts.startTimeNanos, diff --git a/packages/sqlite-datasource/src/db-datasource.ts b/packages/sqlite-datasource/src/db-datasource.ts index 3844c2f..438d813 100644 --- a/packages/sqlite-datasource/src/db-datasource.ts +++ b/packages/sqlite-datasource/src/db-datasource.ts @@ -907,6 +907,296 @@ export class DbDatasource implements datasource.TelemetryDatasource { }); } } + + async getServices(): Promise<{ services: string[] }> { + try { + const { sql, parameters } = queryBuilder + .selectFrom("otel_traces") + .select("ServiceName") + .distinct() + .orderBy("ServiceName", "asc") + .compile(); + + const rows = this.sqliteConnection + .prepare(sql) + .all(...(parameters as (string | number | bigint | null)[])) as { + ServiceName: string; + }[]; + + return { services: rows.map((r) => r.ServiceName) }; + } catch (error) { + throw new SqliteDatasourceQueryError("Failed to get services", { + cause: error, + }); + } + } + + async getOperations(filter: { + serviceName: string; + }): Promise<{ operations: string[] }> { + try { + const { sql, parameters } = queryBuilder + .selectFrom("otel_traces") + .select("SpanName") + .distinct() + .where("ServiceName", "=", filter.serviceName) + .orderBy("SpanName", "asc") + .compile(); + + const rows = this.sqliteConnection + .prepare(sql) + .all(...(parameters as (string | number | bigint | null)[])) as { + SpanName: string; + }[]; + + return { operations: rows.map((r) => r.SpanName) }; + } catch (error) { + throw new SqliteDatasourceQueryError("Failed to get operations", { + cause: error, + }); + } + } + + async getTraceSummaries( + filter: dataFilterSchemas.TraceSummariesFilter + ): Promise<{ + data: dataFilterSchemas.TraceSummaryRow[]; + nextCursor: string | null; + }> { + try { + const limit = filter.limit ?? 20; + const sortOrder = filter.sortOrder ?? "DESC"; + + // Step 1: Find matching trace IDs from otel_traces_trace_id_ts with time range + cursor + const traceIdClauses: string[] = ["1=1"]; + const traceIdParams: (string | bigint)[] = []; + + if (filter.timestampMin != null) { + traceIdClauses.push("t.Start >= ?"); + traceIdParams.push(BigInt(filter.timestampMin)); + } + if (filter.timestampMax != null) { + traceIdClauses.push("t.End <= ?"); + traceIdParams.push(BigInt(filter.timestampMax)); + } + + if (filter.cursor) { + const colonIdx = filter.cursor.indexOf(":"); + const cursorTs = BigInt(filter.cursor.slice(0, colonIdx)); + const cursorTraceId = filter.cursor.slice(colonIdx + 1); + + if (sortOrder === "DESC") { + traceIdClauses.push( + "(t.Start < ? OR (t.Start = ? AND t.TraceId < ?))" + ); + traceIdParams.push(cursorTs, cursorTs, cursorTraceId); + } else { + traceIdClauses.push( + "(t.Start > ? OR (t.Start = ? AND t.TraceId > ?))" + ); + traceIdParams.push(cursorTs, cursorTs, cursorTraceId); + } + } + + // If we have span-level filters, we need to restrict trace IDs to those + // containing matching spans + const hasSpanFilters = + filter.serviceName || + filter.spanName || + filter.durationMin != null || + filter.durationMax != null || + filter.spanAttributes || + filter.resourceAttributes; + + let spanFilterJoin = ""; + const spanFilterParams: (string | bigint)[] = []; + + if (hasSpanFilters) { + const spanClauses: string[] = []; + if (filter.serviceName) { + spanClauses.push("s.ServiceName = ?"); + spanFilterParams.push(filter.serviceName); + } + if (filter.spanName) { + spanClauses.push("s.SpanName = ?"); + spanFilterParams.push(filter.spanName); + } + if (filter.durationMin != null) { + spanClauses.push("s.Duration >= ?"); + spanFilterParams.push(BigInt(filter.durationMin)); + } + if (filter.durationMax != null) { + spanClauses.push("s.Duration <= ?"); + spanFilterParams.push(BigInt(filter.durationMax)); + } + if (filter.spanAttributes) { + for (const [key, value] of Object.entries(filter.spanAttributes)) { + const jsonPath = `$."${key.replace(/"/g, '""')}"`; + spanClauses.push( + `json_extract(s.SpanAttributes, '${jsonPath}') = ?` + ); + spanFilterParams.push(value); + } + } + if (filter.resourceAttributes) { + for (const [key, value] of Object.entries( + filter.resourceAttributes + )) { + const jsonPath = `$."${key.replace(/"/g, '""')}"`; + spanClauses.push( + `json_extract(s.ResourceAttributes, '${jsonPath}') = ?` + ); + spanFilterParams.push(value); + } + } + + spanFilterJoin = `AND t.TraceId IN (SELECT DISTINCT TraceId FROM otel_traces s WHERE ${spanClauses.join(" AND ")})`; + } + + const orderDir = sortOrder === "ASC" ? "ASC" : "DESC"; + const traceIdSql = ` + SELECT t.TraceId, t.Start + FROM otel_traces_trace_id_ts t + WHERE ${traceIdClauses.join(" AND ")} + ${spanFilterJoin} + ORDER BY t.Start ${orderDir}, t.TraceId ${orderDir} + LIMIT ? + `; + + const allTraceIdParams = [ + ...traceIdParams, + ...spanFilterParams, + BigInt(limit + 1), + ]; + + const traceIdStmt = this.sqliteConnection.prepare(traceIdSql); + traceIdStmt.setReadBigInts(true); + const traceIdRows = traceIdStmt.all(...allTraceIdParams) as { + TraceId: string; + Start: bigint; + }[]; + + // Determine hasMore + const hasMore = traceIdRows.length > limit; + const pageTraceRows = hasMore ? traceIdRows.slice(0, limit) : traceIdRows; + + if (pageTraceRows.length === 0) { + return { data: [], nextCursor: null }; + } + + const lastTraceRow = pageTraceRows[pageTraceRows.length - 1]; + const nextCursor = + hasMore && lastTraceRow + ? `${lastTraceRow.Start}:${lastTraceRow.TraceId}` + : null; + + // Step 2: Get all spans for the matched trace IDs + const traceIds = pageTraceRows.map((r) => r.TraceId); + const placeholders = traceIds.map(() => "?").join(","); + const spansSql = ` + SELECT TraceId, SpanId, ParentSpanId, ServiceName, SpanName, + StatusCode, Timestamp, Duration + FROM otel_traces + WHERE TraceId IN (${placeholders}) + `; + + const spansStmt = this.sqliteConnection.prepare(spansSql); + spansStmt.setReadBigInts(true); + const spanRows = spansStmt.all(...traceIds) as { + TraceId: string; + SpanId: string; + ParentSpanId: string; + ServiceName: string; + SpanName: string; + StatusCode: string; + Timestamp: bigint; + Duration: bigint; + }[]; + + // Step 3: Aggregate spans into TraceSummaryRow per trace + const traceMap = new Map< + string, + { + spans: typeof spanRows; + } + >(); + + for (const span of spanRows) { + let entry = traceMap.get(span.TraceId); + if (!entry) { + entry = { spans: [] }; + traceMap.set(span.TraceId, entry); + } + entry.spans.push(span); + } + + // Build results in the same order as traceIds + const data: dataFilterSchemas.TraceSummaryRow[] = []; + + for (const traceId of traceIds) { + const entry = traceMap.get(traceId); + if (!entry || entry.spans.length === 0) continue; + + const spans = entry.spans; + const firstSpan = spans[0]; + if (!firstSpan) continue; + + // Find root span (empty ParentSpanId) + const rootSpan = spans.find((s) => !s.ParentSpanId) ?? firstSpan; + + // Compute aggregates + let minTimestamp = firstSpan.Timestamp; + let maxEnd = firstSpan.Timestamp + firstSpan.Duration; + let errorCount = 0; + const serviceMap = new Map< + string, + { count: number; hasError: boolean } + >(); + + for (const span of spans) { + if (span.Timestamp < minTimestamp) minTimestamp = span.Timestamp; + const spanEnd = span.Timestamp + span.Duration; + if (spanEnd > maxEnd) maxEnd = spanEnd; + if (span.StatusCode === "STATUS_CODE_ERROR") errorCount++; + + const svc = serviceMap.get(span.ServiceName); + if (svc) { + svc.count++; + if (span.StatusCode === "STATUS_CODE_ERROR") svc.hasError = true; + } else { + serviceMap.set(span.ServiceName, { + count: 1, + hasError: span.StatusCode === "STATUS_CODE_ERROR", + }); + } + } + + const durationNs = maxEnd - minTimestamp; + + data.push({ + traceId, + rootServiceName: rootSpan.ServiceName, + rootSpanName: rootSpan.SpanName, + startTimeNs: String(minTimestamp), + durationNs: String(durationNs), + spanCount: spans.length, + errorCount, + services: Array.from(serviceMap.entries()).map(([name, info]) => ({ + name, + count: info.count, + hasError: info.hasError, + })), + }); + } + + return { data, nextCursor }; + } catch (error) { + if (error instanceof SqliteDatasourceQueryError) throw error; + throw new SqliteDatasourceQueryError("Failed to get trace summaries", { + cause: error, + }); + } + } } /** In-memory state for a single discovered metric */ diff --git a/packages/sqlite-datasource/src/optimized-datasource.ts b/packages/sqlite-datasource/src/optimized-datasource.ts index 6c80092..d2ca845 100644 --- a/packages/sqlite-datasource/src/optimized-datasource.ts +++ b/packages/sqlite-datasource/src/optimized-datasource.ts @@ -113,6 +113,25 @@ export class OptimizedDatasource implements datasource.TelemetryDatasource { return this.dbDatasource.getLogs(filter); } + async getServices(): Promise<{ services: string[] }> { + return this.dbDatasource.getServices(); + } + + async getOperations(filter: { + serviceName: string; + }): Promise<{ operations: string[] }> { + return this.dbDatasource.getOperations(filter); + } + + async getTraceSummaries( + filter: dataFilterSchemas.TraceSummariesFilter + ): Promise<{ + data: dataFilterSchemas.TraceSummaryRow[]; + nextCursor: string | null; + }> { + return this.dbDatasource.getTraceSummaries(filter); + } + async discoverMetrics(): Promise { // Return from in-memory state (O(1)) const metrics: datasource.DiscoveredMetric[] = []; diff --git a/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx b/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx index b516584..1fcf522 100644 --- a/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +++ b/packages/ui/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx @@ -28,6 +28,11 @@ function createMockClient(): MockClient { .fn() .mockResolvedValue({ data: [], nextCursor: null }), searchDashboards: vi.fn().mockReturnValue((async function* () {})()), + getServices: vi.fn().mockResolvedValue({ services: [] }), + getOperations: vi.fn().mockResolvedValue({ operations: [] }), + searchTraceSummariesPage: vi + .fn() + .mockResolvedValue({ data: [], nextCursor: null }), }; } diff --git a/packages/ui/src/components/observability/TraceComparison/index.tsx b/packages/ui/src/components/observability/TraceComparison/index.tsx new file mode 100644 index 0000000..99bc926 --- /dev/null +++ b/packages/ui/src/components/observability/TraceComparison/index.tsx @@ -0,0 +1,332 @@ +import { useMemo } from "react"; +import type { denormalizedSignals } from "@kopai/core"; +import type { DataSource } from "../../../lib/component-catalog.js"; +import { useKopaiData } from "../../../hooks/use-kopai-data.js"; +import { TraceTimeline } from "../TraceTimeline/index.js"; +import { formatDuration } from "../utils/time.js"; + +type OtelTracesRow = denormalizedSignals.OtelTracesRow; + +export interface TraceComparisonProps { + traceIdA: string; + traceIdB: string; + onBack: () => void; +} + +interface DiffRow { + serviceName: string; + spanName: string; + countA: number; + countB: number; + avgDurationA: number; + avgDurationB: number; + deltaMs: number; +} + +function computeTraceStats(rows: OtelTracesRow[]) { + if (rows.length === 0) return { durationMs: 0, spanCount: 0 }; + let minTs = Infinity; + let maxEnd = -Infinity; + for (const row of rows) { + const startMs = parseInt(row.Timestamp, 10) / 1e6; + const durNs = row.Duration ? parseInt(row.Duration, 10) : 0; + const endMs = startMs + durNs / 1e6; + minTs = Math.min(minTs, startMs); + maxEnd = Math.max(maxEnd, endMs); + } + return { durationMs: maxEnd - minTs, spanCount: rows.length }; +} + +function collectSignatures( + rows: OtelTracesRow[] +): Map { + const map = new Map(); + for (const row of rows) { + const key = `${row.ServiceName ?? "unknown"}::${row.SpanName ?? ""}`; + const durNs = row.Duration ? parseInt(row.Duration, 10) : 0; + const durMs = durNs / 1e6; + const existing = map.get(key); + if (existing) { + existing.count++; + existing.totalDurationMs += durMs; + } else { + map.set(key, { count: 1, totalDurationMs: durMs }); + } + } + return map; +} + +function computeDiff( + rowsA: OtelTracesRow[], + rowsB: OtelTracesRow[] +): DiffRow[] { + const sigA = collectSignatures(rowsA); + const sigB = collectSignatures(rowsB); + const allKeys = new Set([...sigA.keys(), ...sigB.keys()]); + const result: DiffRow[] = []; + + for (const key of allKeys) { + const [serviceName = "unknown", spanName = ""] = key.split("::"); + const a = sigA.get(key); + const b = sigB.get(key); + const countA = a?.count ?? 0; + const countB = b?.count ?? 0; + const avgA = a ? a.totalDurationMs / a.count : 0; + const avgB = b ? b.totalDurationMs / b.count : 0; + result.push({ + serviceName, + spanName, + countA, + countB, + avgDurationA: avgA, + avgDurationB: avgB, + deltaMs: avgB - avgA, + }); + } + + // Sort: spans only in A first, then only in B, then shared (by absolute delta desc) + return result.sort((a, b) => { + const aShared = a.countA > 0 && a.countB > 0; + const bShared = b.countA > 0 && b.countB > 0; + if (aShared !== bShared) return aShared ? 1 : -1; + return Math.abs(b.deltaMs) - Math.abs(a.deltaMs); + }); +} + +function formatDelta(ms: number): string { + const sign = ms > 0 ? "+" : ""; + return `${sign}${formatDuration(ms)}`; +} + +export function TraceComparison({ + traceIdA, + traceIdB, + onBack, +}: TraceComparisonProps) { + const dsA = useMemo( + () => ({ method: "getTrace", params: { traceId: traceIdA } }), + [traceIdA] + ); + const dsB = useMemo( + () => ({ method: "getTrace", params: { traceId: traceIdB } }), + [traceIdB] + ); + + const { + data: rowsA, + loading: loadingA, + error: errorA, + } = useKopaiData(dsA); + const { + data: rowsB, + loading: loadingB, + error: errorB, + } = useKopaiData(dsB); + + const statsA = useMemo(() => computeTraceStats(rowsA ?? []), [rowsA]); + const statsB = useMemo(() => computeTraceStats(rowsB ?? []), [rowsB]); + const diff = useMemo( + () => computeDiff(rowsA ?? [], rowsB ?? []), + [rowsA, rowsB] + ); + + const durationDelta = statsB.durationMs - statsA.durationMs; + const spanDelta = statsB.spanCount - statsA.spanCount; + const isLoading = loadingA || loadingB; + + return ( +
+ {/* Header */} +
+
+ +
+
+ A: + + {traceIdA.slice(0, 16)}... + +
+
+ B: + + {traceIdB.slice(0, 16)}... + +
+
+
+ {!isLoading && ( +
+
+ + Duration delta: + + 0 + ? "text-red-400" + : durationDelta < 0 + ? "text-green-400" + : "text-foreground" + } + > + {formatDelta(durationDelta)} + +
+
+ + Span count delta: + + 0 + ? "text-red-400" + : spanDelta < 0 + ? "text-green-400" + : "text-foreground" + } + > + {spanDelta > 0 ? `+${spanDelta}` : String(spanDelta)} + +
+
+ )} +
+ + {/* Side-by-side timelines */} +
+
+ +
+
+ +
+
+ + {/* Structural Diff Table */} + {!isLoading && diff.length > 0 && ( +
+
+

+ Structural Diff +

+
+
+ + + + + + + + + + + + + + {diff.map((row) => { + const onlyA = row.countA > 0 && row.countB === 0; + const onlyB = row.countA === 0 && row.countB > 0; + const rowBg = onlyA + ? "bg-red-500/5" + : onlyB + ? "bg-green-500/5" + : ""; + + return ( + + + + + + + + + + ); + })} + +
+ Service + + Span + + Count A + + Count B + + Avg Dur A + + Avg Dur B + + Delta +
+ {row.serviceName} + + {row.spanName} + + {row.countA || ( + - + )} + + {row.countB || ( + - + )} + + {row.countA > 0 ? ( + formatDuration(row.avgDurationA) + ) : ( + - + )} + + {row.countB > 0 ? ( + formatDuration(row.avgDurationB) + ) : ( + - + )} + + {row.countA > 0 && row.countB > 0 ? ( + 0 + ? "text-red-400" + : row.deltaMs < 0 + ? "text-green-400" + : "text-foreground" + } + > + {formatDelta(row.deltaMs)} + + ) : ( + + {onlyA ? "removed" : "added"} + + )} +
+
+
+ )} +
+ ); +} diff --git a/packages/ui/src/components/observability/TraceDetail/index.tsx b/packages/ui/src/components/observability/TraceDetail/index.tsx index d3d06af..19f12de 100644 --- a/packages/ui/src/components/observability/TraceDetail/index.tsx +++ b/packages/ui/src/components/observability/TraceDetail/index.tsx @@ -5,7 +5,6 @@ import type { SpanNode } from "../types.js"; type OtelTracesRow = denormalizedSignals.OtelTracesRow; export interface TraceDetailProps { - service: string; traceId: string; rows: OtelTracesRow[]; isLoading?: boolean; @@ -16,7 +15,6 @@ export interface TraceDetailProps { } export function TraceDetail({ - service, traceId, rows, isLoading, @@ -33,7 +31,7 @@ export function TraceDetail({ onClick={onBack} className="hover:text-foreground transition-colors" > - Services / {service} + Services / diff --git a/packages/ui/src/components/observability/TraceSearch/DurationBar.tsx b/packages/ui/src/components/observability/TraceSearch/DurationBar.tsx new file mode 100644 index 0000000..b5059da --- /dev/null +++ b/packages/ui/src/components/observability/TraceSearch/DurationBar.tsx @@ -0,0 +1,37 @@ +/** + * DurationBar - Horizontal bar showing relative trace duration. + */ + +import { formatDuration } from "../utils/time.js"; + +export interface DurationBarProps { + durationMs: number; + maxDurationMs: number; + color: string; +} + +export function DurationBar({ + durationMs, + maxDurationMs, + color, +}: DurationBarProps) { + const widthPct = maxDurationMs > 0 ? (durationMs / maxDurationMs) * 100 : 0; + + return ( +
+
+
+
+ + {formatDuration(durationMs)} + +
+ ); +} diff --git a/packages/ui/src/components/observability/TraceSearch/ScatterPlot.tsx b/packages/ui/src/components/observability/TraceSearch/ScatterPlot.tsx new file mode 100644 index 0000000..d48811b --- /dev/null +++ b/packages/ui/src/components/observability/TraceSearch/ScatterPlot.tsx @@ -0,0 +1,135 @@ +/** + * ScatterPlot - Scatter chart showing trace duration vs timestamp. + */ + +import { useMemo, useCallback } from "react"; +import { + ScatterChart, + Scatter, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import type { TraceSummary } from "./index.js"; +import { getServiceColor } from "../utils/colors.js"; +import { formatDuration, formatTimestamp } from "../utils/time.js"; + +export interface ScatterPlotProps { + traces: TraceSummary[]; + onSelectTrace: (traceId: string) => void; +} + +interface ScatterPoint { + x: number; + y: number; + traceId: string; + serviceName: string; + rootSpanName: string; + spanCount: number; + hasError: boolean; +} + +function CustomTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: Array<{ payload: ScatterPoint }>; +}) { + if (!active || !payload?.[0]) return null; + const d = payload[0].payload; + return ( +
+
+ {d.serviceName}: {d.rootSpanName} +
+
+ {d.spanCount} span{d.spanCount !== 1 ? "s" : ""} ·{" "} + {formatDuration(d.y)} +
+
{formatTimestamp(d.x)}
+
+ ); +} + +export function ScatterPlot({ traces, onSelectTrace }: ScatterPlotProps) { + const data = useMemo( + () => + traces.map((t) => ({ + x: t.timestampMs, + y: t.durationMs, + traceId: t.traceId, + serviceName: t.serviceName, + rootSpanName: t.rootSpanName, + spanCount: t.spanCount, + hasError: t.errorCount > 0, + })), + [traces] + ); + + const handleClick = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (entry: any) => { + const payload = entry?.payload as ScatterPoint | undefined; + if (payload?.traceId) { + onSelectTrace(payload.traceId); + } + }, + [onSelectTrace] + ); + + if (traces.length === 0) return null; + + return ( +
+ + + + { + const d = new Date(v); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; + }} + tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} + stroke="hsl(var(--border))" + name="Time" + /> + formatDuration(v)} + tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} + stroke="hsl(var(--border))" + name="Duration" + width={70} + /> + } /> + + {data.map((point, i) => ( + + ))} + + + +
+ ); +} diff --git a/packages/ui/src/components/observability/TraceSearch/SearchForm.tsx b/packages/ui/src/components/observability/TraceSearch/SearchForm.tsx new file mode 100644 index 0000000..db09e0a --- /dev/null +++ b/packages/ui/src/components/observability/TraceSearch/SearchForm.tsx @@ -0,0 +1,181 @@ +/** + * SearchForm - Jaeger-style sidebar search form for trace filtering. + */ + +export interface SearchFormProps { + services: string[]; + operations: string[]; + service: string; + operation: string; + tags: string; + lookback: string; + minDuration: string; + maxDuration: string; + limit: number; + onServiceChange: (service: string) => void; + onOperationChange: (operation: string) => void; + onTagsChange: (tags: string) => void; + onLookbackChange: (lookback: string) => void; + onMinDurationChange: (minDuration: string) => void; + onMaxDurationChange: (maxDuration: string) => void; + onLimitChange: (limit: number) => void; + onSubmit: () => void; + isLoading?: boolean; +} + +const LOOKBACK_OPTIONS = [ + { label: "Last 5 Minutes", value: "5m" }, + { label: "Last 15 Minutes", value: "15m" }, + { label: "Last 30 Minutes", value: "30m" }, + { label: "Last 1 Hour", value: "1h" }, + { label: "Last 2 Hours", value: "2h" }, + { label: "Last 6 Hours", value: "6h" }, + { label: "Last 12 Hours", value: "12h" }, + { label: "Last 24 Hours", value: "24h" }, +] as const; + +const inputClass = + "w-full bg-muted/50 border border-border rounded px-2 py-1.5 text-sm text-foreground"; + +export function SearchForm({ + services, + operations, + service, + operation, + tags, + lookback, + minDuration, + maxDuration, + limit, + onServiceChange, + onOperationChange, + onTagsChange, + onLookbackChange, + onMinDurationChange, + onMaxDurationChange, + onLimitChange, + onSubmit, + isLoading, +}: SearchFormProps) { + return ( +
+

+ Search +

+ + {/* Service */} + + + {/* Operation */} + + + {/* Tags */} +