diff --git a/CLAUDE.md b/CLAUDE.md index 6e97f4a..f54b32e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,11 @@ export const myEndpoint = defineEndpoint("my_endpoint", { Never use raw `fetch()` calls to `/v0/sql` - always define typed endpoints for type safety and consistency. +When adding a new Tinybird endpoint, register it in **three** places: +1. Define in `packages/domain/src/tinybird/endpoints.ts` +2. Add pipe name to `packages/domain/src/tinybird-pipes.ts` +3. Register in `apps/api/src/services/TinybirdService.ts` pipes config + import + ## Environment Variables ``` diff --git a/apps/api/src/services/TinybirdService.ts b/apps/api/src/services/TinybirdService.ts index c6f287e..236c86d 100644 --- a/apps/api/src/services/TinybirdService.ts +++ b/apps/api/src/services/TinybirdService.ts @@ -25,6 +25,8 @@ import { errorsFacets, errorsSummary, getServiceUsage, + httpEndpointsOverview, + httpEndpointsTimeseries, listLogs, listMetrics, listTraces, @@ -99,6 +101,8 @@ export class TinybirdService extends Effect.Service()("Tinybird span_attribute_values: spanAttributeValues, resource_attribute_keys: resourceAttributeKeys, resource_attribute_values: resourceAttributeValues, + http_endpoints_overview: httpEndpointsOverview, + http_endpoints_timeseries: httpEndpointsTimeseries, }, }) diff --git a/apps/web/src/api/tinybird/custom-charts.ts b/apps/web/src/api/tinybird/custom-charts.ts index cf485ff..3788637 100644 --- a/apps/web/src/api/tinybird/custom-charts.ts +++ b/apps/web/src/api/tinybird/custom-charts.ts @@ -30,7 +30,7 @@ function sortByBucket(rows: T[]): T[] { return [...rows].sort((left, right) => left.bucket.localeCompare(right.bucket)) } -function fillServiceDetailPoints( +export function fillServiceDetailPoints( points: ServiceDetailTimeSeriesPoint[], startTime: string | undefined, endTime: string | undefined, diff --git a/apps/web/src/api/tinybird/endpoint-detail.ts b/apps/web/src/api/tinybird/endpoint-detail.ts new file mode 100644 index 0000000..8ff420f --- /dev/null +++ b/apps/web/src/api/tinybird/endpoint-detail.ts @@ -0,0 +1,136 @@ +import { Effect, Schema } from "effect" +import { getTinybird } from "@/lib/tinybird" +import { + computeBucketSeconds, + toIsoBucket, +} from "@/api/tinybird/timeseries-utils" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, +} from "@/api/tinybird/effect-utils" +import { fillServiceDetailPoints } from "@/api/tinybird/custom-charts" +import type { ServiceDetailTimeSeriesPoint } from "@/api/tinybird/services" + +const GetEndpointDetailTimeSeriesInputSchema = Schema.Struct({ + serviceName: Schema.String, + spanName: Schema.String, + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) + +export type GetEndpointDetailTimeSeriesInput = Schema.Schema.Type< + typeof GetEndpointDetailTimeSeriesInputSchema +> + +export function getEndpointDetailTimeSeries({ + data, +}: { + data: GetEndpointDetailTimeSeriesInput +}) { + return getEndpointDetailTimeSeriesEffect({ data }) +} + +const getEndpointDetailTimeSeriesEffect = Effect.fn( + "Tinybird.getEndpointDetailTimeSeries", +)(function* ({ + data, +}: { + data: GetEndpointDetailTimeSeriesInput +}) { + const input = yield* decodeInput( + GetEndpointDetailTimeSeriesInputSchema, + data, + "getEndpointDetailTimeSeries", + ) + + const tinybird = getTinybird() + const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) + const result = yield* runTinybirdQuery("custom_traces_timeseries", () => + tinybird.query.custom_traces_timeseries({ + start_time: input.startTime, + end_time: input.endTime, + bucket_seconds: bucketSeconds, + service_name: input.serviceName, + span_name: input.spanName, + }), + ) + + const points = result.data.map( + (row): ServiceDetailTimeSeriesPoint => ({ + bucket: toIsoBucket(row.bucket), + throughput: Number(row.count), + errorRate: Number(row.errorRate), + p50LatencyMs: Number(row.p50Duration), + p95LatencyMs: Number(row.p95Duration), + p99LatencyMs: Number(row.p99Duration), + }), + ) + + return { + data: fillServiceDetailPoints( + points, + input.startTime, + input.endTime, + bucketSeconds, + ), + } +}) + +const GetEndpointStatusCodeBreakdownInputSchema = Schema.Struct({ + serviceName: Schema.String, + spanName: Schema.String, + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) + +export type GetEndpointStatusCodeBreakdownInput = Schema.Schema.Type< + typeof GetEndpointStatusCodeBreakdownInputSchema +> + +export interface StatusCodeBreakdownItem { + statusCode: string + count: number +} + +export function getEndpointStatusCodeBreakdown({ + data, +}: { + data: GetEndpointStatusCodeBreakdownInput +}) { + return getEndpointStatusCodeBreakdownEffect({ data }) +} + +const getEndpointStatusCodeBreakdownEffect = Effect.fn( + "Tinybird.getEndpointStatusCodeBreakdown", +)(function* ({ + data, +}: { + data: GetEndpointStatusCodeBreakdownInput +}) { + const input = yield* decodeInput( + GetEndpointStatusCodeBreakdownInputSchema, + data, + "getEndpointStatusCodeBreakdown", + ) + + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("custom_traces_breakdown", () => + tinybird.query.custom_traces_breakdown({ + start_time: input.startTime, + end_time: input.endTime, + service_name: input.serviceName, + span_name: input.spanName, + group_by_attribute: "http.status_code", + }), + ) + + return { + data: result.data.map( + (row): StatusCodeBreakdownItem => ({ + statusCode: String(row.name), + count: Number(row.count), + }), + ), + } +}) diff --git a/apps/web/src/api/tinybird/endpoints-overview.ts b/apps/web/src/api/tinybird/endpoints-overview.ts new file mode 100644 index 0000000..df37cd2 --- /dev/null +++ b/apps/web/src/api/tinybird/endpoints-overview.ts @@ -0,0 +1,188 @@ +import { Effect, Schema } from "effect" +import { getTinybird, type HttpEndpointsOverviewOutput } from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, +} from "@/api/tinybird/effect-utils" +import { + buildBucketTimeline, + computeBucketSeconds, + toIsoBucket, +} from "@/api/tinybird/timeseries-utils" + +const dateTimeString = TinybirdDateTimeString + +export interface HttpEndpointOverview { + serviceName: string + endpointName: string + httpMethod: string + count: number + avgDuration: number + p50Duration: number + p95Duration: number + p99Duration: number + errorRate: number +} + +export interface HttpEndpointsOverviewResponse { + data: HttpEndpointOverview[] +} + +const GetHttpEndpointsOverviewInput = Schema.Struct({ + startTime: dateTimeString, + endTime: dateTimeString, + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +export type GetHttpEndpointsOverviewInput = Schema.Schema.Type + +function coerceRow(raw: HttpEndpointsOverviewOutput): HttpEndpointOverview { + return { + serviceName: raw.serviceName, + endpointName: raw.endpointName, + httpMethod: raw.httpMethod || "UNKNOWN", + count: Number(raw.count), + avgDuration: Number(raw.avgDuration), + p50Duration: Number(raw.p50Duration), + p95Duration: Number(raw.p95Duration), + p99Duration: Number(raw.p99Duration), + errorRate: Number(raw.errorRate), + } +} + +export function getHttpEndpointsOverview({ + data, +}: { + data: GetHttpEndpointsOverviewInput +}) { + return getHttpEndpointsOverviewEffect({ data }) +} + +const getHttpEndpointsOverviewEffect = Effect.fn("Tinybird.getHttpEndpointsOverview")( + function* ({ + data, + }: { + data: GetHttpEndpointsOverviewInput + }) { + const input = yield* decodeInput( + GetHttpEndpointsOverviewInput, + data, + "getHttpEndpointsOverview", + ) + + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("http_endpoints_overview", () => + tinybird.query.http_endpoints_overview({ + start_time: input.startTime, + end_time: input.endTime, + services: input.services?.join(","), + http_methods: input.httpMethods?.join(","), + environments: input.environments?.join(","), + }), + ) + + return { + data: result.data.map(coerceRow), + } + }, +) + +// Sparkline types +export interface EndpointSparklinePoint { + bucket: string + throughput: number + errorRate: number +} + +const GetHttpEndpointsSparklinesInput = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +export type GetHttpEndpointsSparklinesInput = Schema.Schema.Type + +function fillSparklinePoints( + points: EndpointSparklinePoint[], + timeline: string[], +): EndpointSparklinePoint[] { + if (timeline.length === 0) { + return [...points].sort((a, b) => a.bucket.localeCompare(b.bucket)) + } + + const byBucket = new Map() + for (const point of points) { + byBucket.set(toIsoBucket(point.bucket), point) + } + + return timeline.map((bucket) => { + const existing = byBucket.get(bucket) + if (existing) return existing + return { bucket, throughput: 0, errorRate: 0 } + }) +} + +export function getHttpEndpointsSparklines({ + data, +}: { + data: GetHttpEndpointsSparklinesInput +}) { + return getHttpEndpointsSparklinesEffect({ data }) +} + +const getHttpEndpointsSparklinesEffect = Effect.fn("Tinybird.getHttpEndpointsSparklines")( + function* ({ + data, + }: { + data: GetHttpEndpointsSparklinesInput + }) { + const input = yield* decodeInput( + GetHttpEndpointsSparklinesInput, + data ?? {}, + "getHttpEndpointsSparklines", + ) + + const tinybird = getTinybird() + const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) + const result = yield* runTinybirdQuery("http_endpoints_timeseries", () => + tinybird.query.http_endpoints_timeseries({ + start_time: input.startTime, + end_time: input.endTime, + bucket_seconds: bucketSeconds, + services: input.services?.join(","), + http_methods: input.httpMethods?.join(","), + environments: input.environments?.join(","), + }), + ) + + const timeline = buildBucketTimeline(input.startTime, input.endTime, bucketSeconds) + // endpointKey format: "serviceName::endpointName::httpMethod" + const grouped: Record = {} + for (const row of result.data) { + const bucket = toIsoBucket(row.bucket) + const point: EndpointSparklinePoint = { + bucket, + throughput: Number(row.count), + errorRate: Number(row.errorRate), + } + if (!grouped[row.endpointKey]) { + grouped[row.endpointKey] = [] + } + grouped[row.endpointKey].push(point) + } + + const filledGrouped = Object.fromEntries( + Object.entries(grouped).map(([key, points]) => [ + key, + fillSparklinePoints(points, timeline), + ]), + ) + + return { data: filledGrouped } + }, +) diff --git a/apps/web/src/components/dashboard/app-sidebar.tsx b/apps/web/src/components/dashboard/app-sidebar.tsx index 4632b4a..3927d09 100644 --- a/apps/web/src/components/dashboard/app-sidebar.tsx +++ b/apps/web/src/components/dashboard/app-sidebar.tsx @@ -4,6 +4,7 @@ import { HouseIcon, FileIcon, PulseIcon, + ChartBarIcon, ChartLineIcon, ServerIcon, CircleWarningIcon, @@ -69,6 +70,11 @@ const servicesNavItems = [ ] const telemetryNavItems = [ + { + title: "Endpoints", + href: "/endpoints", + icon: ChartBarIcon, + }, { title: "Errors", href: "/errors", diff --git a/apps/web/src/components/endpoints/endpoint-recent-traces.tsx b/apps/web/src/components/endpoints/endpoint-recent-traces.tsx new file mode 100644 index 0000000..0f74f63 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoint-recent-traces.tsx @@ -0,0 +1,156 @@ +import { Link, useNavigate } from "@tanstack/react-router" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { Badge } from "@maple/ui/components/ui/badge" +import type { Trace } from "@/api/tinybird/traces" + +function formatDuration(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(0)}us` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp) + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +function truncateId(id: string, length = 8): string { + if (id.length <= length) return id + return id.slice(0, length) +} + +function StatusBadge({ hasError }: { hasError: boolean }) { + if (hasError) { + return ( + + Error + + ) + } + return ( + + OK + + ) +} + +interface EndpointRecentTracesProps { + traces: Trace[] + service: string + endpoint: string + method: string + startTime?: string + endTime?: string +} + +export function EndpointRecentTraces({ + traces, + service, + endpoint, + method, + startTime, + endTime, +}: EndpointRecentTracesProps) { + const navigate = useNavigate() + + return ( +
+
+ + + + Trace ID + Time + Duration + Spans + Status + + + + {traces.length === 0 ? ( + + + No traces found for this endpoint + + + ) : ( + traces.map((trace) => ( + + navigate({ + to: "/traces/$traceId", + params: { traceId: trace.traceId }, + }) + } + > + + e.stopPropagation()} + > + {truncateId(trace.traceId)} + + + + {formatTimestamp(trace.startTime)} + + + {formatDuration(trace.durationMs)} + + + {trace.spanCount} + + + + + + )) + )} + +
+
+ + {traces.length > 0 && ( +
+ + View all traces for this endpoint → + +
+ )} +
+ ) +} diff --git a/apps/web/src/components/endpoints/endpoint-status-breakdown.tsx b/apps/web/src/components/endpoints/endpoint-status-breakdown.tsx new file mode 100644 index 0000000..2bc84a1 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoint-status-breakdown.tsx @@ -0,0 +1,73 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { Badge } from "@maple/ui/components/ui/badge" +import type { StatusCodeBreakdownItem } from "@/api/tinybird/endpoint-detail" + +function getStatusColorClass(code: string): string { + const num = Number.parseInt(code, 10) + if (num >= 500) return "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400" + if (num >= 400) return "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400" + if (num >= 300) return "bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400" + if (num >= 200) return "bg-green-500/10 text-green-600 dark:bg-green-400/10 dark:text-green-400" + return "" +} + +interface EndpointStatusBreakdownProps { + data: StatusCodeBreakdownItem[] +} + +export function EndpointStatusBreakdown({ data }: EndpointStatusBreakdownProps) { + const total = data.reduce((sum, item) => sum + item.count, 0) + const sorted = [...data] + .filter((item) => item.statusCode && item.statusCode !== "") + .sort((a, b) => b.count - a.count) + + if (sorted.length === 0) { + return ( +
+ No status code data available +
+ ) + } + + return ( + + + + Status Code + Count + Percentage + + + + {sorted.map((item) => { + const pct = total > 0 ? (item.count / total) * 100 : 0 + return ( + + + + {item.statusCode} + + + + {item.count.toLocaleString()} + + + {pct.toFixed(1)}% + + + ) + })} + +
+ ) +} diff --git a/apps/web/src/components/endpoints/endpoint-summary-stats.tsx b/apps/web/src/components/endpoints/endpoint-summary-stats.tsx new file mode 100644 index 0000000..34c8c26 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoint-summary-stats.tsx @@ -0,0 +1,74 @@ +import type { HttpEndpointOverview } from "@/api/tinybird/endpoints-overview" + +function formatLatency(ms: number): string { + if (ms == null || Number.isNaN(ms)) return "-" + if (ms < 1) return `${(ms * 1000).toFixed(0)}us` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` + return n.toLocaleString() +} + +function formatRate(rate: number): string { + if (rate < 0.01) return "0%" + if (rate < 1) return `${rate.toFixed(2)}%` + return `${rate.toFixed(1)}%` +} + +function formatThroughput(count: number, durationSeconds: number): string { + if (durationSeconds <= 0) return `${count}` + const rate = count / durationSeconds + if (rate >= 1000) return `${(rate / 1000).toFixed(1)}k/s` + if (rate >= 1) return `${rate.toFixed(1)}/s` + if (rate >= 0.01) return `${rate.toFixed(2)}/s` + return `${rate.toFixed(3)}/s` +} + +interface StatCardProps { + label: string + value: string +} + +function StatCard({ label, value }: StatCardProps) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +interface EndpointSummaryStatsProps { + endpoint: HttpEndpointOverview | undefined + durationSeconds: number +} + +export function EndpointSummaryStats({ endpoint, durationSeconds }: EndpointSummaryStatsProps) { + if (!endpoint) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) + } + + return ( +
+ + + + + + +
+ ) +} diff --git a/apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx b/apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx new file mode 100644 index 0000000..e2ca935 --- /dev/null +++ b/apps/web/src/components/endpoints/endpoints-filter-sidebar.tsx @@ -0,0 +1,92 @@ +import { useNavigate } from "@tanstack/react-router" + +import { + FilterSection, + SearchableFilterSection, +} from "@/components/filters/filter-section" +import type { FilterOption } from "@/components/filters/filter-section" +import { Separator } from "@maple/ui/components/ui/separator" +import { + FilterSidebarBody, + FilterSidebarFrame, + FilterSidebarHeader, + FilterSidebarLoading, +} from "@/components/filters/filter-sidebar" +import { Route } from "@/routes/endpoints" + +function LoadingState() { + return +} + +export interface EndpointsFacets { + services: FilterOption[] + httpMethods: FilterOption[] +} + +interface EndpointsFilterSidebarProps { + facets: EndpointsFacets | undefined + isLoading: boolean +} + +export function EndpointsFilterSidebar({ facets, isLoading }: EndpointsFilterSidebarProps) { + const navigate = useNavigate({ from: Route.fullPath }) + const search = Route.useSearch() + + const updateFilter = ( + key: K, + value: (typeof search)[K], + ) => { + navigate({ + search: (prev) => ({ + ...prev, + [key]: + value === undefined || (Array.isArray(value) && value.length === 0) + ? undefined + : value, + }), + }) + } + + const clearAllFilters = () => { + navigate({ + search: { + startTime: search.startTime, + endTime: search.endTime, + }, + }) + } + + const hasActiveFilters = + (search.services?.length ?? 0) > 0 || + (search.httpMethods?.length ?? 0) > 0 + + if (isLoading || !facets) { + return + } + + return ( + + + + updateFilter("services", val)} + /> + + {facets.httpMethods.length > 0 && ( + <> + + updateFilter("httpMethods", val)} + /> + + )} + + + ) +} diff --git a/apps/web/src/components/endpoints/endpoints-table.tsx b/apps/web/src/components/endpoints/endpoints-table.tsx new file mode 100644 index 0000000..70af0da --- /dev/null +++ b/apps/web/src/components/endpoints/endpoints-table.tsx @@ -0,0 +1,260 @@ +import { Result, useAtomValue } from "@effect-atom/atom-react" +import { Link } from "@tanstack/react-router" + +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Sparkline } from "@maple/ui/components/ui/gradient-chart" +import type { HttpEndpointOverview } from "@/api/tinybird/endpoints-overview" +import { MethodBadge } from "@/components/endpoints/method-badge" +import { + getHttpEndpointsOverviewResultAtom, + getHttpEndpointsSparklinesResultAtom, +} from "@/lib/services/atoms/tinybird-query-atoms" + +function formatLatency(ms: number): string { + if (ms == null || Number.isNaN(ms)) return "-" + if (ms < 1) return `${(ms * 1000).toFixed(0)}μs` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatThroughput(count: number, durationSeconds: number): string { + if (durationSeconds <= 0) return `${count}` + const rate = count / durationSeconds + if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M/s` + if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k/s` + if (rate >= 100) return `${rate.toFixed(0)}/s` + if (rate >= 10) return `${rate.toFixed(1)}/s` + if (rate >= 1) return `${rate.toFixed(1)}/s` + if (rate >= 0.01) return `${rate.toFixed(2)}/s` + return `${rate.toFixed(3)}/s` +} + +function formatErrorRate(rate: number): string { + if (rate < 0.01) return "0%" + if (rate < 1) return `${rate.toFixed(2)}%` + return `${rate.toFixed(1)}%` +} + +export interface EndpointsTableProps { + filters?: { + startTime?: string + endTime?: string + services?: string[] + httpMethods?: string[] + environments?: string[] + } +} + +function LoadingState() { + return ( +
+ + + + Method + Endpoint + Service + P50 + P95 + P99 + Throughput + Error Rate + + + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + + ))} + +
+
+ ) +} + +function endpointKey(endpoint: HttpEndpointOverview): string { + return `${endpoint.serviceName}::${endpoint.endpointName}::${endpoint.httpMethod}` +} + +export function EndpointsTable({ filters }: EndpointsTableProps) { + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = + useEffectiveTimeRange(filters?.startTime, filters?.endTime) + + const durationSeconds = Math.max( + (new Date(effectiveEndTime).getTime() - new Date(effectiveStartTime).getTime()) / 1000, + 1, + ) + + const overviewResult = useAtomValue( + getHttpEndpointsOverviewResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + services: filters?.services, + httpMethods: filters?.httpMethods, + environments: filters?.environments, + }, + }), + ) + + const sparklinesResult = useAtomValue( + getHttpEndpointsSparklinesResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + services: filters?.services, + httpMethods: filters?.httpMethods, + environments: filters?.environments, + }, + }), + ) + + return Result.builder(Result.all([overviewResult, sparklinesResult])) + .onInitial(() => ) + .onError((error) => ( +
+

Failed to load endpoints

+
{error.message}
+
+ )) + .onSuccess(([overviewResponse, sparklinesResponse], combinedResult) => { + const endpoints = overviewResponse.data + const sparklinesMap = sparklinesResponse.data + + return ( +
+
+ + + + Method + Endpoint + Service + P50 + P95 + P99 + Throughput + Error Rate + + + + {endpoints.length === 0 ? ( + + + No HTTP endpoints found in traces + + + ) : ( + endpoints.map((endpoint: HttpEndpointOverview) => { + const key = endpointKey(endpoint) + const series = sparklinesMap[key] + const throughputData = series?.map((p) => ({ value: p.throughput })) ?? [] + const errorRateData = series?.map((p) => ({ value: p.errorRate })) ?? [] + + return ( + + + + + + + {endpoint.endpointName} + + + + + {endpoint.serviceName} + + + + {formatLatency(endpoint.p50Duration)} + + + {formatLatency(endpoint.p95Duration)} + + + {formatLatency(endpoint.p99Duration)} + + +
+ +
+ + {formatThroughput(endpoint.count, durationSeconds)} + +
+
+
+ +
+ +
+ + {formatErrorRate(endpoint.errorRate)} + +
+
+
+
+ ) + }) + )} +
+
+
+ +
+ Showing {endpoints.length} endpoint{endpoints.length !== 1 ? "s" : ""} +
+
+ ) + }) + .render() +} diff --git a/apps/web/src/components/endpoints/method-badge.tsx b/apps/web/src/components/endpoints/method-badge.tsx new file mode 100644 index 0000000..cd5416a --- /dev/null +++ b/apps/web/src/components/endpoints/method-badge.tsx @@ -0,0 +1,18 @@ +import { Badge } from "@maple/ui/components/ui/badge" + +export const METHOD_COLORS: Record = { + GET: "bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400", + POST: "bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400", + PUT: "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400", + PATCH: "bg-orange-500/10 text-orange-600 dark:bg-orange-400/10 dark:text-orange-400", + DELETE: "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400", +} + +export function MethodBadge({ method, className }: { method: string; className?: string }) { + const colorClass = METHOD_COLORS[method.toUpperCase()] ?? "" + return ( + + {method} + + ) +} diff --git a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts index b33c192..e926ce0 100644 --- a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts @@ -6,6 +6,8 @@ import { getCustomChartTimeSeries, getOverviewTimeSeries, } from "@/api/tinybird/custom-charts" +import { getEndpointDetailTimeSeries, getEndpointStatusCodeBreakdown } from "@/api/tinybird/endpoint-detail" +import { getHttpEndpointsOverview, getHttpEndpointsSparklines } from "@/api/tinybird/endpoints-overview" import { getErrorDetailTraces, getErrorsByType, @@ -217,3 +219,31 @@ export const getResourceAttributeKeysResultAtom = makeQueryAtomFamily(getResourc export const getResourceAttributeValuesResultAtom = makeQueryAtomFamily(getResourceAttributeValues, { staleTime: 30_000, }) + +export const getEndpointDetailTimeSeriesResultAtom = makeQueryAtomFamily( + getEndpointDetailTimeSeries, + { + staleTime: 30_000, + }, +) + +export const getEndpointStatusCodeBreakdownResultAtom = makeQueryAtomFamily( + getEndpointStatusCodeBreakdown, + { + staleTime: 30_000, + }, +) + +export const getHttpEndpointsOverviewResultAtom = makeQueryAtomFamily( + getHttpEndpointsOverview, + { + staleTime: 30_000, + }, +) + +export const getHttpEndpointsSparklinesResultAtom = makeQueryAtomFamily( + getHttpEndpointsSparklines, + { + staleTime: 30_000, + }, +) diff --git a/apps/web/src/lib/tinybird.ts b/apps/web/src/lib/tinybird.ts index dc43f0e..82504d2 100644 --- a/apps/web/src/lib/tinybird.ts +++ b/apps/web/src/lib/tinybird.ts @@ -29,6 +29,8 @@ import type { ServiceDependenciesOutput, ServiceOverviewOutput, ServicesFacetsOutput, + HttpEndpointsOverviewOutput, + HttpEndpointsTimeseriesOutput, ResourceAttributeKeysOutput, ResourceAttributeValuesOutput, SpanAttributeKeysOutput, @@ -89,6 +91,10 @@ export type { ServiceOverviewOutput, ServicesFacetsParams, ServicesFacetsOutput, + HttpEndpointsOverviewParams, + HttpEndpointsOverviewOutput, + HttpEndpointsTimeseriesParams, + HttpEndpointsTimeseriesOutput, ResourceAttributeKeysParams, ResourceAttributeKeysOutput, ResourceAttributeValuesParams, @@ -192,6 +198,10 @@ const query = { queryTinybird("resource_attribute_keys", params), resource_attribute_values: (params?: Record) => queryTinybird("resource_attribute_values", params), + http_endpoints_overview: (params?: Record) => + queryTinybird("http_endpoints_overview", params), + http_endpoints_timeseries: (params?: Record) => + queryTinybird("http_endpoints_timeseries", params), } export function createTinybird() { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6cbd286..02bc8c1 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -27,8 +27,10 @@ import { Route as ConnectorsRouteImport } from './routes/connectors' import { Route as IndexRouteImport } from './routes/index' import { Route as TracesIndexRouteImport } from './routes/traces/index' import { Route as ServicesIndexRouteImport } from './routes/services/index' +import { Route as EndpointsIndexRouteImport } from './routes/endpoints/index' import { Route as TracesTraceIdRouteImport } from './routes/traces/$traceId' import { Route as ServicesServiceNameRouteImport } from './routes/services/$serviceName' +import { Route as EndpointsDetailRouteImport } from './routes/endpoints/detail' const SignUpRoute = SignUpRouteImport.update({ id: '/sign-up', @@ -120,6 +122,11 @@ const ServicesIndexRoute = ServicesIndexRouteImport.update({ path: '/services/', getParentRoute: () => rootRouteImport, } as any) +const EndpointsIndexRoute = EndpointsIndexRouteImport.update({ + id: '/endpoints/', + path: '/endpoints/', + getParentRoute: () => rootRouteImport, +} as any) const TracesTraceIdRoute = TracesTraceIdRouteImport.update({ id: '/traces/$traceId', path: '/traces/$traceId', @@ -130,6 +137,11 @@ const ServicesServiceNameRoute = ServicesServiceNameRouteImport.update({ path: '/services/$serviceName', getParentRoute: () => rootRouteImport, } as any) +const EndpointsDetailRoute = EndpointsDetailRouteImport.update({ + id: '/endpoints/detail', + path: '/endpoints/detail', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -148,8 +160,10 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/endpoints/detail': typeof EndpointsDetailRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/endpoints/': typeof EndpointsIndexRoute '/services/': typeof ServicesIndexRoute '/traces/': typeof TracesIndexRoute } @@ -170,8 +184,10 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/endpoints/detail': typeof EndpointsDetailRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/endpoints': typeof EndpointsIndexRoute '/services': typeof ServicesIndexRoute '/traces': typeof TracesIndexRoute } @@ -193,8 +209,10 @@ export interface FileRoutesById { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/endpoints/detail': typeof EndpointsDetailRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute + '/endpoints/': typeof EndpointsIndexRoute '/services/': typeof ServicesIndexRoute '/traces/': typeof TracesIndexRoute } @@ -217,8 +235,10 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/endpoints/detail' | '/services/$serviceName' | '/traces/$traceId' + | '/endpoints/' | '/services/' | '/traces/' fileRoutesByTo: FileRoutesByTo @@ -239,8 +259,10 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/endpoints/detail' | '/services/$serviceName' | '/traces/$traceId' + | '/endpoints' | '/services' | '/traces' id: @@ -261,8 +283,10 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/endpoints/detail' | '/services/$serviceName' | '/traces/$traceId' + | '/endpoints/' | '/services/' | '/traces/' fileRoutesById: FileRoutesById @@ -284,8 +308,10 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRoute SignInRoute: typeof SignInRoute SignUpRoute: typeof SignUpRoute + EndpointsDetailRoute: typeof EndpointsDetailRoute ServicesServiceNameRoute: typeof ServicesServiceNameRoute TracesTraceIdRoute: typeof TracesTraceIdRoute + EndpointsIndexRoute: typeof EndpointsIndexRoute ServicesIndexRoute: typeof ServicesIndexRoute TracesIndexRoute: typeof TracesIndexRoute } @@ -418,6 +444,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ServicesIndexRouteImport parentRoute: typeof rootRouteImport } + '/endpoints/': { + id: '/endpoints/' + path: '/endpoints' + fullPath: '/endpoints/' + preLoaderRoute: typeof EndpointsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/traces/$traceId': { id: '/traces/$traceId' path: '/traces/$traceId' @@ -432,6 +465,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ServicesServiceNameRouteImport parentRoute: typeof rootRouteImport } + '/endpoints/detail': { + id: '/endpoints/detail' + path: '/endpoints/detail' + fullPath: '/endpoints/detail' + preLoaderRoute: typeof EndpointsDetailRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -452,8 +492,10 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRoute, SignInRoute: SignInRoute, SignUpRoute: SignUpRoute, + EndpointsDetailRoute: EndpointsDetailRoute, ServicesServiceNameRoute: ServicesServiceNameRoute, TracesTraceIdRoute: TracesTraceIdRoute, + EndpointsIndexRoute: EndpointsIndexRoute, ServicesIndexRoute: ServicesIndexRoute, TracesIndexRoute: TracesIndexRoute, } diff --git a/apps/web/src/routes/endpoints/detail.tsx b/apps/web/src/routes/endpoints/detail.tsx new file mode 100644 index 0000000..7b516cf --- /dev/null +++ b/apps/web/src/routes/endpoints/detail.tsx @@ -0,0 +1,240 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { Result, useAtomValue } from "@effect-atom/atom-react" +import { Schema } from "effect" + +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { TimeRangePicker } from "@/components/time-range-picker" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { MetricsGrid } from "@/components/dashboard/metrics-grid" +import { MethodBadge } from "@/components/endpoints/method-badge" +import { EndpointSummaryStats } from "@/components/endpoints/endpoint-summary-stats" +import { EndpointStatusBreakdown } from "@/components/endpoints/endpoint-status-breakdown" +import { EndpointRecentTraces } from "@/components/endpoints/endpoint-recent-traces" +import { ReadonlyWidgetShell } from "@/components/dashboard-builder/widgets/widget-shell" +import { + getEndpointDetailTimeSeriesResultAtom, + getEndpointStatusCodeBreakdownResultAtom, + getHttpEndpointsOverviewResultAtom, + listTracesResultAtom, +} from "@/lib/services/atoms/tinybird-query-atoms" +import type { ChartLegendMode, ChartTooltipMode } from "@maple/ui/components/charts/_shared/chart-types" + +const endpointDetailSearchSchema = Schema.Struct({ + service: Schema.String, + endpoint: Schema.String, + method: Schema.String, + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), +}) + +export const Route = createFileRoute("/endpoints/detail")({ + component: EndpointDetailPage, + validateSearch: Schema.standardSchemaV1(endpointDetailSearchSchema), +}) + +interface EndpointChartConfig { + id: string + chartId: string + title: string + layout: { x: number; y: number; w: number; h: number } + legend?: ChartLegendMode + tooltip?: ChartTooltipMode +} + +const ENDPOINT_CHARTS: EndpointChartConfig[] = [ + { id: "latency", chartId: "latency-line", title: "Latency", layout: { x: 0, y: 0, w: 6, h: 4 }, legend: "visible", tooltip: "visible" }, + { id: "throughput", chartId: "throughput-area", title: "Throughput", layout: { x: 6, y: 0, w: 6, h: 4 }, tooltip: "visible" }, + { id: "error-rate", chartId: "error-rate-area", title: "Error Rate", layout: { x: 0, y: 4, w: 6, h: 4 }, tooltip: "visible" }, +] + +function EndpointDetailPage() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = + useEffectiveTimeRange(search.startTime, search.endTime) + + const durationSeconds = Math.max( + (new Date(effectiveEndTime).getTime() - new Date(effectiveStartTime).getTime()) / 1000, + 1, + ) + + const handleTimeChange = ({ + startTime, + endTime, + presetValue, + }: { + startTime?: string + endTime?: string + presetValue?: string + }) => { + navigate({ + search: { + ...search, + startTime, + endTime, + timePreset: presetValue, + }, + }) + } + + // Time series data for charts + const timeSeriesResult = useAtomValue( + getEndpointDetailTimeSeriesResultAtom({ + data: { + serviceName: search.service, + spanName: search.endpoint, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + }, + }), + ) + + // Status code breakdown + const statusCodeResult = useAtomValue( + getEndpointStatusCodeBreakdownResultAtom({ + data: { + serviceName: search.service, + spanName: search.endpoint, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + }, + }), + ) + + // Overview stats (reuse existing endpoint, filter client-side) + const overviewResult = useAtomValue( + getHttpEndpointsOverviewResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + services: [search.service], + }, + }), + ) + + // Recent traces — filter by http.route attribute since endpointName + // comes from http.route (not rootSpanName which list_traces filters on) + const tracesResult = useAtomValue( + listTracesResultAtom({ + data: { + service: search.service, + httpMethod: search.method, + attributeKey: "http.route", + attributeValue: search.endpoint, + startTime: effectiveStartTime, + endTime: effectiveEndTime, + limit: 20, + }, + }), + ) + + // Extract matching endpoint from overview + const matchingEndpoint = Result.builder(overviewResult) + .onSuccess((response) => + response.data.find( + (ep) => + ep.serviceName === search.service && + ep.endpointName === search.endpoint && + ep.httpMethod === search.method, + ), + ) + .orElse(() => undefined) + + // Chart data + const detailPoints = Result.builder(timeSeriesResult) + .onSuccess((response) => response.data as unknown as Record[]) + .orElse(() => []) + + const metrics = ENDPOINT_CHARTS.map((chart) => ({ + id: chart.id, + chartId: chart.chartId, + title: chart.title, + layout: chart.layout, + data: detailPoints, + legend: chart.legend, + tooltip: chart.tooltip, + })) + + // Status code data + const statusCodeData = Result.builder(statusCodeResult) + .onSuccess((response) => response.data) + .orElse(() => []) + + // Traces data + const traces = Result.builder(tracesResult) + .onSuccess((response) => response.data) + .orElse(() => []) + + return ( + +
+ +

+ {search.endpoint} +

+
+

+ Service:{" "} + + {search.service} + +

+
+ } + headerActions={ + + } + > +
+ {/* Summary Stats */} + + + {/* Charts */} +
+ {/* Latency, Throughput, Error Rate charts */} +
+ +
+ + {/* Status Code Breakdown */} +
+ + + +
+
+ + {/* Recent Traces */} +
+

Recent Traces

+ +
+
+ + ) +} diff --git a/apps/web/src/routes/endpoints/index.tsx b/apps/web/src/routes/endpoints/index.tsx new file mode 100644 index 0000000..db24b96 --- /dev/null +++ b/apps/web/src/routes/endpoints/index.tsx @@ -0,0 +1,100 @@ +import { useMemo } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { Result, useAtomValue } from "@effect-atom/atom-react" +import { Schema } from "effect" + +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { EndpointsTable } from "@/components/endpoints/endpoints-table" +import { EndpointsFilterSidebar, type EndpointsFacets } from "@/components/endpoints/endpoints-filter-sidebar" +import type { FilterOption } from "@/components/filters/filter-section" +import { TimeRangePicker } from "@/components/time-range-picker" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { getHttpEndpointsOverviewResultAtom } from "@/lib/services/atoms/tinybird-query-atoms" + +const endpointsSearchSchema = Schema.Struct({ + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) + +export const Route = createFileRoute("/endpoints/")({ + component: EndpointsPage, + validateSearch: Schema.standardSchemaV1(endpointsSearchSchema), +}) + +function EndpointsPage() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = + useEffectiveTimeRange(search.startTime, search.endTime) + + const handleTimeChange = ({ + startTime, + endTime, + }: { + startTime?: string + endTime?: string + }) => { + navigate({ + search: (prev: Record) => ({ ...prev, startTime, endTime }), + }) + } + + // Unfiltered overview for deriving facet counts + const unfilteredResult = useAtomValue( + getHttpEndpointsOverviewResultAtom({ + data: { + startTime: effectiveStartTime, + endTime: effectiveEndTime, + }, + }), + ) + + const facets = useMemo((): EndpointsFacets | undefined => { + if (!Result.isSuccess(unfilteredResult)) return undefined + const data = unfilteredResult.value.data + + const serviceMap = new Map() + const methodMap = new Map() + for (const row of data) { + serviceMap.set(row.serviceName, (serviceMap.get(row.serviceName) ?? 0) + row.count) + if (row.httpMethod && row.httpMethod !== "UNKNOWN") { + methodMap.set(row.httpMethod, (methodMap.get(row.httpMethod) ?? 0) + row.count) + } + } + + const toSorted = (map: Map): FilterOption[] => + Array.from(map.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + + return { + services: toSorted(serviceMap), + httpMethods: toSorted(methodMap), + } + }, [unfilteredResult]) + + const isLoading = Result.isInitial(unfilteredResult) + + return ( + + } + filterSidebar={ + + } + > + + + ) +} diff --git a/packages/domain/src/tinybird-pipes.ts b/packages/domain/src/tinybird-pipes.ts index 732913f..713d217 100644 --- a/packages/domain/src/tinybird-pipes.ts +++ b/packages/domain/src/tinybird-pipes.ts @@ -31,6 +31,8 @@ export const tinybirdPipes = [ "span_attribute_values", "resource_attribute_keys", "resource_attribute_values", + "http_endpoints_overview", + "http_endpoints_timeseries", ] as const export type TinybirdPipe = (typeof tinybirdPipes)[number] diff --git a/packages/domain/src/tinybird/endpoints.ts b/packages/domain/src/tinybird/endpoints.ts index 93e64eb..e770ace 100644 --- a/packages/domain/src/tinybird/endpoints.ts +++ b/packages/domain/src/tinybird/endpoints.ts @@ -2770,3 +2770,138 @@ export const resourceAttributeValues = defineEndpoint("resource_attribute_values export type ResourceAttributeValuesParams = InferParams; export type ResourceAttributeValuesOutput = InferOutputRow; + +/** + * HTTP endpoints overview - aggregated metrics for HTTP server endpoints discovered from traces + */ +export const httpEndpointsOverview = defineEndpoint("http_endpoints_overview", { + description: "Get aggregated metrics for HTTP endpoints grouped by service, method, and route.", + params: { + org_id: p.string().optional().describe("Organization ID"), + start_time: p.dateTime().describe("Start of time range"), + end_time: p.dateTime().describe("End of time range"), + services: p.string().optional().describe("Comma-separated service names filter"), + http_methods: p.string().optional().describe("Comma-separated HTTP methods filter"), + environments: p.string().optional().describe("Comma-separated environments filter"), + limit: p.int32().optional(100).describe("Maximum number of results"), + }, + nodes: [ + node({ + name: "http_endpoints_overview_node", + sql: ` + SELECT + ServiceName AS serviceName, + if( + SpanAttributes['http.route'] != '', + SpanAttributes['http.route'], + SpanName + ) AS endpointName, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) AS httpMethod, + count() AS count, + avg(Duration) / 1000000 AS avgDuration, + quantile(0.5)(Duration) / 1000000 AS p50Duration, + quantile(0.95)(Duration) / 1000000 AS p95Duration, + quantile(0.99)(Duration) / 1000000 AS p99Duration, + if(count() > 0, countIf(StatusCode = 'Error') * 100.0 / count(), 0) AS errorRate + FROM traces + WHERE Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND OrgId = {{String(org_id, "")}} + AND SpanKind = 'Server' + {% if defined(services) %} + AND ServiceName IN splitByChar(',', {{String(services, "")}}) + {% end %} + {% if defined(http_methods) %} + AND if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) + IN splitByChar(',', {{String(http_methods, "")}}) + {% end %} + {% if defined(environments) %} + AND ResourceAttributes['deployment.environment'] IN splitByChar(',', {{String(environments, "")}}) + {% end %} + GROUP BY serviceName, endpointName, httpMethod + ORDER BY count DESC + LIMIT {{Int32(limit, 100)}} + `, + }), + ], + output: { + serviceName: t.string(), + endpointName: t.string(), + httpMethod: t.string(), + count: t.uint64(), + avgDuration: t.float64(), + p50Duration: t.float64(), + p95Duration: t.float64(), + p99Duration: t.float64(), + errorRate: t.float64(), + }, +}); + +export type HttpEndpointsOverviewParams = InferParams; +export type HttpEndpointsOverviewOutput = InferOutputRow; + +/** + * HTTP endpoints time series - time-bucketed throughput and error rate per endpoint + */ +export const httpEndpointsTimeseries = defineEndpoint("http_endpoints_timeseries", { + description: "Time-bucketed throughput and error rate for HTTP server endpoints, grouped by endpoint name.", + params: { + org_id: p.string().optional().describe("Organization ID"), + start_time: p.dateTime().describe("Start of time range"), + end_time: p.dateTime().describe("End of time range"), + bucket_seconds: p.int32().optional(60).describe("Bucket size in seconds"), + services: p.string().optional().describe("Comma-separated service names filter"), + http_methods: p.string().optional().describe("Comma-separated HTTP methods filter"), + environments: p.string().optional().describe("Comma-separated environments filter"), + }, + nodes: [ + node({ + name: "http_endpoints_ts_node", + sql: ` + SELECT + toStartOfInterval(Timestamp, INTERVAL {{Int32(bucket_seconds, 60)}} SECOND) AS bucket, + concat( + ServiceName, '::', + if( + SpanAttributes['http.route'] != '', + SpanAttributes['http.route'], + SpanName + ), '::', + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) + ) AS endpointKey, + count() AS count, + if(count() > 0, countIf(StatusCode = 'Error') * 100.0 / count(), 0) AS errorRate + FROM traces + WHERE Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND OrgId = {{String(org_id, "")}} + AND SpanKind = 'Server' + {% if defined(services) %} + AND ServiceName IN splitByChar(',', {{String(services, "")}}) + {% end %} + {% if defined(http_methods) %} + AND if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], + if(SpanAttributes['http.request.method'] != '', SpanAttributes['http.request.method'], '')) + IN splitByChar(',', {{String(http_methods, "")}}) + {% end %} + {% if defined(environments) %} + AND ResourceAttributes['deployment.environment'] IN splitByChar(',', {{String(environments, "")}}) + {% end %} + GROUP BY bucket, endpointKey + ORDER BY bucket ASC, endpointKey ASC + `, + }), + ], + output: { + bucket: t.dateTime(), + endpointKey: t.string(), + count: t.uint64(), + errorRate: t.float64(), + }, +}); + +export type HttpEndpointsTimeseriesParams = InferParams; +export type HttpEndpointsTimeseriesOutput = InferOutputRow;