diff --git a/.changeset/free-stamps-lie.md b/.changeset/free-stamps-lie.md new file mode 100644 index 0000000..8a69be8 --- /dev/null +++ b/.changeset/free-stamps-lie.md @@ -0,0 +1,10 @@ +--- +"@kopai/clickhouse-datasource": minor +"@kopai/sqlite-datasource": minor +"@kopai/core": minor +"@kopai/api": minor +"@kopai/sdk": minor +"@kopai/ui": minor +--- + +Add new trace-related API methods 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/dashboards.ts b/packages/api/src/routes/dashboards.ts index 79eedd2..23a0c1e 100644 --- a/packages/api/src/routes/dashboards.ts +++ b/packages/api/src/routes/dashboards.ts @@ -16,11 +16,8 @@ export const dashboardsRoutes: FastifyPluginAsyncZod<{ "Get UI tree schema as markdown prompt instructions for AI agents", produces: ["text/markdown"], response: { - 200: { type: "string", description: "Markdown prompt instructions" }, - 404: { - type: "string", - description: "Prompt instructions not configured", - }, + 200: z.string().describe("Markdown prompt instructions"), + 404: z.string().describe("Prompt instructions not configured"), }, }, handler: async (_req, reply) => { 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.test.ts b/packages/clickhouse-datasource/src/datasource.test.ts index c03c3bf..27f9f7d 100644 --- a/packages/clickhouse-datasource/src/datasource.test.ts +++ b/packages/clickhouse-datasource/src/datasource.test.ts @@ -42,6 +42,38 @@ function createSpyLogger() { } satisfies Logger; } +/** Builds a full otel_traces row, merging overrides into sensible defaults. */ +function makeSpan( + overrides: Partial> & { + Timestamp: string; + TraceId: string; + SpanId: string; + SpanName: string; + ServiceName: string; + } +) { + return { + ParentSpanId: "", + TraceState: "", + SpanKind: "SERVER", + ResourceAttributes: {}, + ScopeName: "", + ScopeVersion: "", + SpanAttributes: {}, + Duration: 1000000, + StatusCode: "OK", + StatusMessage: "", + "Events.Timestamp": [], + "Events.Name": [], + "Events.Attributes": [], + "Links.TraceId": [], + "Links.SpanId": [], + "Links.TraceState": [], + "Links.Attributes": [], + ...overrides, + }; +} + const CLICKHOUSE_HTTP_PORT = 8123; const TEST_DATABASE = "test_db"; const TENANT_B_DATABASE = "tenant_b_db"; @@ -147,6 +179,9 @@ beforeAll(async () => { await seedTenantBData(tenantBClient); await tenantBClient.close(); + // Seed recent spans for getServices/getOperations lookback window + await seedRecentServiceSpans(); + // Create a read-only user scoped to test_db only (mirrors prod tenant readers) await adminClient.command({ query: `CREATE USER IF NOT EXISTS ${READER_USERNAME} IDENTIFIED WITH plaintext_password BY '${READER_PASSWORD}'`, @@ -394,6 +429,63 @@ async function seedTenantBData(client: ClickHouseClient) { }); } +/** Seed recent-timestamp spans so getServices/getOperations (7-day lookback) find them. */ +async function seedRecentServiceSpans() { + const ts = new Date().toISOString().replace("T", " ").replace("Z", "000"); + + const dbClient = createClient({ + url: baseUrl, + database: TEST_DATABASE, + username: "default", + password: "", + }); + const tenantBClient = createClient({ + url: baseUrl, + database: TENANT_B_DATABASE, + username: "default", + password: "", + }); + + await Promise.all([ + dbClient.insert({ + table: "otel_traces", + values: [ + makeSpan({ + Timestamp: ts, + TraceId: "trace-recent-svc-001", + SpanId: "span-recent-svc-001", + SpanName: "GET /api/users", + ServiceName: "user-service", + }), + makeSpan({ + Timestamp: ts, + TraceId: "trace-recent-svc-002", + SpanId: "span-recent-svc-002", + SpanName: "POST /api/orders", + ServiceName: "order-service", + Duration: 2000000, + }), + ], + format: "JSONEachRow", + }), + tenantBClient.insert({ + table: "otel_traces", + values: [ + makeSpan({ + Timestamp: ts, + TraceId: "trace-recent-b-001", + SpanId: "span-recent-b-001", + SpanName: "GET /api/tenant-b", + ServiceName: "tenant-b-service", + }), + ], + format: "JSONEachRow", + }), + ]); + + await Promise.all([dbClient.close(), tenantBClient.close()]); +} + async function seedTraces(client: ClickHouseClient) { await client.insert({ table: "otel_traces", @@ -470,6 +562,24 @@ async function seedTraces(client: ClickHouseClient) { "Links.TraceState": [""], "Links.Attributes": [{ "link.type": "follows_from" }], }, + // Multi-service trace: order-service root + payment-service child + makeSpan({ + Timestamp: "2024-01-01 00:00:04.000000000", + TraceId: "trace-003", + SpanId: "span-004", + SpanName: "POST /api/checkout", + ServiceName: "order-service", + Duration: 20000000, + }), + makeSpan({ + Timestamp: "2024-01-01 00:00:04.500000000", + TraceId: "trace-003", + SpanId: "span-005", + ParentSpanId: "span-004", + SpanName: "charge", + ServiceName: "payment-service", + Duration: 10000000, + }), ], format: "JSONEachRow", }); @@ -920,7 +1030,8 @@ describe("ClickHouseReadDatasource", () => { it("returns all traces with no filters", async () => { const result = await ds.getTraces({ requestContext: requestContext() }); - expect(result.data.length).toBe(3); + // 5 original + 2 recent-timestamp spans seeded for getServices/getOperations + expect(result.data.length).toBe(7); expect(result.nextCursor).toBeNull(); }); @@ -942,8 +1053,11 @@ describe("ClickHouseReadDatasource", () => { requestContext: requestContext(), }); - expect(result.data.length).toBe(1); - expect(firstRow(result.data).ServiceName).toBe("order-service"); + // 2 original (trace-002 + trace-003) + 1 recent-timestamp span + expect(result.data.length).toBe(3); + expect(result.data.every((r) => r.ServiceName === "order-service")).toBe( + true + ); }); it("returns timestamps as nanosecond strings", async () => { @@ -1013,16 +1127,18 @@ describe("ClickHouseReadDatasource", () => { it("supports cursor pagination", async () => { const page1 = await ds.getTraces({ - limit: 2, + traceId: "trace-001", + limit: 1, sortOrder: "DESC", requestContext: requestContext(), }); - expect(page1.data.length).toBe(2); + expect(page1.data.length).toBe(1); const cursor = defined(page1.nextCursor, "nextCursor"); const page2 = await ds.getTraces({ - limit: 2, + traceId: "trace-001", + limit: 1, sortOrder: "DESC", cursor, requestContext: requestContext(), @@ -1107,6 +1223,178 @@ describe("ClickHouseReadDatasource", () => { }); }); + describe("getServices", () => { + it("returns services sorted alphabetically", async () => { + const result = await ds.getServices({ + requestContext: requestContext(), + }); + + expect(result.services).toEqual(["order-service", "user-service"]); + }); + + it("tenant isolation: tenant B sees only its services", async () => { + const result = await ds.getServices({ + requestContext: tenantBRequestContext(), + }); + + expect(result.services).toEqual(["tenant-b-service"]); + }); + }); + + describe("getOperations", () => { + it("returns operations for user-service", async () => { + const result = await ds.getOperations({ + serviceName: "user-service", + requestContext: requestContext(), + }); + + expect(result.operations).toEqual(["GET /api/users"]); + }); + + it("returns operations for order-service", async () => { + const result = await ds.getOperations({ + serviceName: "order-service", + requestContext: requestContext(), + }); + + expect(result.operations).toEqual(["POST /api/orders"]); + }); + + it("returns empty for nonexistent service", async () => { + const result = await ds.getOperations({ + serviceName: "nonexistent-service", + requestContext: requestContext(), + }); + + expect(result.operations).toEqual([]); + }); + }); + + describe("getTraceSummaries", () => { + it("returns all trace summaries (no filter)", async () => { + const result = await ds.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + // At least the 2 original traces + the 2 recent ones seeded for getServices + expect(result.data.length).toBeGreaterThanOrEqual(2); + }); + + it("aggregates trace-001 correctly", async () => { + const result = await ds.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + const t = result.data.find((r) => r.traceId === "trace-001"); + expect(t).toBeDefined(); + expect(t!.rootServiceName).toBe("user-service"); + expect(t!.rootSpanName).toBe("GET /api/users"); + expect(t!.spanCount).toBe(2); + expect(t!.errorCount).toBe(0); + expect(t!.services.length).toBe(1); + expect(t!.services[0]!.name).toBe("user-service"); + expect(t!.services[0]!.count).toBe(2); + }); + + it("aggregates trace-002 with error", async () => { + const result = await ds.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + const t = result.data.find((r) => r.traceId === "trace-002"); + expect(t).toBeDefined(); + expect(t!.rootServiceName).toBe("order-service"); + expect(t!.rootSpanName).toBe("POST /api/orders"); + expect(t!.spanCount).toBe(1); + expect(t!.errorCount).toBe(1); + }); + + it("filters by serviceName and preserves full trace", async () => { + const result = await ds.getTraceSummaries({ + serviceName: "order-service", + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + expect( + result.data.every((r) => + r.services.some((s) => s.name === "order-service") + ) + ).toBe(true); + + // Multi-service trace-003 should include all spans/services, not just order-service + const multi = result.data.find((r) => r.traceId === "trace-003"); + expect(multi).toBeDefined(); + expect(multi!.spanCount).toBe(2); + expect(multi!.services.map((s) => s.name).sort()).toEqual([ + "order-service", + "payment-service", + ]); + }); + + it("sorts DESC by default", async () => { + const result = await ds.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + const times = result.data.map((r) => BigInt(r.startTimeNs)); + for (let i = 1; i < times.length; i++) { + expect(times[i]! <= times[i - 1]!).toBe(true); + } + }); + + it("sorts ASC", async () => { + const result = await ds.getTraceSummaries({ + limit: 20, + sortOrder: "ASC", + requestContext: requestContext(), + }); + + expectAscending(result.data.map((r) => BigInt(r.startTimeNs))); + }); + + it("supports cursor pagination", async () => { + const page1 = await ds.getTraceSummaries({ + limit: 1, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + expect(page1.data.length).toBe(1); + const cursor = defined(page1.nextCursor, "nextCursor"); + + const page2 = await ds.getTraceSummaries({ + limit: 1, + sortOrder: "DESC", + cursor, + requestContext: requestContext(), + }); + + expect(page2.data.length).toBe(1); + expect(page2.data[0]!.traceId).not.toBe(page1.data[0]!.traceId); + }); + + it("throws on malformed cursor", async () => { + await expect( + ds.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + cursor: "malformed-no-colon", + requestContext: requestContext(), + }) + ).rejects.toThrow("Invalid cursor format"); + }); + }); + describe("getLogs", () => { it("returns all logs with no filters", async () => { const result = await ds.getLogs({ requestContext: requestContext() }); @@ -1689,14 +1977,16 @@ GROUP BY MetricName, MetricType, source, attr_key`, requestContext: tenantBRequestContext(), }); - // Tenant A has 3 traces, tenant B has 1 - expect(tenantA.data.length).toBe(3); - expect(tenantB.data.length).toBe(1); + // Tenant A has 5 original + 2 recent-timestamp traces, tenant B has 1 + 1 + expect(tenantA.data.length).toBe(7); + expect(tenantB.data.length).toBe(2); // No cross-contamination expect(tenantA.data.every((r) => r.TraceId !== "trace-b-001")).toBe(true); - expect(firstRow(tenantB.data).TraceId).toBe("trace-b-001"); - expect(firstRow(tenantB.data).ServiceName).toBe("tenant-b-service"); + expect(tenantB.data.some((r) => r.TraceId === "trace-b-001")).toBe(true); + expect( + tenantB.data.every((r) => r.ServiceName === "tenant-b-service") + ).toBe(true); }); it("routes logs to the correct database", async () => { diff --git a/packages/clickhouse-datasource/src/datasource.ts b/packages/clickhouse-datasource/src/datasource.ts index cf2e500..8d2a01c 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,241 @@ export class ClickHouseReadDatasource return { data, nextCursor }; } + async getServices(opts?: { + requestContext?: unknown; + }): Promise<{ services: string[] }> { + const requestContext = opts?.requestContext; + assertClickHouseRequestContext(requestContext); + const { database, username, password } = requestContext; + const log = getLogger(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 === "ERROR") existing.hasError = true; + } else { + serviceMap.set(svcName, { + count: 1, + hasError: statusCode === "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/e2e.integration.test.ts b/packages/clickhouse-datasource/src/e2e.integration.test.ts index ea5bdc7..e0b4016 100644 --- a/packages/clickhouse-datasource/src/e2e.integration.test.ts +++ b/packages/clickhouse-datasource/src/e2e.integration.test.ts @@ -448,6 +448,60 @@ describe("E2E: OTEL Collector → ClickHouse → ReadDatasource", () => { }); }); + describe("getServices", () => { + it("returns at least TEST_SERVICE_NAME", async () => { + const result = await ds.getServices({ + requestContext: requestContext(), + }); + + expect(result.services).toContain(TEST_SERVICE_NAME); + }); + }); + + describe("getOperations", () => { + it("returns operations for TEST_SERVICE_NAME", async () => { + const result = await ds.getOperations({ + serviceName: TEST_SERVICE_NAME, + requestContext: requestContext(), + }); + + expect(result.operations).toContain("GET /api/e2e-test"); + expect(result.operations).toContain("DB query"); + }); + }); + + describe("getTraceSummaries", () => { + it("returns summaries with data", async () => { + const result = await ds.getTraceSummaries({ + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + expect(result.data.length).toBeGreaterThan(0); + const summary = result.data[0]!; + expect(summary.traceId).toBeDefined(); + expect(summary.spanCount).toBeGreaterThan(0); + expect(summary.services.length).toBeGreaterThan(0); + }); + + it("filters by serviceName", async () => { + const result = await ds.getTraceSummaries({ + serviceName: TEST_SERVICE_NAME, + limit: 20, + sortOrder: "DESC", + requestContext: requestContext(), + }); + + expect(result.data.length).toBeGreaterThan(0); + expect( + result.data.every((r) => + r.services.some((s) => s.name === TEST_SERVICE_NAME) + ) + ).toBe(true); + }); + }); + describe("discoverMetrics", () => { beforeAll(async () => { const schema = getDiscoverMVSchema(CH_DATABASE); diff --git a/packages/clickhouse-datasource/src/query-traces.ts b/packages/clickhouse-datasource/src/query-traces.ts index 0efc3a3..e8c626b 100644 --- a/packages/clickhouse-datasource/src/query-traces.ts +++ b/packages/clickhouse-datasource/src/query-traces.ts @@ -1,6 +1,170 @@ import type { dataFilterSchemas } from "@kopai/core"; import { nanosToDateTime64 } from "./timestamp.js"; +/** Default lookback for services/operations discovery (7 days in ms). */ +const DISCOVERY_LOOKBACK_MS = 7 * 24 * 60 * 60_000; + +export function buildServicesQuery(): { + query: string; + params: Record; +} { + const tsMin = String((Date.now() - DISCOVERY_LOOKBACK_MS) * 1e6); + return { + query: `SELECT DISTINCT ServiceName FROM otel_traces WHERE Timestamp >= {tsMin:DateTime64(9)} ORDER BY ServiceName`, + params: { tsMin: nanosToDateTime64(tsMin) }, + }; +} + +export function buildOperationsQuery(filter: { serviceName: string }): { + query: string; + params: Record; +} { + const tsMin = String((Date.now() - DISCOVERY_LOOKBACK_MS) * 1e6); + return { + query: `SELECT DISTINCT SpanName FROM otel_traces WHERE ServiceName = {serviceName:String} AND Timestamp >= {tsMin:DateTime64(9)} ORDER BY SpanName`, + params: { + serviceName: filter.serviceName, + tsMin: nanosToDateTime64(tsMin), + }, + }; +} + +export function buildTraceSummariesQuery( + filter: dataFilterSchemas.TraceSummariesFilter +): { + query: string; + params: Record; +} { + const outerConditions: string[] = []; + const spanConditions: string[] = []; + const havingConditions: string[] = []; + const params: Record = {}; + const limit = filter.limit ?? 20; + const sortOrder = filter.sortOrder === "ASC" ? "ASC" : "DESC"; + + // Span-level filters — used in subquery to find matching TraceIds + if (filter.serviceName) { + spanConditions.push("ServiceName = {serviceName:String}"); + params.serviceName = filter.serviceName; + } + if (filter.spanName) { + spanConditions.push("SpanName = {spanName:String}"); + params.spanName = filter.spanName; + } + if (filter.spanAttributes) { + let i = 0; + for (const [key, value] of Object.entries(filter.spanAttributes)) { + spanConditions.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)) { + spanConditions.push( + `ResourceAttributes[{resAttrKey${String(i)}:String}] = {resAttrVal${String(i)}:String}` + ); + params[`resAttrKey${String(i)}`] = key; + params[`resAttrVal${String(i)}`] = value; + i++; + } + } + + // Time range — applied to both outer query and span subquery + if (filter.timestampMin != null) { + outerConditions.push("Timestamp >= {tsMin:DateTime64(9)}"); + spanConditions.push("Timestamp >= {tsMin:DateTime64(9)}"); + params.tsMin = nanosToDateTime64(filter.timestampMin); + } + if (filter.timestampMax != null) { + outerConditions.push("Timestamp <= {tsMax:DateTime64(9)}"); + spanConditions.push("Timestamp <= {tsMax:DateTime64(9)}"); + params.tsMax = nanosToDateTime64(filter.timestampMax); + } + + // Restrict to matching TraceIds when span-level filters are present + if (spanConditions.length > 0) { + outerConditions.push( + `TraceId IN (SELECT DISTINCT TraceId FROM otel_traces WHERE ${spanConditions.join(" AND ")})` + ); + } + + // Duration filters — trace-level, applied as HAVING on aggregated duration + if (filter.durationMin != null) { + havingConditions.push( + "dateDiff('nanosecond', min(Timestamp), max(Timestamp + toIntervalNanosecond(Duration))) >= {durMin:UInt64}" + ); + params.durMin = filter.durationMin; + } + if (filter.durationMax != null) { + havingConditions.push( + "dateDiff('nanosecond', min(Timestamp), max(Timestamp + toIntervalNanosecond(Duration))) <= {durMax:UInt64}" + ); + params.durMax = filter.durationMax; + } + + // 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 = + outerConditions.length > 0 ? `WHERE ${outerConditions.join(" AND ")}` : ""; + const havingClause = + havingConditions.length > 0 + ? `HAVING ${havingConditions.join(" AND ")}` + : ""; + + const query = ` +SELECT + TraceId, + if(anyIf(ServiceName, ParentSpanId = '') != '', anyIf(ServiceName, ParentSpanId = ''), any(ServiceName)) as rootServiceName, + if(anyIf(SpanName, ParentSpanId = '') != '', anyIf(SpanName, ParentSpanId = ''), any(SpanName)) 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 = '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..b101270 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.input; + +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/pagination.test.ts b/packages/sdk/src/pagination.test.ts index ff14608..7bcc0c9 100644 --- a/packages/sdk/src/pagination.test.ts +++ b/packages/sdk/src/pagination.test.ts @@ -96,7 +96,6 @@ describe("paginate", () => { nextCursor: null, }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _item of paginate(fetcher, controller.signal)) { // empty } 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..fcc5b95 100644 --- a/packages/sqlite-datasource/src/datasource-read.test.ts +++ b/packages/sqlite-datasource/src/datasource-read.test.ts @@ -2117,6 +2117,470 @@ describe("OptimizedDatasource", () => { expect(tagsValue).not.toBe("[object Object]"); }); }); + + describe("getServices", () => { + let testConnection: DatabaseSync; + let ds: OptimizedDatasource; + let readDs: datasource.ReadTelemetryDatasource; + let insertSpan: ReturnType; + // Recent timestamp (within 7-day lookback window) + const recentNs = String(Date.now() * 1e6); + const recentEndNs = String(Date.now() * 1e6 + 1_000_000_000); + + 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: recentNs, + endTimeNanos: recentEndNs, + }); + await insertSpan({ + traceId: "t2", + spanId: "s2", + serviceName: "alpha-svc", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + // duplicate service + await insertSpan({ + traceId: "t3", + spanId: "s3", + serviceName: "beta-svc", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + + 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: recentNs, + endTimeNanos: recentEndNs, + }); + await insertSpan({ + traceId: "t1", + spanId: "s2", + serviceName: "svc-b", + parentSpanId: "s1", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + await insertSpan({ + traceId: "t2", + spanId: "s3", + serviceName: "svc-c", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + + 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; + const recentNs = String(Date.now() * 1e6); + const recentEndNs = String(Date.now() * 1e6 + 1_000_000_000); + + 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: recentNs, + endTimeNanos: recentEndNs, + }); + await insertSpan({ + traceId: "t2", + spanId: "s2", + serviceName: "my-svc", + spanName: "GET /api", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + // duplicate operation + await insertSpan({ + traceId: "t3", + spanId: "s3", + serviceName: "my-svc", + spanName: "GET /api", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + + 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: recentNs, + endTimeNanos: recentEndNs, + }); + await insertSpan({ + traceId: "t2", + spanId: "s2", + serviceName: "svc-b", + spanName: "op-b", + startTimeNanos: recentNs, + endTimeNanos: recentEndNs, + }); + + 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("falls back to any span when trace has no root span", async () => { + // All child spans — no ParentSpanId = '' + await insertSpan({ + traceId: "no-root", + spanId: "child-1", + parentSpanId: "missing-parent", + serviceName: "orphan-svc", + spanName: "orphan-op", + startTimeNanos: "1000000000000000", + endTimeNanos: "1000000200000000", + }); + await insertSpan({ + traceId: "no-root", + spanId: "child-2", + parentSpanId: "missing-parent", + serviceName: "orphan-svc-2", + spanName: "orphan-op-2", + 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("no-root"); + expect(summary.rootServiceName).toBeTruthy(); + expect(summary.rootSpanName).toBeTruthy(); + expect(summary.spanCount).toBe(2); + }); + + 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 +2596,7 @@ function createInsertSpan( scopeName?: string; startTimeNanos: string; endTimeNanos: string; + parentSpanId?: string; spanAttributes?: Record; resourceAttributes?: Record; events?: { name: string; timeUnixNano: string }[]; @@ -2170,6 +2635,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..87af600 100644 --- a/packages/sqlite-datasource/src/db-datasource.ts +++ b/packages/sqlite-datasource/src/db-datasource.ts @@ -38,6 +38,9 @@ const queryBuilder = new Kysely({ }, }); +/** Default lookback for services/operations discovery (7 days in ms). */ +const DISCOVERY_LOOKBACK_MS = 7 * 24 * 60 * 60_000; + export class DbDatasource implements datasource.TelemetryDatasource { constructor(private sqliteConnection: DatabaseSync) {} @@ -907,6 +910,292 @@ export class DbDatasource implements datasource.TelemetryDatasource { }); } } + + async getServices(): Promise<{ services: string[] }> { + try { + const tsMin = BigInt((Date.now() - DISCOVERY_LOOKBACK_MS) * 1e6); + const { sql, parameters } = queryBuilder + .selectFrom("otel_traces") + .select("ServiceName") + .distinct() + .where("Timestamp", ">=", tsMin) + .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 tsMin = BigInt((Date.now() - DISCOVERY_LOOKBACK_MS) * 1e6); + const { sql, parameters } = queryBuilder + .selectFrom("otel_traces") + .select("SpanName") + .distinct() + .where("ServiceName", "=", filter.serviceName) + .where("Timestamp", ">=", tsMin) + .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(":"); + if (colonIdx === -1) { + throw new SqliteDatasourceQueryError( + "Invalid cursor format: expected '{timestamp}:{id}'" + ); + } + 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 + // Duration filters apply at trace level (End - Start), not span level + if (filter.durationMin != null) { + traceIdClauses.push("(t.End - t.Start) >= ?"); + traceIdParams.push(BigInt(filter.durationMin)); + } + if (filter.durationMax != null) { + traceIdClauses.push("(t.End - t.Start) <= ?"); + traceIdParams.push(BigInt(filter.durationMax)); + } + + const hasSpanFilters = + filter.serviceName || + filter.spanName || + 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.spanAttributes) { + for (const [key, value] of Object.entries(filter.spanAttributes)) { + const jsonPath = `$."${key.replace(/"/g, '""')}"`; + const safePath = jsonPath.replace(/'/g, "''"); + spanClauses.push( + `json_extract(s.SpanAttributes, '${safePath}') = ?` + ); + spanFilterParams.push(value); + } + } + if (filter.resourceAttributes) { + for (const [key, value] of Object.entries( + filter.resourceAttributes + )) { + const jsonPath = `$."${key.replace(/"/g, '""')}"`; + const safePath = jsonPath.replace(/'/g, "''"); + spanClauses.push( + `json_extract(s.ResourceAttributes, '${safePath}') = ?` + ); + spanFilterParams.push(value); + } + } + + spanFilterJoin = `AND t.TraceId IN (SELECT DISTINCT TraceId FROM otel_traces s WHERE ${spanClauses.join(" AND ")})`; + } + + const traceIdSql = ` + SELECT t.TraceId, t.Start + FROM otel_traces_trace_id_ts t + WHERE ${traceIdClauses.join(" AND ")} + ${spanFilterJoin} + ORDER BY t.Start ${sortOrder}, t.TraceId ${sortOrder} + 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: Aggregate spans per trace in SQL (1 row per trace) + const traceIds = pageTraceRows.map((r) => r.TraceId); + const placeholders = traceIds.map(() => "?").join(","); + + const aggSql = ` + SELECT + TraceId, + COALESCE(MIN(CASE WHEN ParentSpanId = '' THEN ServiceName END), MIN(ServiceName)) as rootServiceName, + COALESCE(MIN(CASE WHEN ParentSpanId = '' THEN SpanName END), MIN(SpanName)) as rootSpanName, + CAST(MIN(Timestamp) AS TEXT) as startTimeNs, + CAST(MAX(Timestamp + Duration) - MIN(Timestamp) AS TEXT) as durationNs, + COUNT(*) as spanCount, + SUM(CASE WHEN StatusCode = 'STATUS_CODE_ERROR' THEN 1 ELSE 0 END) as errorCount + FROM otel_traces + WHERE TraceId IN (${placeholders}) + GROUP BY TraceId + `; + const aggRows = this.sqliteConnection + .prepare(aggSql) + .all(...traceIds) as { + TraceId: string; + rootServiceName: string | null; + rootSpanName: string | null; + startTimeNs: string; + durationNs: string; + spanCount: number; + errorCount: number; + }[]; + + // Step 3: Per-service breakdown (small result: ~traces × avg services) + const svcSql = ` + SELECT TraceId, ServiceName, COUNT(*) as cnt, + MAX(CASE WHEN StatusCode = 'STATUS_CODE_ERROR' THEN 1 ELSE 0 END) as hasError + FROM otel_traces + WHERE TraceId IN (${placeholders}) + GROUP BY TraceId, ServiceName + `; + const svcRows = this.sqliteConnection + .prepare(svcSql) + .all(...traceIds) as { + TraceId: string; + ServiceName: string; + cnt: number; + hasError: number; + }[]; + + const svcMap = new Map< + string, + { name: string; count: number; hasError: boolean }[] + >(); + for (const row of svcRows) { + let arr = svcMap.get(row.TraceId); + if (!arr) { + arr = []; + svcMap.set(row.TraceId, arr); + } + arr.push({ + name: row.ServiceName, + count: row.cnt, + hasError: row.hasError === 1, + }); + } + + // Build lookup for aggregate rows + const aggMap = new Map(aggRows.map((r) => [r.TraceId, r])); + + // Build results in the same order as traceIds + const data: dataFilterSchemas.TraceSummaryRow[] = []; + + for (const traceId of traceIds) { + const agg = aggMap.get(traceId); + if (!agg) continue; + + data.push({ + traceId, + rootServiceName: agg.rootServiceName ?? "", + rootSpanName: agg.rootSpanName ?? "", + startTimeNs: agg.startTimeNs, + durationNs: agg.durationNs, + spanCount: agg.spanCount, + errorCount: agg.errorCount, + services: svcMap.get(traceId) ?? [], + }); + } + + 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/KeyboardShortcuts/KeyboardShortcutsProvider.tsx b/packages/ui/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx index a2e2d6e..c5e758e 100644 --- a/packages/ui/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +++ b/packages/ui/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx @@ -8,7 +8,7 @@ const GENERAL_GROUP: ShortcutGroup = { name: "General", shortcuts: [ { keys: ["Shift", "?"], description: "Toggle shortcuts help" }, - { keys: ["Shift", "S"], description: "Services tab" }, + { keys: ["Shift", "T"], description: "Traces tab" }, { keys: ["Shift", "L"], description: "Logs tab" }, { keys: ["Shift", "M"], description: "Metrics tab" }, ], @@ -48,12 +48,12 @@ export function KeyboardShortcutsProvider({ useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - const target = e.target as HTMLElement; + if (!(e.target instanceof HTMLElement)) return; if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable + e.target.tagName === "INPUT" || + e.target.tagName === "TEXTAREA" || + e.target.tagName === "SELECT" || + e.target.isContentEditable ) { return; } @@ -70,7 +70,7 @@ export function KeyboardShortcutsProvider({ return; } - if (e.shiftKey && e.key === "S") { + if (e.shiftKey && e.key === "T") { e.preventDefault(); onNavigateServices(); return; 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/LogTimeline/LogFilter.tsx b/packages/ui/src/components/observability/LogTimeline/LogFilter.tsx index 53e3079..2b91460 100644 --- a/packages/ui/src/components/observability/LogTimeline/LogFilter.tsx +++ b/packages/ui/src/components/observability/LogTimeline/LogFilter.tsx @@ -130,7 +130,11 @@ function MultiSelect({ useEffect(() => { if (!dropOpen) return; const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { + if ( + ref.current && + e.target instanceof Node && + !ref.current.contains(e.target) + ) { setDropOpen(false); } }; diff --git a/packages/ui/src/components/observability/LogTimeline/index.tsx b/packages/ui/src/components/observability/LogTimeline/index.tsx index c0cb925..2ccef75 100644 --- a/packages/ui/src/components/observability/LogTimeline/index.tsx +++ b/packages/ui/src/components/observability/LogTimeline/index.tsx @@ -269,8 +269,12 @@ export function LogTimeline({ e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement; - if (isFormField && e.key === "Escape") { - (e.target as HTMLElement).blur(); + if ( + isFormField && + e.key === "Escape" && + e.target instanceof HTMLElement + ) { + e.target.blur(); return; } if (isFormField) return; diff --git a/packages/ui/src/components/observability/MetricHistogram/index.tsx b/packages/ui/src/components/observability/MetricHistogram/index.tsx index 8fe8c56..817ffdd 100644 --- a/packages/ui/src/components/observability/MetricHistogram/index.tsx +++ b/packages/ui/src/components/observability/MetricHistogram/index.tsx @@ -133,7 +133,8 @@ function buildHistogramData( }; buckets.push(bucket); } - bucket[seriesName] = ((bucket[seriesName] as number) ?? 0) + count; + const prev = bucket[seriesName]; + bucket[seriesName] = (typeof prev === "number" ? prev : 0) + count; } } diff --git a/packages/ui/src/components/observability/ServiceList/shortcuts.ts b/packages/ui/src/components/observability/ServiceList/shortcuts.ts index 84afa3e..f29aec5 100644 --- a/packages/ui/src/components/observability/ServiceList/shortcuts.ts +++ b/packages/ui/src/components/observability/ServiceList/shortcuts.ts @@ -1,6 +1,6 @@ import type { ShortcutGroup } from "../../KeyboardShortcuts/types.js"; export const SERVICES_SHORTCUTS: ShortcutGroup = { - name: "Services", + name: "Traces", shortcuts: [{ keys: ["Backspace"], description: "Go back" }], }; 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/TraceDetail.stories.tsx b/packages/ui/src/components/observability/TraceDetail/TraceDetail.stories.tsx index 6d09d74..4306d0f 100644 --- a/packages/ui/src/components/observability/TraceDetail/TraceDetail.stories.tsx +++ b/packages/ui/src/components/observability/TraceDetail/TraceDetail.stories.tsx @@ -18,7 +18,6 @@ type Story = StoryObj; export const Default: Story = { args: { - service: "api-gateway", traceId: "0af7651916cd43dd8448eb211c80319c", rows: mockTraceRows, }, @@ -26,7 +25,6 @@ export const Default: Story = { export const ErrorTrace: Story = { args: { - service: "api-gateway", traceId: "1bf8762027de54ee9559fc322d91420d", rows: mockErrorTraceRows, }, @@ -34,7 +32,6 @@ export const ErrorTrace: Story = { export const Loading: Story = { args: { - service: "api-gateway", traceId: "0af7651916cd43dd8448eb211c80319c", rows: [], isLoading: true, @@ -43,7 +40,6 @@ export const Loading: Story = { export const Error: Story = { args: { - service: "api-gateway", traceId: "0af7651916cd43dd8448eb211c80319c", rows: [], error: new globalThis.Error("Failed to fetch trace"), diff --git a/packages/ui/src/components/observability/TraceDetail/index.tsx b/packages/ui/src/components/observability/TraceDetail/index.tsx index d3d06af..8b696b9 100644 --- a/packages/ui/src/components/observability/TraceDetail/index.tsx +++ b/packages/ui/src/components/observability/TraceDetail/index.tsx @@ -5,24 +5,24 @@ import type { SpanNode } from "../types.js"; type OtelTracesRow = denormalizedSignals.OtelTracesRow; export interface TraceDetailProps { - service: string; traceId: string; rows: OtelTracesRow[]; isLoading?: boolean; error?: Error; selectedSpanId?: string; onSpanClick?: (span: SpanNode) => void; + onSpanDeselect?: () => void; onBack: () => void; } export function TraceDetail({ - service, traceId, rows, isLoading, error, selectedSpanId, onSpanClick, + onSpanDeselect, onBack, }: TraceDetailProps) { return ( @@ -33,7 +33,7 @@ export function TraceDetail({ onClick={onBack} className="hover:text-foreground transition-colors" > - Services / {service} + Traces / @@ -47,6 +47,7 @@ export function TraceDetail({ error={error} selectedSpanId={selectedSpanId} onSpanClick={onSpanClick} + onSpanDeselect={onSpanDeselect} /> ); 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..0da29a1 --- /dev/null +++ b/packages/ui/src/components/observability/TraceSearch/DurationBar.tsx @@ -0,0 +1,38 @@ +/** + * 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 rawPct = maxDurationMs > 0 ? (durationMs / maxDurationMs) * 100 : 0; + const widthPct = durationMs <= 0 ? 0 : Math.min(Math.max(rawPct, 1), 100); + + 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..42db733 --- /dev/null +++ b/packages/ui/src/components/observability/TraceSearch/SearchForm.tsx @@ -0,0 +1,195 @@ +/** + * SearchForm - Jaeger-style sidebar search form for trace filtering. + * Owns its own form state; parent only receives values on submit. + */ + +import { useState, useEffect } from "react"; + +export interface SearchFormValues { + service: string; + operation: string; + tags: string; + lookback: string; + minDuration: string; + maxDuration: string; + limit: number; +} + +export interface SearchFormProps { + services: string[]; + operations: string[]; + initialValues?: Partial; + onSubmit: (values: SearchFormValues) => 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, + initialValues, + onSubmit, + isLoading, +}: SearchFormProps) { + const [service, setService] = useState(initialValues?.service ?? ""); + const [operation, setOperation] = useState(initialValues?.operation ?? ""); + const [tags, setTags] = useState(initialValues?.tags ?? ""); + const [lookback, setLookback] = useState(initialValues?.lookback ?? ""); + const [minDuration, setMinDuration] = useState( + initialValues?.minDuration ?? "" + ); + const [maxDuration, setMaxDuration] = useState( + initialValues?.maxDuration ?? "" + ); + const [limit, setLimit] = useState(initialValues?.limit ?? 20); + + // Sync service from URL-driven changes + useEffect(() => { + if (initialValues?.service != null) setService(initialValues.service); + }, [initialValues?.service]); + + const handleSubmit = () => { + onSubmit({ + service, + operation, + tags, + lookback, + minDuration, + maxDuration, + limit, + }); + }; + + return ( +
+

+ Search +

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