From e9fc74737eec758cdfd275e3af0db1b656efe3fa Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 19 Feb 2026 12:02:09 +0100 Subject: [PATCH 1/4] inti qyer lab --- apps/api/src/services/TinybirdService.ts | 4 + apps/api/src/tinybird/endpoints.ts | 77 +++++++ apps/web/src/api/tinybird/traces.ts | 94 ++++++++ .../query-builder/query-builder-lab.tsx | 216 +++++++++++++++++- .../query-builder/where-clause-editor.tsx | 12 + .../where-clause-autocomplete.ts | 59 ++++- .../services/atoms/tinybird-query-atoms.ts | 10 +- apps/web/src/lib/tinybird.ts | 10 + apps/web/src/tinybird/endpoints.ts | 77 +++++++ packages/domain/src/tinybird-pipes.ts | 2 + 10 files changed, 554 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/TinybirdService.ts b/apps/api/src/services/TinybirdService.ts index 1c50bea..610360e 100644 --- a/apps/api/src/services/TinybirdService.ts +++ b/apps/api/src/services/TinybirdService.ts @@ -47,6 +47,8 @@ import { serviceDependencies, serviceOverview, servicesFacets, + spanAttributeKeys, + spanAttributeValues, spanHierarchy, tracesDurationStats, tracesFacets, @@ -91,6 +93,8 @@ export class TinybirdService extends Effect.Service()("Tinybird custom_logs_breakdown: customLogsBreakdown, custom_metrics_breakdown: customMetricsBreakdown, service_dependencies: serviceDependencies, + span_attribute_keys: spanAttributeKeys, + span_attribute_values: spanAttributeValues, }, }) diff --git a/apps/api/src/tinybird/endpoints.ts b/apps/api/src/tinybird/endpoints.ts index c28db2e..7e2e1e7 100644 --- a/apps/api/src/tinybird/endpoints.ts +++ b/apps/api/src/tinybird/endpoints.ts @@ -2488,3 +2488,80 @@ export const serviceDependencies = defineEndpoint("service_dependencies", { export type ServiceDependenciesParams = InferParams; export type ServiceDependenciesOutput = InferOutputRow; + +/** + * Span attribute keys endpoint - returns distinct span attribute key names + */ +export const spanAttributeKeys = defineEndpoint("span_attribute_keys", { + description: "List distinct span attribute keys with usage counts.", + 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"), + limit: p.int32().optional(200).describe("Maximum number of keys to return"), + }, + nodes: [ + node({ + name: "span_attribute_keys_node", + sql: ` + SELECT + arrayJoin(mapKeys(SpanAttributes)) AS attributeKey, + count() AS usageCount + FROM traces + WHERE OrgId = {{String(org_id, "")}} + AND Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND SpanAttributes != map() + GROUP BY attributeKey + ORDER BY usageCount DESC + LIMIT {{Int32(limit, 200)}} + `, + }), + ], + output: { + attributeKey: t.string(), + usageCount: t.uint64(), + }, +}); + +export type SpanAttributeKeysParams = InferParams; +export type SpanAttributeKeysOutput = InferOutputRow; + +/** + * Span attribute values endpoint - returns distinct values for a specific attribute key + */ +export const spanAttributeValues = defineEndpoint("span_attribute_values", { + description: "List distinct values for a specific span attribute key.", + 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"), + attribute_key: p.string().describe("The attribute key to get values for"), + limit: p.int32().optional(50).describe("Maximum number of values to return"), + }, + nodes: [ + node({ + name: "span_attribute_values_node", + sql: ` + SELECT + SpanAttributes[{{String(attribute_key)}}] AS attributeValue, + count() AS usageCount + FROM traces + WHERE OrgId = {{String(org_id, "")}} + AND Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND SpanAttributes[{{String(attribute_key)}}] != '' + GROUP BY attributeValue + ORDER BY usageCount DESC + LIMIT {{Int32(limit, 50)}} + `, + }), + ], + output: { + attributeValue: t.string(), + usageCount: t.uint64(), + }, +}); + +export type SpanAttributeValuesParams = InferParams; +export type SpanAttributeValuesOutput = InferOutputRow; diff --git a/apps/web/src/api/tinybird/traces.ts b/apps/web/src/api/tinybird/traces.ts index 4433cdb..194e093 100644 --- a/apps/web/src/api/tinybird/traces.ts +++ b/apps/web/src/api/tinybird/traces.ts @@ -470,3 +470,97 @@ export async function getTracesDurationStats({ }; } } + +// --- Span Attribute Keys --- + +const GetSpanAttributeKeysInput = z.object({ + startTime: dateTimeString.optional(), + endTime: dateTimeString.optional(), +}); + +export type GetSpanAttributeKeysInput = z.infer; + +export interface SpanAttributeKeysResponse { + data: Array<{ attributeKey: string; usageCount: number }>; + error: string | null; +} + +export async function getSpanAttributeKeys({ + data, +}: { + data: GetSpanAttributeKeysInput; +}): Promise { + data = GetSpanAttributeKeysInput.parse(data ?? {}); + + try { + const tinybird = getTinybird(); + const result = await tinybird.query.span_attribute_keys({ + start_time: data.startTime, + end_time: data.endTime, + }); + + return { + data: result.data.map((row) => ({ + attributeKey: row.attributeKey, + usageCount: Number(row.usageCount), + })), + error: null, + }; + } catch (error) { + console.error("[Tinybird] getSpanAttributeKeys failed:", error); + return { + data: [], + error: error instanceof Error ? error.message : "Failed to fetch span attribute keys", + }; + } +} + +// --- Span Attribute Values --- + +const GetSpanAttributeValuesInput = z.object({ + startTime: dateTimeString.optional(), + endTime: dateTimeString.optional(), + attributeKey: z.string(), +}); + +export type GetSpanAttributeValuesInput = z.infer; + +export interface SpanAttributeValuesResponse { + data: Array<{ attributeValue: string; usageCount: number }>; + error: string | null; +} + +export async function getSpanAttributeValues({ + data, +}: { + data: GetSpanAttributeValuesInput; +}): Promise { + data = GetSpanAttributeValuesInput.parse(data ?? {}); + + if (!data.attributeKey) { + return { data: [], error: null }; + } + + try { + const tinybird = getTinybird(); + const result = await tinybird.query.span_attribute_values({ + start_time: data.startTime, + end_time: data.endTime, + attribute_key: data.attributeKey, + }); + + return { + data: result.data.map((row) => ({ + attributeValue: row.attributeValue, + usageCount: Number(row.usageCount), + })), + error: null, + }; + } catch (error) { + console.error("[Tinybird] getSpanAttributeValues failed:", error); + return { + data: [], + error: error instanceof Error ? error.message : "Failed to fetch span attribute values", + }; + } +} diff --git a/apps/web/src/components/query-builder/query-builder-lab.tsx b/apps/web/src/components/query-builder/query-builder-lab.tsx index 7486f0a..bf9ffc0 100644 --- a/apps/web/src/components/query-builder/query-builder-lab.tsx +++ b/apps/web/src/components/query-builder/query-builder-lab.tsx @@ -32,10 +32,13 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { cn } from "@/lib/utils" import { WhereClauseEditor } from "@/components/query-builder/where-clause-editor" import { getLogsFacetsResultAtom, getQueryBuilderTimeseriesResultAtom, + getSpanAttributeKeysResultAtom, + getSpanAttributeValuesResultAtom, getTracesFacetsResultAtom, listMetricsResultAtom, } from "@/lib/services/atoms/tinybird-query-atoms" @@ -166,6 +169,161 @@ function debugWarnings(debug: unknown): string[] { return warnings } +const GROUP_BY_OPTIONS: Record> = { + traces: [ + { label: "service.name", value: "service.name" }, + { label: "span.name", value: "span.name" }, + { label: "status.code", value: "status.code" }, + { label: "http.method", value: "http.method" }, + { label: "none", value: "none" }, + ], + logs: [ + { label: "service.name", value: "service.name" }, + { label: "severity", value: "severity" }, + { label: "none", value: "none" }, + ], + metrics: [ + { label: "service.name", value: "service.name" }, + { label: "none", value: "none" }, + ], +} + +function GroupByAutocomplete({ + value, + onChange, + dataSource, + attributeKeys, + placeholder, +}: { + value: string + onChange: (value: string) => void + dataSource: DataSource + attributeKeys?: string[] + placeholder?: string +}) { + const inputRef = React.useRef(null) + const [isFocused, setIsFocused] = React.useState(false) + const [isDismissed, setIsDismissed] = React.useState(false) + const [activeIndex, setActiveIndex] = React.useState(0) + + const suggestions = React.useMemo(() => { + const query = value.toLowerCase() + const staticOptions = GROUP_BY_OPTIONS[dataSource].map((opt) => ({ + label: opt.label, + value: opt.value, + })) + + const attrOptions = + dataSource === "traces" && attributeKeys + ? attributeKeys + .filter((key) => !key.startsWith("http.request.header.") && !key.startsWith("http.response.header.")) + .map((key) => ({ + label: `attr.${key}`, + value: `attr.${key}`, + })) + : [] + + const allOptions = [...staticOptions, ...attrOptions] + + if (!query) return allOptions.slice(0, 12) + + return allOptions + .filter( + (opt) => + opt.label.toLowerCase().includes(query) || + opt.value.toLowerCase().includes(query), + ) + .slice(0, 12) + }, [value, dataSource, attributeKeys]) + + const isOpen = isFocused && !isDismissed && suggestions.length > 0 + + React.useEffect(() => { + setActiveIndex(0) + }, [suggestions.length, value]) + + const applySuggestion = React.useCallback( + (index: number) => { + const suggestion = suggestions[index] + if (!suggestion) return + onChange(suggestion.value) + setIsDismissed(true) + }, + [suggestions, onChange], + ) + + return ( +
+ { + setIsFocused(true) + setIsDismissed(false) + }} + onBlur={() => setIsFocused(false)} + onChange={(event) => { + onChange(event.target.value) + setIsDismissed(false) + }} + onKeyDown={(event) => { + if (!isOpen || suggestions.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + setActiveIndex((c) => (c + 1) % suggestions.length) + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + setActiveIndex((c) => (c - 1 + suggestions.length) % suggestions.length) + return + } + + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault() + applySuggestion(activeIndex) + return + } + + if (event.key === "Escape") { + event.preventDefault() + setIsDismissed(true) + } + }} + /> + {isOpen && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ ) +} + function QueryBuilderAtomResults({ input, }: { @@ -302,6 +460,45 @@ export function QueryBuilderLab({ }), ) + const spanAttributeKeysResult = useAtomValue( + getSpanAttributeKeysResultAtom({ + data: { + startTime, + endTime, + }, + }), + ) + + const [activeAttributeKey, setActiveAttributeKey] = React.useState(null) + + const spanAttributeValuesResult = useAtomValue( + getSpanAttributeValuesResultAtom({ + data: { + startTime, + endTime, + attributeKey: activeAttributeKey ?? "", + }, + }), + ) + + const attributeKeys = React.useMemo( + () => + Result.builder(spanAttributeKeysResult) + .onSuccess((response) => response.data.map((row) => row.attributeKey)) + .orElse(() => []), + [spanAttributeKeysResult], + ) + + const attributeValues = React.useMemo( + () => + activeAttributeKey + ? Result.builder(spanAttributeValuesResult) + .onSuccess((response) => response.data.map((row) => row.attributeValue)) + .orElse(() => []) + : [], + [activeAttributeKey, spanAttributeValuesResult], + ) + const metricRows = React.useMemo( () => Result.builder(metricsResult) @@ -378,6 +575,8 @@ export function QueryBuilderLab({ services: toNames(tracesFacets.services), spanNames: toNames(tracesFacets.spanNames), environments: toNames(tracesFacets.deploymentEnvs), + attributeKeys, + attributeValues, }, logs: { services: toNames(logsFacets.services), @@ -388,7 +587,7 @@ export function QueryBuilderLab({ metricTypes: [...QUERY_BUILDER_METRIC_TYPES], }, } - }, [logsFacetsResult, metricRows, tracesFacetsResult]) + }, [attributeKeys, attributeValues, logsFacetsResult, metricRows, tracesFacetsResult]) React.useEffect(() => { const firstMetric = metricOptions[0] @@ -744,6 +943,11 @@ export function QueryBuilderLab({ value={query.whereClause} dataSource={query.dataSource} values={autocompleteValuesBySource[query.dataSource]} + onActiveAttributeKey={ + query.dataSource === "traces" + ? setActiveAttributeKey + : undefined + } onChange={(nextWhereClause) => updateQuery(query.id, (current) => ({ ...current, @@ -835,15 +1039,17 @@ export function QueryBuilderLab({ - + onChange={(nextGroupBy) => updateQuery(query.id, (current) => ({ ...current, - groupBy: event.target.value, + groupBy: nextGroupBy, })) } - placeholder="service.name | span.name | severity | none | attr.http.route" + dataSource={query.dataSource} + attributeKeys={attributeKeys} + placeholder="service.name | span.name | none | attr.http.route" /> )} diff --git a/apps/web/src/components/query-builder/where-clause-editor.tsx b/apps/web/src/components/query-builder/where-clause-editor.tsx index 21575d7..52271b7 100644 --- a/apps/web/src/components/query-builder/where-clause-editor.tsx +++ b/apps/web/src/components/query-builder/where-clause-editor.tsx @@ -14,6 +14,7 @@ interface WhereClauseEditorProps { value: string onChange: (value: string) => void values?: WhereClauseAutocompleteValues + onActiveAttributeKey?: (key: string | null) => void placeholder?: string rows?: number className?: string @@ -26,6 +27,7 @@ export function WhereClauseEditor({ value, onChange, values, + onActiveAttributeKey, placeholder, rows = 2, className, @@ -53,6 +55,16 @@ export function WhereClauseEditor({ [cursor, dataSource, value, values], ) + // Notify parent when user is editing a value for an attr.* key + React.useEffect(() => { + if (!onActiveAttributeKey) return + if (autocomplete.context === "value" && autocomplete.key?.startsWith("attr.")) { + onActiveAttributeKey(autocomplete.key.slice(5)) + } else { + onActiveAttributeKey(null) + } + }, [autocomplete.context, autocomplete.key, onActiveAttributeKey]) + const suggestions = autocomplete.suggestions const isOpen = isFocused && !isDismissed && suggestions.length > 0 diff --git a/apps/web/src/lib/query-builder/where-clause-autocomplete.ts b/apps/web/src/lib/query-builder/where-clause-autocomplete.ts index 6cb7e4f..2a798ad 100644 --- a/apps/web/src/lib/query-builder/where-clause-autocomplete.ts +++ b/apps/web/src/lib/query-builder/where-clause-autocomplete.ts @@ -17,6 +17,8 @@ export interface WhereClauseAutocompleteValues { commitShas?: string[] severities?: string[] metricTypes?: QueryBuilderMetricType[] + attributeKeys?: string[] + attributeValues?: string[] } export interface WhereClauseAutocompleteSuggestion { @@ -570,9 +572,49 @@ function buildValueSuggestions( ) } + if (normalizedKey === "attr.*" && values?.attributeValues) { + return uniqueValues(values.attributeValues).map((value) => + toStringValueSuggestion(value, "attr"), + ) + } + return [] } +const NOISY_ATTR_PREFIXES = [ + "http.request.header.", + "http.response.header.", +] + +function isNoisyAttributeKey(key: string): boolean { + return NOISY_ATTR_PREFIXES.some((prefix) => key.startsWith(prefix)) +} + +function buildAttributeKeySuggestions( + attributeKeys: string[], +): WhereClauseAutocompleteSuggestion[] { + const clean: WhereClauseAutocompleteSuggestion[] = [] + const noisy: WhereClauseAutocompleteSuggestion[] = [] + + for (const key of attributeKeys) { + const suggestion: WhereClauseAutocompleteSuggestion = { + id: `key:attr.${key}`, + kind: "key", + label: `attr.${key}`, + insertText: `attr.${key}`, + description: "Span attribute", + } + + if (isNoisyAttributeKey(key)) { + noisy.push(suggestion) + } else { + clean.push(suggestion) + } + } + + return [...clean, ...noisy] +} + function buildSuggestions( parsed: ParsedWhereClauseContext, dataSource: QueryBuilderDataSource, @@ -580,6 +622,19 @@ function buildSuggestions( maxSuggestions: number, ): WhereClauseAutocompleteSuggestion[] { if (parsed.context === "key") { + const query = parsed.query.toLowerCase() + + // When typing attr., show dynamic attribute keys instead of static definitions + if ( + dataSource === "traces" && + query.startsWith("attr.") && + values?.attributeKeys && + values.attributeKeys.length > 0 + ) { + const attrSuggestions = buildAttributeKeySuggestions(values.attributeKeys) + return filterAndRankSuggestions(attrSuggestions, query, maxSuggestions) + } + const keySuggestions = KEY_DEFINITIONS[dataSource].map((keyDef) => toSuggestion( { @@ -691,7 +746,9 @@ export function applyWhereClauseSuggestion({ } const shouldAddLeadingSpace = before.length > 0 && !/\s$/.test(before) - const shouldAddTrailingSpace = after.length === 0 || !/^\s/.test(after) + const isPrefix = suggestion.insertText.endsWith(".") + const shouldAddTrailingSpace = + !isPrefix && (after.length === 0 || !/^\s/.test(after)) const insertion = `${shouldAddLeadingSpace ? " " : ""}${ suggestion.insertText }${shouldAddTrailingSpace ? " " : ""}` 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 44b7543..0a51909 100644 --- a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts @@ -21,7 +21,7 @@ import { getServiceOverview, getServicesFacets, } from "@/api/tinybird/services" -import { getSpanHierarchy, getTracesFacets, listTraces } from "@/api/tinybird/traces" +import { getSpanAttributeKeys, getSpanAttributeValues, getSpanHierarchy, getTracesFacets, listTraces } from "@/api/tinybird/traces" import { getQueryBuilderTimeseries } from "@/api/tinybird/query-builder-timeseries" type AsyncQuery = (input: Input) => Promise @@ -214,3 +214,11 @@ export const getQueryBuilderTimeseriesResultAtom = makeQueryAtomFamily( export const getServiceMapResultAtom = makeQueryAtomFamily(getServiceMap, { staleTime: 15_000, }) + +export const getSpanAttributeKeysResultAtom = makeQueryAtomFamily(getSpanAttributeKeys, { + staleTime: 60_000, +}) + +export const getSpanAttributeValuesResultAtom = makeQueryAtomFamily(getSpanAttributeValues, { + staleTime: 30_000, +}) diff --git a/apps/web/src/lib/tinybird.ts b/apps/web/src/lib/tinybird.ts index 9c1546b..2e778d2 100644 --- a/apps/web/src/lib/tinybird.ts +++ b/apps/web/src/lib/tinybird.ts @@ -30,6 +30,8 @@ import type { ServiceDependenciesOutput, ServiceOverviewOutput, ServicesFacetsOutput, + SpanAttributeKeysOutput, + SpanAttributeValuesOutput, SpanHierarchyOutput, TracesDurationStatsOutput, TracesFacetsOutput, @@ -86,6 +88,10 @@ export type { ServiceOverviewOutput, ServicesFacetsParams, ServicesFacetsOutput, + SpanAttributeKeysParams, + SpanAttributeKeysOutput, + SpanAttributeValuesParams, + SpanAttributeValuesOutput, SpanHierarchyParams, SpanHierarchyOutput, TracesDurationStatsParams, @@ -170,6 +176,10 @@ const query = { queryTinybird("custom_metrics_breakdown", params), service_dependencies: (params?: Record) => queryTinybird("service_dependencies", params), + span_attribute_keys: (params?: Record) => + queryTinybird("span_attribute_keys", params), + span_attribute_values: (params?: Record) => + queryTinybird("span_attribute_values", params), } export function createTinybird() { diff --git a/apps/web/src/tinybird/endpoints.ts b/apps/web/src/tinybird/endpoints.ts index e92b1a7..7eba88e 100644 --- a/apps/web/src/tinybird/endpoints.ts +++ b/apps/web/src/tinybird/endpoints.ts @@ -2490,3 +2490,80 @@ export const serviceDependencies = defineEndpoint("service_dependencies", { export type ServiceDependenciesParams = InferParams; export type ServiceDependenciesOutput = InferOutputRow; + +/** + * Span attribute keys endpoint - returns distinct span attribute key names + */ +export const spanAttributeKeys = defineEndpoint("span_attribute_keys", { + description: "List distinct span attribute keys with usage counts.", + 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"), + limit: p.int32().optional(200).describe("Maximum number of keys to return"), + }, + nodes: [ + node({ + name: "span_attribute_keys_node", + sql: ` + SELECT + arrayJoin(mapKeys(SpanAttributes)) AS attributeKey, + count() AS usageCount + FROM traces + WHERE OrgId = {{String(org_id, "")}} + AND Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND SpanAttributes != map() + GROUP BY attributeKey + ORDER BY usageCount DESC + LIMIT {{Int32(limit, 200)}} + `, + }), + ], + output: { + attributeKey: t.string(), + usageCount: t.uint64(), + }, +}); + +export type SpanAttributeKeysParams = InferParams; +export type SpanAttributeKeysOutput = InferOutputRow; + +/** + * Span attribute values endpoint - returns distinct values for a specific attribute key + */ +export const spanAttributeValues = defineEndpoint("span_attribute_values", { + description: "List distinct values for a specific span attribute key.", + 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"), + attribute_key: p.string().describe("The attribute key to get values for"), + limit: p.int32().optional(50).describe("Maximum number of values to return"), + }, + nodes: [ + node({ + name: "span_attribute_values_node", + sql: ` + SELECT + SpanAttributes[{{String(attribute_key)}}] AS attributeValue, + count() AS usageCount + FROM traces + WHERE OrgId = {{String(org_id, "")}} + AND Timestamp >= {{DateTime(start_time)}} + AND Timestamp <= {{DateTime(end_time)}} + AND SpanAttributes[{{String(attribute_key)}}] != '' + GROUP BY attributeValue + ORDER BY usageCount DESC + LIMIT {{Int32(limit, 50)}} + `, + }), + ], + output: { + attributeValue: t.string(), + usageCount: t.uint64(), + }, +}); + +export type SpanAttributeValuesParams = InferParams; +export type SpanAttributeValuesOutput = InferOutputRow; diff --git a/packages/domain/src/tinybird-pipes.ts b/packages/domain/src/tinybird-pipes.ts index bb7d218..3147824 100644 --- a/packages/domain/src/tinybird-pipes.ts +++ b/packages/domain/src/tinybird-pipes.ts @@ -27,6 +27,8 @@ export const tinybirdPipes = [ "custom_logs_breakdown", "custom_metrics_breakdown", "service_dependencies", + "span_attribute_keys", + "span_attribute_values", ] as const export type TinybirdPipe = (typeof tinybirdPipes)[number] From e907848cdeb9c8bd7cc5c0dbff63e5dc10adb0db Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 19 Feb 2026 12:28:56 +0100 Subject: [PATCH 2/4] feat: use effesct schmea instead of zod --- .../query-builder/query-builder-lab.tsx | 3 +- apps/web/src/routes/dashboards.tsx | 8 ++--- apps/web/src/routes/errors.tsx | 22 +++++++------- apps/web/src/routes/index.tsx | 14 ++++----- apps/web/src/routes/logs.tsx | 20 ++++++------- apps/web/src/routes/query-builder-lab.tsx | 12 ++++---- apps/web/src/routes/services/$serviceName.tsx | 12 ++++---- apps/web/src/routes/services/index.tsx | 16 +++++----- apps/web/src/routes/traces/index.tsx | 30 +++++++++---------- 9 files changed, 69 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/query-builder/query-builder-lab.tsx b/apps/web/src/components/query-builder/query-builder-lab.tsx index bf9ffc0..46af3f2 100644 --- a/apps/web/src/components/query-builder/query-builder-lab.tsx +++ b/apps/web/src/components/query-builder/query-builder-lab.tsx @@ -409,7 +409,8 @@ function QueryBuilderAtomResults({ )} ) - })} + }) + .render()} ) } diff --git a/apps/web/src/routes/dashboards.tsx b/apps/web/src/routes/dashboards.tsx index bfb79ad..8ffedb7 100644 --- a/apps/web/src/routes/dashboards.tsx +++ b/apps/web/src/routes/dashboards.tsx @@ -1,6 +1,6 @@ import { useState } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { DashboardList } from "@/components/dashboard-builder/list/dashboard-list" @@ -21,13 +21,13 @@ import type { } from "@/components/dashboard-builder/types" import { useDashboardStore } from "@/hooks/use-dashboard-store" -const dashboardsSearchSchema = z.object({ - dashboardId: z.string().optional(), +const dashboardsSearchSchema = Schema.Struct({ + dashboardId: Schema.optional(Schema.String), }) export const Route = createFileRoute("/dashboards")({ component: DashboardsPage, - validateSearch: (search) => dashboardsSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(dashboardsSearchSchema), }) function DashboardsPage() { diff --git a/apps/web/src/routes/errors.tsx b/apps/web/src/routes/errors.tsx index 7855952..1020876 100644 --- a/apps/web/src/routes/errors.tsx +++ b/apps/web/src/routes/errors.tsx @@ -1,5 +1,5 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { ErrorsSummaryCards } from "@/components/errors/errors-summary-cards" @@ -8,21 +8,21 @@ import { ErrorsFilterSidebar } from "@/components/errors/errors-filter-sidebar" import { TimeRangePicker } from "@/components/time-range-picker" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" -const errorsSearchSchema = z.object({ - services: z.array(z.string()).optional(), - deploymentEnvs: z.array(z.string()).optional(), - errorTypes: z.array(z.string()).optional(), - startTime: z.string().optional(), - endTime: z.string().optional(), - timePreset: z.string().optional(), - showSpam: z.boolean().optional(), +const errorsSearchSchema = Schema.Struct({ + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + deploymentEnvs: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + errorTypes: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), + showSpam: Schema.optional(Schema.Boolean), }) -export type ErrorsSearchParams = z.infer +export type ErrorsSearchParams = Schema.Schema.Type export const Route = createFileRoute("/errors")({ component: ErrorsPage, - validateSearch: (search) => errorsSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(errorsSearchSchema), }) function ErrorsPage() { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4315e63..dd05bed 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Result, useAtomValue } from "@effect-atom/atom-react" import { useRef, useEffect } from "react" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { TimeRangePicker } from "@/components/time-range-picker" @@ -25,16 +25,16 @@ import { getServicesFacetsResultAtom, } from "@/lib/services/atoms/tinybird-query-atoms" -const dashboardSearchSchema = z.object({ - startTime: z.string().optional(), - endTime: z.string().optional(), - timePreset: z.string().optional(), - environment: z.string().optional(), +const dashboardSearchSchema = Schema.Struct({ + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), + environment: Schema.optional(Schema.String), }) export const Route = createFileRoute("/")({ component: DashboardPage, - validateSearch: (search) => dashboardSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(dashboardSearchSchema), }) interface OverviewChartConfig { diff --git a/apps/web/src/routes/logs.tsx b/apps/web/src/routes/logs.tsx index 146bf6c..7b202f6 100644 --- a/apps/web/src/routes/logs.tsx +++ b/apps/web/src/routes/logs.tsx @@ -1,25 +1,25 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { LogsTable } from "@/components/logs/logs-table" import { LogsFilterSidebar } from "@/components/logs/logs-filter-sidebar" import { TimeRangePicker } from "@/components/time-range-picker" -const logsSearchSchema = z.object({ - services: z.array(z.string()).optional(), - severities: z.array(z.string()).optional(), - search: z.string().optional(), - startTime: z.string().optional(), - endTime: z.string().optional(), - timePreset: z.string().optional(), +const logsSearchSchema = Schema.Struct({ + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + severities: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + search: Schema.optional(Schema.String), + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), }) -export type LogsSearchParams = z.infer +export type LogsSearchParams = Schema.Schema.Type export const Route = createFileRoute("/logs")({ component: LogsPage, - validateSearch: (search) => logsSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(logsSearchSchema), }) function LogsPage() { diff --git a/apps/web/src/routes/query-builder-lab.tsx b/apps/web/src/routes/query-builder-lab.tsx index ab8301a..f753788 100644 --- a/apps/web/src/routes/query-builder-lab.tsx +++ b/apps/web/src/routes/query-builder-lab.tsx @@ -1,20 +1,20 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { z } from "zod" +import { Schema } from "effect" import { QueryBuilderLab } from "@/components/query-builder/query-builder-lab" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { TimeRangePicker } from "@/components/time-range-picker" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" -const queryBuilderLabSearchSchema = z.object({ - startTime: z.string().optional(), - endTime: z.string().optional(), - timePreset: z.string().optional(), +const queryBuilderLabSearchSchema = Schema.Struct({ + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), }) export const Route = createFileRoute("/query-builder-lab")({ component: QueryBuilderLabPage, - validateSearch: (search) => queryBuilderLabSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(queryBuilderLabSearchSchema), }) function QueryBuilderLabPage() { diff --git a/apps/web/src/routes/services/$serviceName.tsx b/apps/web/src/routes/services/$serviceName.tsx index bf56710..ca9233c 100644 --- a/apps/web/src/routes/services/$serviceName.tsx +++ b/apps/web/src/routes/services/$serviceName.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Result, useAtomValue } from "@effect-atom/atom-react" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { TimeRangePicker } from "@/components/time-range-picker" @@ -15,15 +15,15 @@ import { getServiceApdexTimeSeriesResultAtom, } from "@/lib/services/atoms/tinybird-query-atoms" -const serviceDetailSearchSchema = z.object({ - startTime: z.string().optional(), - endTime: z.string().optional(), - timePreset: z.string().optional(), +const serviceDetailSearchSchema = Schema.Struct({ + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + timePreset: Schema.optional(Schema.String), }) export const Route = createFileRoute("/services/$serviceName")({ component: ServiceDetailPage, - validateSearch: (search) => serviceDetailSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(serviceDetailSearchSchema), }) interface ServiceChartConfig { diff --git a/apps/web/src/routes/services/index.tsx b/apps/web/src/routes/services/index.tsx index 89ea4e5..e00beb4 100644 --- a/apps/web/src/routes/services/index.tsx +++ b/apps/web/src/routes/services/index.tsx @@ -1,23 +1,23 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { ServicesTable } from "@/components/services/services-table" import { ServicesFilterSidebar } from "@/components/services/services-filter-sidebar" import { TimeRangePicker } from "@/components/time-range-picker" -const servicesSearchSchema = z.object({ - environments: z.array(z.string()).optional(), - commitShas: z.array(z.string()).optional(), - startTime: z.string().optional(), - endTime: z.string().optional(), +const servicesSearchSchema = Schema.Struct({ + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + commitShas: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), }) -export type ServicesSearchParams = z.infer +export type ServicesSearchParams = Schema.Schema.Type export const Route = createFileRoute("/services/")({ component: ServicesPage, - validateSearch: (search) => servicesSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(servicesSearchSchema), }) function ServicesPage() { diff --git a/apps/web/src/routes/traces/index.tsx b/apps/web/src/routes/traces/index.tsx index 9c7e412..5d9da6d 100644 --- a/apps/web/src/routes/traces/index.tsx +++ b/apps/web/src/routes/traces/index.tsx @@ -1,30 +1,30 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { z } from "zod" +import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { TracesTable } from "@/components/traces/traces-table" import { TracesFilterSidebar } from "@/components/traces/traces-filter-sidebar" import { TimeRangePicker } from "@/components/time-range-picker" -const tracesSearchSchema = z.object({ - services: z.array(z.string()).optional(), - spanNames: z.array(z.string()).optional(), - hasError: z.boolean().optional(), - minDurationMs: z.number().optional(), - maxDurationMs: z.number().optional(), - httpMethods: z.array(z.string()).optional(), - httpStatusCodes: z.array(z.string()).optional(), - deploymentEnvs: z.array(z.string()).optional(), - startTime: z.string().optional(), - endTime: z.string().optional(), - rootOnly: z.boolean().optional(), +const tracesSearchSchema = Schema.Struct({ + services: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + spanNames: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + hasError: Schema.optional(Schema.Boolean), + minDurationMs: Schema.optional(Schema.Number), + maxDurationMs: Schema.optional(Schema.Number), + httpMethods: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + httpStatusCodes: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + deploymentEnvs: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + rootOnly: Schema.optional(Schema.Boolean), }) -export type TracesSearchParams = z.infer +export type TracesSearchParams = Schema.Schema.Type export const Route = createFileRoute("/traces/")({ component: TracesPage, - validateSearch: (search) => tracesSearchSchema.parse(search), + validateSearch: Schema.standardSchemaV1(tracesSearchSchema), }) function TracesPage() { From 1431f167eb5142c4db1a383e6d3b5f0acaf26dab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 19 Feb 2026 13:00:00 +0100 Subject: [PATCH 3/4] chore remove zod --- apps/web/package.json | 3 +- apps/web/src/api/tinybird/custom-charts.ts | 604 ++++++++------- apps/web/src/api/tinybird/effect-utils.ts | 79 ++ apps/web/src/api/tinybird/error-rates.ts | 71 +- apps/web/src/api/tinybird/errors.ts | 379 +++++----- apps/web/src/api/tinybird/logs.ts | 306 ++++---- apps/web/src/api/tinybird/metrics.ts | 322 ++++---- .../api/tinybird/query-builder-timeseries.ts | 166 +++-- apps/web/src/api/tinybird/service-map.ts | 56 +- apps/web/src/api/tinybird/service-usage.ts | 87 +-- apps/web/src/api/tinybird/services.ts | 196 +++-- .../api/tinybird/timeseries-adapters.test.ts | 33 +- apps/web/src/api/tinybird/traces.ts | 685 ++++++++---------- .../dashboard-builder/data-source-registry.ts | 3 +- apps/web/src/hooks/use-widget-data.ts | 51 +- .../services/atoms/tinybird-query-atoms.ts | 31 +- apps/web/src/lib/tinybird.ts | 21 +- bun.lock | 1 - 18 files changed, 1511 insertions(+), 1583 deletions(-) create mode 100644 apps/web/src/api/tinybird/effect-utils.ts diff --git a/apps/web/package.json b/apps/web/package.json index e1a439a..6e471c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,8 +50,7 @@ "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", - "web-vitals": "^5.1.0", - "zod": "^3.25.76" + "web-vitals": "^5.1.0" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.21.2", diff --git a/apps/web/src/api/tinybird/custom-charts.ts b/apps/web/src/api/tinybird/custom-charts.ts index 53965e9..8b132a8 100644 --- a/apps/web/src/api/tinybird/custom-charts.ts +++ b/apps/web/src/api/tinybird/custom-charts.ts @@ -1,33 +1,35 @@ -import { z } from "zod"; -import { Effect, Schema } from "effect"; -import { - QueryEngineExecuteRequest, - type QuerySpec, - type TracesMetric, - type MetricsMetric, -} from "@maple/domain"; -import { getTinybird } from "@/lib/tinybird"; +import { QueryEngineExecuteRequest, type MetricsMetric, type QuerySpec, type TracesMetric } from "@maple/domain" +import { Effect, Schema } from "effect" + +import { getTinybird } from "@/lib/tinybird" +import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { buildBucketTimeline, computeBucketSeconds, toIsoBucket, -} from "@/api/tinybird/timeseries-utils"; -import { MapleApiAtomClient } from "@/lib/services/common/atom-client"; -import { runtime } from "@/lib/services/common/runtime"; +} from "@/api/tinybird/timeseries-utils" +import { + TinybirdDateTimeString, + TinybirdApiError, + decodeInput, + invalidTinybirdInput, + runTinybirdQuery, +} from "@/api/tinybird/effect-utils" import type { ServiceDetailTimeSeriesPoint, ServiceDetailTimeSeriesResponse, - ServiceTimeSeriesPoint, ServiceOverviewTimeSeriesResponse, -} from "@/api/tinybird/services"; + ServiceTimeSeriesPoint, +} from "@/api/tinybird/services" + +const dateTimeString = TinybirdDateTimeString -// Date format: "YYYY-MM-DD HH:mm:ss" (Tinybird/ClickHouse compatible) -const dateTimeString = z - .string() - .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, "Invalid datetime format"); +function toMessage(cause: unknown, fallback: string): string { + return cause instanceof Error ? cause.message : fallback +} function sortByBucket(rows: T[]): T[] { - return [...rows].sort((left, right) => left.bucket.localeCompare(right.bucket)); + return [...rows].sort((left, right) => left.bucket.localeCompare(right.bucket)) } function fillServiceDetailPoints( @@ -36,20 +38,20 @@ function fillServiceDetailPoints( endTime: string | undefined, bucketSeconds: number, ): ServiceDetailTimeSeriesPoint[] { - const timeline = buildBucketTimeline(startTime, endTime, bucketSeconds); + const timeline = buildBucketTimeline(startTime, endTime, bucketSeconds) if (timeline.length === 0) { - return sortByBucket(points); + return sortByBucket(points) } - const byBucket = new Map(); + const byBucket = new Map() for (const point of points) { - byBucket.set(toIsoBucket(point.bucket), point); + byBucket.set(toIsoBucket(point.bucket), point) } return timeline.map((bucket) => { - const existing = byBucket.get(bucket); + const existing = byBucket.get(bucket) if (existing) { - return existing; + return existing } return { @@ -59,8 +61,8 @@ function fillServiceDetailPoints( p50LatencyMs: 0, p95LatencyMs: 0, p99LatencyMs: 0, - }; - }); + } + }) } function fillServiceSparklinePoints( @@ -68,69 +70,66 @@ function fillServiceSparklinePoints( timeline: string[], ): ServiceTimeSeriesPoint[] { if (timeline.length === 0) { - return sortByBucket(points); + return sortByBucket(points) } - const byBucket = new Map(); + const byBucket = new Map() for (const point of points) { - byBucket.set(toIsoBucket(point.bucket), point); + byBucket.set(toIsoBucket(point.bucket), point) } return timeline.map((bucket) => { - const existing = byBucket.get(bucket); + const existing = byBucket.get(bucket) if (existing) { - return existing; + return existing } return { bucket, throughput: 0, errorRate: 0, - }; - }); + } + }) } -// --- Time Series --- - -const CustomChartTimeSeriesInput = z.object({ - source: z.enum(["traces", "logs", "metrics"]), - metric: z.string(), - groupBy: z - .enum(["service", "span_name", "status_code", "severity", "attribute", "none"]) - .optional(), - filters: z - .object({ - serviceName: z.string().optional(), - spanName: z.string().optional(), - severity: z.string().optional(), - metricName: z.string().optional(), - metricType: z - .enum(["sum", "gauge", "histogram", "exponential_histogram"]) - .optional(), - rootSpansOnly: z.boolean().optional(), - environments: z.array(z.string()).optional(), - commitShas: z.array(z.string()).optional(), - attributeKey: z.string().optional(), - attributeValue: z.string().optional(), - }) - .optional(), +const SharedFiltersSchema = Schema.Struct({ + serviceName: Schema.optional(Schema.String), + spanName: Schema.optional(Schema.String), + severity: Schema.optional(Schema.String), + metricName: Schema.optional(Schema.String), + metricType: Schema.optional( + Schema.Literal("sum", "gauge", "histogram", "exponential_histogram"), + ), + rootSpansOnly: Schema.optional(Schema.Boolean), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + commitShas: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + attributeKey: Schema.optional(Schema.String), + attributeValue: Schema.optional(Schema.String), +}) + +const CustomChartTimeSeriesInputSchema = Schema.Struct({ + source: Schema.Literal("traces", "logs", "metrics"), + metric: Schema.String, + groupBy: Schema.optional( + Schema.Literal("service", "span_name", "status_code", "severity", "attribute", "none"), + ), + filters: Schema.optional(SharedFiltersSchema), startTime: dateTimeString, endTime: dateTimeString, - bucketSeconds: z.number().min(1).optional(), -}); + bucketSeconds: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1)), + ), +}) -export type CustomChartTimeSeriesInput = z.infer< - typeof CustomChartTimeSeriesInput ->; +export type CustomChartTimeSeriesInput = Schema.Schema.Type export interface CustomChartTimeSeriesPoint { - bucket: string; - series: Record; + bucket: string + series: Record } export interface CustomChartTimeSeriesResponse { - data: CustomChartTimeSeriesPoint[]; - error: string | null; + data: CustomChartTimeSeriesPoint[] } const tracesMetrics = new Set([ @@ -140,27 +139,32 @@ const tracesMetrics = new Set([ "p95_duration", "p99_duration", "error_rate", -]); -const metricsMetrics = new Set(["avg", "sum", "min", "max", "count"]); -const metricsBreakdownMetrics = new Set<"avg" | "sum" | "count">(["avg", "sum", "count"]); - -function decodeQueryEngineRequest(input: unknown) { - return Schema.decodeUnknownSync(QueryEngineExecuteRequest)(input); -} +]) +const metricsMetrics = new Set(["avg", "sum", "min", "max", "count"]) +const metricsBreakdownMetrics = new Set<"avg" | "sum" | "count">(["avg", "sum", "count"]) function executeQueryEngine(payload: QueryEngineExecuteRequest) { - return runtime.runPromise( - Effect.gen(function* () { - const client = yield* MapleApiAtomClient; - return yield* client.queryEngine.execute({ payload }); - }), - ); + return Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.execute({ payload }) + }).pipe( + Effect.provide(MapleApiAtomClient.layer), + Effect.mapError( + (cause) => + new TinybirdApiError({ + operation: "queryEngine.execute", + stage: "query", + message: toMessage(cause, "Query engine request failed"), + cause, + }), + ), + ) } function buildTimeseriesQuerySpec(data: CustomChartTimeSeriesInput): QuerySpec | string { if (data.source === "traces") { if (!tracesMetrics.has(data.metric as TracesMetric)) { - return `Unknown trace metric: ${data.metric}`; + return `Unknown trace metric: ${data.metric}` } if ( data.groupBy && @@ -168,14 +172,21 @@ function buildTimeseriesQuerySpec(data: CustomChartTimeSeriesInput): QuerySpec | data.groupBy, ) ) { - return `Unsupported traces groupBy: ${data.groupBy}`; + return `Unsupported traces groupBy: ${data.groupBy}` } return { kind: "timeseries", source: "traces", metric: data.metric as TracesMetric, - groupBy: data.groupBy as "service" | "span_name" | "status_code" | "http_method" | "attribute" | "none" | undefined, + groupBy: data.groupBy as + | "service" + | "span_name" + | "status_code" + | "http_method" + | "attribute" + | "none" + | undefined, filters: { serviceName: data.filters?.serviceName, spanName: data.filters?.spanName, @@ -186,15 +197,15 @@ function buildTimeseriesQuerySpec(data: CustomChartTimeSeriesInput): QuerySpec | attributeValue: data.filters?.attributeValue, }, bucketSeconds: data.bucketSeconds, - }; + } } if (data.source === "logs") { if (data.metric !== "count") { - return `Unknown logs metric: ${data.metric}`; + return `Unknown logs metric: ${data.metric}` } if (data.groupBy && !["service", "severity", "none"].includes(data.groupBy)) { - return `Unsupported logs groupBy: ${data.groupBy}`; + return `Unsupported logs groupBy: ${data.groupBy}` } return { @@ -207,17 +218,17 @@ function buildTimeseriesQuerySpec(data: CustomChartTimeSeriesInput): QuerySpec | severity: data.filters?.severity, }, bucketSeconds: data.bucketSeconds, - }; + } } if (!metricsMetrics.has(data.metric as MetricsMetric)) { - return `Unknown metrics metric: ${data.metric}`; + return `Unknown metrics metric: ${data.metric}` } if (!data.filters?.metricName || !data.filters.metricType) { - return "metricName and metricType are required for metrics source"; + return "metricName and metricType are required for metrics source" } if (data.groupBy && !["service", "none"].includes(data.groupBy)) { - return `Unsupported metrics groupBy: ${data.groupBy}`; + return `Unsupported metrics groupBy: ${data.groupBy}` } return { @@ -231,31 +242,42 @@ function buildTimeseriesQuerySpec(data: CustomChartTimeSeriesInput): QuerySpec | serviceName: data.filters.serviceName, }, bucketSeconds: data.bucketSeconds, - }; + } } -export async function getCustomChartTimeSeries({ +export function getCustomChartTimeSeries({ data, }: { data: CustomChartTimeSeriesInput -}): Promise { - data = CustomChartTimeSeriesInput.parse(data) - - try { - const query = buildTimeseriesQuerySpec(data); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + CustomChartTimeSeriesInputSchema, + data, + "getCustomChartTimeSeries", + ) + + const query = buildTimeseriesQuerySpec(input) if (typeof query === "string") { - return { data: [], error: query }; + return yield* invalidTinybirdInput("getCustomChartTimeSeries", query) } - const request = decodeQueryEngineRequest({ - startTime: data.startTime, - endTime: data.endTime, - query, - }); + const request = yield* decodeInput( + QueryEngineExecuteRequest, + { + startTime: input.startTime, + endTime: input.endTime, + query, + }, + "getCustomChartTimeSeries.request", + ) - const response = await executeQueryEngine(request); + const response = yield* executeQueryEngine(request) if (response.result.kind !== "timeseries") { - return { data: [], error: "Unexpected query result kind" }; + return yield* invalidTinybirdInput( + "getCustomChartTimeSeries", + "Unexpected query result kind", + ) } return { @@ -263,77 +285,49 @@ export async function getCustomChartTimeSeries({ bucket: point.bucket, series: { ...point.series }, })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getCustomChartTimeSeries failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch custom chart time series", - }; - } + } + }) } -// --- Breakdown --- - -const CustomChartBreakdownInput = z.object({ - source: z.enum(["traces", "logs", "metrics"]), - metric: z.string(), - groupBy: z.enum([ +const CustomChartBreakdownInputSchema = Schema.Struct({ + source: Schema.Literal("traces", "logs", "metrics"), + metric: Schema.String, + groupBy: Schema.Literal( "service", "span_name", "status_code", "http_method", "severity", "attribute", - ]), - filters: z - .object({ - serviceName: z.string().optional(), - spanName: z.string().optional(), - severity: z.string().optional(), - metricName: z.string().optional(), - metricType: z - .enum(["sum", "gauge", "histogram", "exponential_histogram"]) - .optional(), - rootSpansOnly: z.boolean().optional(), - environments: z.array(z.string()).optional(), - commitShas: z.array(z.string()).optional(), - attributeKey: z.string().optional(), - attributeValue: z.string().optional(), - }) - .optional(), + ), + filters: Schema.optional(SharedFiltersSchema), startTime: dateTimeString, endTime: dateTimeString, - limit: z.number().min(1).max(100).optional(), -}); + limit: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1), Schema.lessThanOrEqualTo(100)), + ), +}) -export type CustomChartBreakdownInput = z.infer< - typeof CustomChartBreakdownInput ->; +export type CustomChartBreakdownInput = Schema.Schema.Type export interface CustomChartBreakdownItem { - name: string; - value: number; + name: string + value: number } export interface CustomChartBreakdownResponse { - data: CustomChartBreakdownItem[]; - error: string | null; + data: CustomChartBreakdownItem[] } function buildBreakdownQuerySpec(data: CustomChartBreakdownInput): QuerySpec | string { if (data.source === "traces") { if (!tracesMetrics.has(data.metric as TracesMetric)) { - return `Unknown trace metric: ${data.metric}`; + return `Unknown trace metric: ${data.metric}` } if ( !["service", "span_name", "status_code", "http_method", "attribute"].includes(data.groupBy) ) { - return `Unsupported traces groupBy: ${data.groupBy}`; + return `Unsupported traces groupBy: ${data.groupBy}` } return { @@ -351,15 +345,15 @@ function buildBreakdownQuerySpec(data: CustomChartBreakdownInput): QuerySpec | s attributeValue: data.filters?.attributeValue, }, limit: data.limit, - }; + } } if (data.source === "logs") { if (data.metric !== "count") { - return `Unknown logs metric: ${data.metric}`; + return `Unknown logs metric: ${data.metric}` } - if (!["service", "severity"].includes(data.groupBy)) { - return `Unsupported logs groupBy: ${data.groupBy}`; + if (![("service" as const), ("severity" as const)].includes(data.groupBy as "service" | "severity")) { + return `Unsupported logs groupBy: ${data.groupBy}` } return { @@ -372,17 +366,17 @@ function buildBreakdownQuerySpec(data: CustomChartBreakdownInput): QuerySpec | s severity: data.filters?.severity, }, limit: data.limit, - }; + } } if (!metricsBreakdownMetrics.has(data.metric as "avg" | "sum" | "count")) { - return `Unknown metrics metric: ${data.metric}`; + return `Unknown metrics metric: ${data.metric}` } if (!data.filters?.metricName || !data.filters.metricType) { - return "metricName and metricType are required for metrics source"; + return "metricName and metricType are required for metrics source" } if (data.groupBy !== "service") { - return `Unsupported metrics groupBy: ${data.groupBy}`; + return `Unsupported metrics groupBy: ${data.groupBy}` } return { @@ -396,30 +390,42 @@ function buildBreakdownQuerySpec(data: CustomChartBreakdownInput): QuerySpec | s serviceName: data.filters.serviceName, }, limit: data.limit, - }; + } } -export async function getCustomChartBreakdown({ +export function getCustomChartBreakdown({ data, }: { data: CustomChartBreakdownInput -}): Promise { - data = CustomChartBreakdownInput.parse(data) - - try { - const query = buildBreakdownQuerySpec(data); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + CustomChartBreakdownInputSchema, + data, + "getCustomChartBreakdown", + ) + + const query = buildBreakdownQuerySpec(input) if (typeof query === "string") { - return { data: [], error: query }; + return yield* invalidTinybirdInput("getCustomChartBreakdown", query) } - const request = decodeQueryEngineRequest({ - startTime: data.startTime, - endTime: data.endTime, - query, - }); - const response = await executeQueryEngine(request); + const request = yield* decodeInput( + QueryEngineExecuteRequest, + { + startTime: input.startTime, + endTime: input.endTime, + query, + }, + "getCustomChartBreakdown.request", + ) + + const response = yield* executeQueryEngine(request) if (response.result.kind !== "breakdown") { - return { data: [], error: "Unexpected query result kind" }; + return yield* invalidTinybirdInput( + "getCustomChartBreakdown", + "Unexpected query result kind", + ) } return { @@ -427,45 +433,41 @@ export async function getCustomChartBreakdown({ name: item.name, value: item.value, })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getCustomChartBreakdown failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch custom chart breakdown", - }; - } + } + }) } -// --- Service Detail (via custom traces timeseries) --- +const GetCustomChartServiceDetailInputSchema = Schema.Struct({ + serviceName: Schema.String, + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), +}) -const GetCustomChartServiceDetailInput = z.object({ - serviceName: z.string(), - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), -}); +type GetCustomChartServiceDetailInput = Schema.Schema.Type -export async function getCustomChartServiceDetail({ +export function getCustomChartServiceDetail({ data, }: { - data: z.infer -}): Promise { - data = GetCustomChartServiceDetailInput.parse(data) - - try { - const tinybird = getTinybird(); - const bucketSeconds = computeBucketSeconds(data.startTime, data.endTime); - const result = await tinybird.query.custom_traces_timeseries({ - start_time: data.startTime!, - end_time: data.endTime!, - bucket_seconds: bucketSeconds, - service_name: data.serviceName, - root_only: "1", - }); + data: GetCustomChartServiceDetailInput +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetCustomChartServiceDetailInputSchema, + data, + "getCustomChartServiceDetail", + ) + + 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, + root_only: "1", + }), + ) const points = result.data.map( (row): ServiceDetailTimeSeriesPoint => ({ @@ -476,54 +478,45 @@ export async function getCustomChartServiceDetail({ p95LatencyMs: Number(row.p95Duration), p99LatencyMs: Number(row.p99Duration), }), - ); + ) return { - data: fillServiceDetailPoints( - points, - data.startTime, - data.endTime, - bucketSeconds, - ), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getCustomChartServiceDetail failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch service detail time series", - }; - } + data: fillServiceDetailPoints(points, input.startTime, input.endTime, bucketSeconds), + } + }) } -// --- Overview Time Series (all services aggregated, optional env filter) --- +const GetOverviewTimeSeriesInputSchema = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) -const GetOverviewTimeSeriesInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - environments: z.array(z.string()).optional(), -}); +type GetOverviewTimeSeriesInput = Schema.Schema.Type -export async function getOverviewTimeSeries({ +export function getOverviewTimeSeries({ data, }: { - data: z.infer -}): Promise { - data = GetOverviewTimeSeriesInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const bucketSeconds = computeBucketSeconds(data.startTime, data.endTime); - const result = await tinybird.query.custom_traces_timeseries({ - start_time: data.startTime!, - end_time: data.endTime!, - bucket_seconds: bucketSeconds, - root_only: "1", - environments: data.environments?.join(","), - }); + data: GetOverviewTimeSeriesInput +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetOverviewTimeSeriesInputSchema, + data ?? {}, + "getOverviewTimeSeries", + ) + + 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, + root_only: "1", + environments: input.environments?.join(","), + }), + ) const points = result.data.map( (row): ServiceDetailTimeSeriesPoint => ({ @@ -534,75 +527,64 @@ export async function getOverviewTimeSeries({ p95LatencyMs: Number(row.p95Duration), p99LatencyMs: Number(row.p99Duration), }), - ); + ) return { - data: fillServiceDetailPoints( - points, - data.startTime, - data.endTime, - bucketSeconds, - ), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getOverviewTimeSeries failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch overview time series", - }; - } + data: fillServiceDetailPoints(points, input.startTime, input.endTime, bucketSeconds), + } + }) } -// --- Service Sparklines (via custom traces timeseries with group_by_service) --- +const GetCustomChartServiceSparklinesInputSchema = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + commitShas: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) -const GetCustomChartServiceSparklinesInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - environments: z.array(z.string()).optional(), - commitShas: z.array(z.string()).optional(), -}); +type GetCustomChartServiceSparklinesInput = Schema.Schema.Type< + typeof GetCustomChartServiceSparklinesInputSchema +> -export async function getCustomChartServiceSparklines({ +export function getCustomChartServiceSparklines({ data, }: { - data: z.infer -}): Promise { - data = GetCustomChartServiceSparklinesInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const bucketSeconds = computeBucketSeconds(data.startTime, data.endTime); - const result = await tinybird.query.custom_traces_timeseries({ - start_time: data.startTime!, - end_time: data.endTime!, - bucket_seconds: bucketSeconds, - root_only: "1", - group_by_service: "1", - environments: data.environments?.join(","), - commit_shas: data.commitShas?.join(","), - }); - - const timeline = buildBucketTimeline( - data.startTime, - data.endTime, - bucketSeconds, - ); - const grouped: Record = {}; + data: GetCustomChartServiceSparklinesInput +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetCustomChartServiceSparklinesInputSchema, + data ?? {}, + "getCustomChartServiceSparklines", + ) + + 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, + root_only: "1", + group_by_service: "1", + environments: input.environments?.join(","), + commit_shas: input.commitShas?.join(","), + }), + ) + + const timeline = buildBucketTimeline(input.startTime, input.endTime, bucketSeconds) + const grouped: Record = {} for (const row of result.data) { - const bucket = toIsoBucket(row.bucket); + const bucket = toIsoBucket(row.bucket) const point: ServiceTimeSeriesPoint = { bucket, throughput: Number(row.count), errorRate: Number(row.errorRate), - }; + } if (!grouped[row.groupName]) { - grouped[row.groupName] = []; + grouped[row.groupName] = [] } - grouped[row.groupName].push(point); + grouped[row.groupName].push(point) } const filledGrouped = Object.fromEntries( @@ -610,20 +592,8 @@ export async function getCustomChartServiceSparklines({ service, fillServiceSparklinePoints(points, timeline), ]), - ); - - return { data: filledGrouped, error: null }; - } catch (error) { - console.error( - "[Tinybird] getCustomChartServiceSparklines failed:", - error, - ); - return { - data: {}, - error: - error instanceof Error - ? error.message - : "Failed to fetch service sparklines", - }; - } + ) + + return { data: filledGrouped } + }) } diff --git a/apps/web/src/api/tinybird/effect-utils.ts b/apps/web/src/api/tinybird/effect-utils.ts new file mode 100644 index 0000000..fbd5143 --- /dev/null +++ b/apps/web/src/api/tinybird/effect-utils.ts @@ -0,0 +1,79 @@ +import { TinybirdDateTime } from "@maple/domain" +import { Effect, Schema } from "effect" +import { MapleApiAtomClient } from "@/lib/services/common/atom-client" + +export const TinybirdDateTimeString = TinybirdDateTime + +export class TinybirdApiError extends Schema.TaggedError()( + "TinybirdApiError", + { + operation: Schema.String, + stage: Schema.Literal("decode", "query", "transform", "invalid"), + message: Schema.String, + cause: Schema.optional(Schema.Unknown), + }, +) {} + +function toMessage(cause: unknown, fallback: string): string { + return cause instanceof Error ? cause.message : fallback +} + +export function decodeInput( + schema: Schema.Schema, + input: unknown, + operation: string, +): Effect.Effect { + return Effect.try({ + try: () => Schema.decodeUnknownSync(schema)(input), + catch: (cause) => + new TinybirdApiError({ + operation, + stage: "decode", + message: toMessage(cause, `Invalid input for ${operation}`), + cause, + }), + }) +} + +export function runTinybirdQuery( + operation: string, + execute: () => Effect.Effect, +): Effect.Effect { + return Effect.try({ + try: execute, + catch: (cause) => + new TinybirdApiError({ + operation, + stage: "query", + message: toMessage(cause, `Tinybird query failed for ${operation}`), + cause, + }), + }).pipe( + Effect.flatMap((effect) => effect), + Effect.provide(MapleApiAtomClient.layer), + Effect.mapError( + (cause) => + cause instanceof TinybirdApiError + ? cause + : new TinybirdApiError({ + operation, + stage: "query", + message: toMessage(cause, `Tinybird query failed for ${operation}`), + cause, + }), + ), + ) +} + +export function invalidTinybirdInput( + operation: string, + message: string, +): Effect.Effect { + return Effect.fail( + new TinybirdApiError({ + operation, + stage: "invalid", + message, + }), + ) +} diff --git a/apps/web/src/api/tinybird/error-rates.ts b/apps/web/src/api/tinybird/error-rates.ts index add0730..43a51e1 100644 --- a/apps/web/src/api/tinybird/error-rates.ts +++ b/apps/web/src/api/tinybird/error-rates.ts @@ -1,40 +1,49 @@ -import { z } from "zod"; -import { getTinybird } from "@/lib/tinybird"; +import { Effect, Schema } from "effect" +import { getTinybird } from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" export interface ErrorRateByService { - serviceName: string; - totalLogs: number; - errorLogs: number; - errorRatePercent: number; + serviceName: string + totalLogs: number + errorLogs: number + errorRatePercent: number } export interface ErrorRateByServiceResponse { - data: ErrorRateByService[]; - error: string | null; + data: ErrorRateByService[] } -const GetErrorRateByServiceInput = z.object({ - startTime: z.string().datetime().optional(), - endTime: z.string().datetime().optional(), -}); +const GetErrorRateByServiceInput = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) -export type GetErrorRateByServiceInput = z.infer< - typeof GetErrorRateByServiceInput ->; +export type GetErrorRateByServiceInput = Schema.Schema.Type -export async function getErrorRateByService({ +export function getErrorRateByService({ data, }: { data: GetErrorRateByServiceInput -}): Promise { - data = GetErrorRateByServiceInput.parse(data ?? {}) +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetErrorRateByServiceInput, + data ?? {}, + "getErrorRateByService", + ) - try { - const tinybird = getTinybird(); - const result = await tinybird.query.error_rate_by_service({ - start_time: data.startTime, - end_time: data.endTime, - }); + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("error_rate_by_service", () => + tinybird.query.error_rate_by_service({ + start_time: input.startTime, + end_time: input.endTime, + }), + ) return { data: result.data.map((row) => ({ @@ -43,16 +52,6 @@ export async function getErrorRateByService({ errorLogs: Number(row.errorLogs), errorRatePercent: Number(row.errorRatePercent), })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getErrorRateByService failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch error rates", - }; - } + } + }) } diff --git a/apps/web/src/api/tinybird/errors.ts b/apps/web/src/api/tinybird/errors.ts index 9896520..88a7a55 100644 --- a/apps/web/src/api/tinybird/errors.ts +++ b/apps/web/src/api/tinybird/errors.ts @@ -1,38 +1,45 @@ -import { z } from "zod"; -import { getTinybird, type ErrorsByTypeOutput, type ErrorDetailTracesOutput, type ErrorsFacetsOutput, type ErrorsSummaryOutput } from "@/lib/tinybird"; -import { getSpamPatternsParam } from "@/lib/spam-patterns"; +import { Effect, Schema } from "effect" +import { + getTinybird, + type ErrorDetailTracesOutput, + type ErrorsByTypeOutput, + type ErrorsFacetsOutput, + type ErrorsSummaryOutput, +} from "@/lib/tinybird" +import { getSpamPatternsParam } from "@/lib/spam-patterns" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" + +const OptionalStringArray = Schema.optional(Schema.mutable(Schema.Array(Schema.String))) -// Date format: "YYYY-MM-DD HH:mm:ss" (Tinybird/ClickHouse compatible) -const dateTimeString = z - .string() - .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, "Invalid datetime format"); - -// Error by type types export interface ErrorByType { - errorType: string; - count: number; - affectedServicesCount: number; - firstSeen: Date; - lastSeen: Date; - affectedServices: string[]; + errorType: string + count: number + affectedServicesCount: number + firstSeen: Date + lastSeen: Date + affectedServices: string[] } export interface ErrorsByTypeResponse { - data: ErrorByType[]; - error: string | null; + data: ErrorByType[] } -const GetErrorsByTypeInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - services: z.array(z.string()).optional(), - deploymentEnvs: z.array(z.string()).optional(), - errorTypes: z.array(z.string()).optional(), - limit: z.number().optional(), - showSpam: z.boolean().optional(), -}); +const GetErrorsByTypeInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + services: OptionalStringArray, + deploymentEnvs: OptionalStringArray, + errorTypes: OptionalStringArray, + limit: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.greaterThan(0))), + showSpam: Schema.optional(Schema.Boolean), +}) -export type GetErrorsByTypeInput = z.infer; +export type GetErrorsByTypeInput = Schema.Schema.Type function transformErrorByType(raw: ErrorsByTypeOutput): ErrorByType { return { @@ -42,157 +49,133 @@ function transformErrorByType(raw: ErrorsByTypeOutput): ErrorByType { firstSeen: new Date(raw.firstSeen), lastSeen: new Date(raw.lastSeen), affectedServices: raw.affectedServices, - }; + } } -export async function getErrorsByType({ +export function getErrorsByType({ data, }: { data: GetErrorsByTypeInput -}): Promise { - data = GetErrorsByTypeInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.errors_by_type({ - start_time: data.startTime, - end_time: data.endTime, - services: data.services?.join(","), - deployment_envs: data.deploymentEnvs?.join(","), - error_types: data.errorTypes?.join(","), - limit: data.limit, - exclude_spam_patterns: getSpamPatternsParam(data.showSpam), - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetErrorsByTypeInputSchema, data ?? {}, "getErrorsByType") + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("errors_by_type", () => + tinybird.query.errors_by_type({ + start_time: input.startTime, + end_time: input.endTime, + services: input.services?.join(","), + deployment_envs: input.deploymentEnvs?.join(","), + error_types: input.errorTypes?.join(","), + limit: input.limit, + exclude_spam_patterns: getSpamPatternsParam(input.showSpam), + }), + ) return { data: result.data.map(transformErrorByType), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getErrorsByType failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch errors by type", - }; - } + } + }) } -// Error facets types export interface FacetItem { - name: string; - count: number; + name: string + count: number } export interface ErrorsFacets { - services: FacetItem[]; - deploymentEnvs: FacetItem[]; - errorTypes: FacetItem[]; + services: FacetItem[] + deploymentEnvs: FacetItem[] + errorTypes: FacetItem[] } export interface ErrorsFacetsResponse { - data: ErrorsFacets; - error: string | null; + data: ErrorsFacets } -const GetErrorsFacetsInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - services: z.array(z.string()).optional(), - deploymentEnvs: z.array(z.string()).optional(), - errorTypes: z.array(z.string()).optional(), - showSpam: z.boolean().optional(), -}); +const GetErrorsFacetsInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + services: OptionalStringArray, + deploymentEnvs: OptionalStringArray, + errorTypes: OptionalStringArray, + showSpam: Schema.optional(Schema.Boolean), +}) -export type GetErrorsFacetsInput = z.infer; +export type GetErrorsFacetsInput = Schema.Schema.Type function transformErrorsFacets(facetsData: ErrorsFacetsOutput[]): ErrorsFacets { - const services: FacetItem[] = []; - const deploymentEnvs: FacetItem[] = []; - const errorTypes: FacetItem[] = []; + const services: FacetItem[] = [] + const deploymentEnvs: FacetItem[] = [] + const errorTypes: FacetItem[] = [] for (const row of facetsData) { - const item = { name: row.name, count: Number(row.count) }; + const item = { name: row.name, count: Number(row.count) } switch (row.facetType) { case "service": - services.push(item); - break; + services.push(item) + break case "deploymentEnv": - deploymentEnvs.push(item); - break; + deploymentEnvs.push(item) + break case "errorType": - errorTypes.push(item); - break; + errorTypes.push(item) + break } } - return { services, deploymentEnvs, errorTypes }; + return { services, deploymentEnvs, errorTypes } } -export async function getErrorsFacets({ +export function getErrorsFacets({ data, }: { data: GetErrorsFacetsInput -}): Promise { - data = GetErrorsFacetsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.errors_facets({ - start_time: data.startTime, - end_time: data.endTime, - services: data.services?.join(","), - deployment_envs: data.deploymentEnvs?.join(","), - error_types: data.errorTypes?.join(","), - exclude_spam_patterns: getSpamPatternsParam(data.showSpam), - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetErrorsFacetsInputSchema, data ?? {}, "getErrorsFacets") + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("errors_facets", () => + tinybird.query.errors_facets({ + start_time: input.startTime, + end_time: input.endTime, + services: input.services?.join(","), + deployment_envs: input.deploymentEnvs?.join(","), + error_types: input.errorTypes?.join(","), + exclude_spam_patterns: getSpamPatternsParam(input.showSpam), + }), + ) return { data: transformErrorsFacets(result.data), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getErrorsFacets failed:", error); - return { - data: { - services: [], - deploymentEnvs: [], - errorTypes: [], - }, - error: - error instanceof Error - ? error.message - : "Failed to fetch errors facets", - }; - } + } + }) } -// Error summary types export interface ErrorsSummary { - totalErrors: number; - totalSpans: number; - errorRate: number; - affectedServicesCount: number; - affectedTracesCount: number; + totalErrors: number + totalSpans: number + errorRate: number + affectedServicesCount: number + affectedTracesCount: number } export interface ErrorsSummaryResponse { - data: ErrorsSummary | null; - error: string | null; + data: ErrorsSummary | null } -const GetErrorsSummaryInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - services: z.array(z.string()).optional(), - deploymentEnvs: z.array(z.string()).optional(), - errorTypes: z.array(z.string()).optional(), - showSpam: z.boolean().optional(), -}); +const GetErrorsSummaryInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + services: OptionalStringArray, + deploymentEnvs: OptionalStringArray, + errorTypes: OptionalStringArray, + showSpam: Schema.optional(Schema.Boolean), +}) -export type GetErrorsSummaryInput = z.infer; +export type GetErrorsSummaryInput = Schema.Schema.Type function transformErrorsSummary(raw: ErrorsSummaryOutput): ErrorsSummary { return { @@ -201,70 +184,60 @@ function transformErrorsSummary(raw: ErrorsSummaryOutput): ErrorsSummary { errorRate: Number(raw.errorRate), affectedServicesCount: Number(raw.affectedServicesCount), affectedTracesCount: Number(raw.affectedTracesCount), - }; + } } -export async function getErrorsSummary({ +export function getErrorsSummary({ data, }: { data: GetErrorsSummaryInput -}): Promise { - data = GetErrorsSummaryInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.errors_summary({ - start_time: data.startTime, - end_time: data.endTime, - services: data.services?.join(","), - deployment_envs: data.deploymentEnvs?.join(","), - error_types: data.errorTypes?.join(","), - exclude_spam_patterns: getSpamPatternsParam(data.showSpam), - }); - - const summary = result.data[0]; +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetErrorsSummaryInputSchema, data ?? {}, "getErrorsSummary") + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("errors_summary", () => + tinybird.query.errors_summary({ + start_time: input.startTime, + end_time: input.endTime, + services: input.services?.join(","), + deployment_envs: input.deploymentEnvs?.join(","), + error_types: input.errorTypes?.join(","), + exclude_spam_patterns: getSpamPatternsParam(input.showSpam), + }), + ) + + const summary = result.data[0] return { data: summary ? transformErrorsSummary(summary) : null, - error: null, - }; - } catch (error) { - console.error("[Tinybird] getErrorsSummary failed:", error); - return { - data: null, - error: - error instanceof Error - ? error.message - : "Failed to fetch errors summary", - }; - } + } + }) } -// Error detail traces types export interface ErrorDetailTrace { - traceId: string; - startTime: Date; - durationMicros: number; - spanCount: number; - services: string[]; - rootSpanName: string; - errorMessage: string; + traceId: string + startTime: Date + durationMicros: number + spanCount: number + services: string[] + rootSpanName: string + errorMessage: string } export interface ErrorDetailTracesResponse { - data: ErrorDetailTrace[]; - error: string | null; + data: ErrorDetailTrace[] } -const GetErrorDetailTracesInput = z.object({ - errorType: z.string(), - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - services: z.array(z.string()).optional(), - limit: z.number().optional(), - showSpam: z.boolean().optional(), -}); +const GetErrorDetailTracesInputSchema = Schema.Struct({ + errorType: Schema.String, + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + services: OptionalStringArray, + limit: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.greaterThan(0))), + showSpam: Schema.optional(Schema.Boolean), +}) -export type GetErrorDetailTracesInput = z.infer; +export type GetErrorDetailTracesInput = Schema.Schema.Type function transformErrorDetailTrace(raw: ErrorDetailTracesOutput): ErrorDetailTrace { return { @@ -275,39 +248,35 @@ function transformErrorDetailTrace(raw: ErrorDetailTracesOutput): ErrorDetailTra services: raw.services, rootSpanName: raw.rootSpanName, errorMessage: raw.errorMessage, - }; + } } -export async function getErrorDetailTraces({ +export function getErrorDetailTraces({ data, }: { data: GetErrorDetailTracesInput -}): Promise { - data = GetErrorDetailTracesInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.error_detail_traces({ - error_type: data.errorType, - start_time: data.startTime, - end_time: data.endTime, - services: data.services?.join(","), - limit: data.limit, - exclude_spam_patterns: getSpamPatternsParam(data.showSpam), - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetErrorDetailTracesInputSchema, + data ?? {}, + "getErrorDetailTraces", + ) + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("error_detail_traces", () => + tinybird.query.error_detail_traces({ + error_type: input.errorType, + start_time: input.startTime, + end_time: input.endTime, + services: input.services?.join(","), + limit: input.limit, + exclude_spam_patterns: getSpamPatternsParam(input.showSpam), + }), + ) return { data: result.data.map(transformErrorDetailTrace), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getErrorDetailTraces failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch error detail traces", - }; - } + } + }) } diff --git a/apps/web/src/api/tinybird/logs.ts b/apps/web/src/api/tinybird/logs.ts index 0f03557..9cedaaa 100644 --- a/apps/web/src/api/tinybird/logs.ts +++ b/apps/web/src/api/tinybird/logs.ts @@ -1,51 +1,62 @@ -import { z } from "zod"; -import { getTinybird, type ListLogsOutput } from "@/lib/tinybird"; - -// Input validation schemas -const ListLogsInput = z.object({ - limit: z.number().min(1).max(1000).optional(), - service: z.string().optional(), - severity: z.string().optional(), - minSeverity: z.number().min(0).max(255).optional(), - startTime: z.string().optional(), - endTime: z.string().optional(), - traceId: z.string().optional(), - spanId: z.string().optional(), - cursor: z.string().optional(), - search: z.string().optional(), -}); - -export type ListLogsInput = z.infer; - -// Default values applied at runtime -const DEFAULT_LIMIT = 100; - -// Log entry for client use (now matches endpoint output directly) +import { Effect, Schema } from "effect" +import { getTinybird, type ListLogsOutput } from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" + +const ListLogsInputSchema = Schema.Struct({ + limit: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1), Schema.lessThanOrEqualTo(1000)), + ), + service: Schema.optional(Schema.String), + severity: Schema.optional(Schema.String), + minSeverity: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0), Schema.lessThanOrEqualTo(255)), + ), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + traceId: Schema.optional(Schema.String), + spanId: Schema.optional(Schema.String), + cursor: Schema.optional(Schema.String), + search: Schema.optional(Schema.String), +}) + +export type ListLogsInput = Schema.Schema.Type + +const DEFAULT_LIMIT = 100 + export interface Log { - timestamp: string; - severityText: string; - severityNumber: number; - serviceName: string; - body: string; - traceId: string; - spanId: string; - logAttributes: Record; - resourceAttributes: Record; + timestamp: string + severityText: string + severityNumber: number + serviceName: string + body: string + traceId: string + spanId: string + logAttributes: Record + resourceAttributes: Record } export interface LogsResponse { - data: Log[]; + data: Log[] meta: { - limit: number; - total: number; - cursor: string | null; - }; - error: string | null; + limit: number + total: number + cursor: string | null + } } export interface LogsCountResponse { - data: Array<{ total: number }>; - error: string | null; + data: Array<{ total: number }> +} + +function parseAttributes(value: string | null | undefined): Record { + if (!value) return {} + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" ? (parsed as Record) : {} } function transformLog(raw: ListLogsOutput): Log { @@ -57,53 +68,51 @@ function transformLog(raw: ListLogsOutput): Log { body: raw.body, traceId: raw.traceId, spanId: raw.spanId, - logAttributes: raw.logAttributes ? JSON.parse(raw.logAttributes) : {}, - resourceAttributes: raw.resourceAttributes ? JSON.parse(raw.resourceAttributes) : {}, - }; + logAttributes: parseAttributes(raw.logAttributes), + resourceAttributes: parseAttributes(raw.resourceAttributes), + } } -export async function listLogs({ +export function listLogs({ data, }: { data: ListLogsInput -}): Promise { - data = ListLogsInput.parse(data ?? {}) - const limit = data.limit ?? DEFAULT_LIMIT; - - try { - const tinybird = getTinybird(); - - const [logsResult, countResult] = await Promise.all([ - tinybird.query.list_logs({ - limit, - service: data.service, - severity: data.severity, - min_severity: data.minSeverity, - start_time: data.startTime, - end_time: data.endTime, - trace_id: data.traceId, - span_id: data.spanId, - cursor: data.cursor, - search: data.search, - }), - tinybird.query.logs_count({ - service: data.service, - severity: data.severity, - start_time: data.startTime, - end_time: data.endTime, - trace_id: data.traceId, - search: data.search, - }), - ]); - - const total = Number(countResult.data[0]?.total ?? 0); - const logs = logsResult.data.map(transformLog); - - // Cursor for next page (last timestamp if we have more data) - const cursor = - logs.length === limit && logs.length > 0 - ? logs[logs.length - 1].timestamp - : null; +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(ListLogsInputSchema, data ?? {}, "listLogs") + const limit = input.limit ?? DEFAULT_LIMIT + const tinybird = getTinybird() + + const [logsResult, countResult] = yield* Effect.all([ + runTinybirdQuery("list_logs", () => + tinybird.query.list_logs({ + limit, + service: input.service, + severity: input.severity, + min_severity: input.minSeverity, + start_time: input.startTime, + end_time: input.endTime, + trace_id: input.traceId, + span_id: input.spanId, + cursor: input.cursor, + search: input.search, + }), + ), + runTinybirdQuery("logs_count", () => + tinybird.query.logs_count({ + service: input.service, + severity: input.severity, + start_time: input.startTime, + end_time: input.endTime, + trace_id: input.traceId, + search: input.search, + }), + ), + ]) + + const total = Number(countResult.data[0]?.total ?? 0) + const logs = logsResult.data.map(transformLog) + const cursor = logs.length === limit && logs.length > 0 ? logs[logs.length - 1].timestamp : null return { data: logs, @@ -112,118 +121,91 @@ export async function listLogs({ total, cursor, }, - error: null, - }; - } catch (error) { - console.error("[Tinybird] listLogs failed:", error); - return { - data: [], - meta: { - limit, - total: 0, - cursor: null, - }, - error: error instanceof Error ? error.message : "Failed to fetch logs", - }; - } + } + }) } -export async function getLogsCount({ +export function getLogsCount({ data, }: { data: ListLogsInput -}): Promise { - data = ListLogsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const countResult = await tinybird.query.logs_count({ - service: data.service, - severity: data.severity, - start_time: data.startTime, - end_time: data.endTime, - trace_id: data.traceId, - search: data.search, - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(ListLogsInputSchema, data ?? {}, "getLogsCount") + const tinybird = getTinybird() + + const countResult = yield* runTinybirdQuery("logs_count", () => + tinybird.query.logs_count({ + service: input.service, + severity: input.severity, + start_time: input.startTime, + end_time: input.endTime, + trace_id: input.traceId, + search: input.search, + }), + ) return { data: [{ total: Number(countResult.data[0]?.total ?? 0) }], - error: null, - }; - } catch (error) { - console.error("[Tinybird] getLogsCount failed:", error); - return { - data: [{ total: 0 }], - error: error instanceof Error ? error.message : "Failed to fetch log count", - }; - } + } + }) } export interface FacetItem { - name: string; - count: number; + name: string + count: number } export interface LogsFacets { - services: FacetItem[]; - severities: FacetItem[]; + services: FacetItem[] + severities: FacetItem[] } export interface LogsFacetsResponse { - data: LogsFacets; - error: string | null; + data: LogsFacets } -const GetLogsFacetsInput = z.object({ - service: z.string().optional(), - severity: z.string().optional(), - startTime: z.string().optional(), - endTime: z.string().optional(), -}); +const GetLogsFacetsInputSchema = Schema.Struct({ + service: Schema.optional(Schema.String), + severity: Schema.optional(Schema.String), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) -export type GetLogsFacetsInput = z.infer; +export type GetLogsFacetsInput = Schema.Schema.Type -export async function getLogsFacets({ +export function getLogsFacets({ data, }: { data: GetLogsFacetsInput -}): Promise { - data = GetLogsFacetsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.logs_facets({ - service: data.service, - severity: data.severity, - start_time: data.startTime, - end_time: data.endTime, - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetLogsFacetsInputSchema, data ?? {}, "getLogsFacets") + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("logs_facets", () => + tinybird.query.logs_facets({ + service: input.service, + severity: input.severity, + start_time: input.startTime, + end_time: input.endTime, + }), + ) - const services: FacetItem[] = []; - const severities: FacetItem[] = []; + const services: FacetItem[] = [] + const severities: FacetItem[] = [] for (const row of result.data) { - const count = Number(row.count); + const count = Number(row.count) if (row.facetType === "service" && row.serviceName) { - services.push({ name: row.serviceName, count }); + services.push({ name: row.serviceName, count }) } else if (row.facetType === "severity" && row.severityText) { - severities.push({ name: row.severityText, count }); + severities.push({ name: row.severityText, count }) } } return { data: { services, severities }, - error: null, - }; - } catch (error) { - console.error("[Tinybird] getLogsFacets failed:", error); - return { - data: { - services: [], - severities: [], - }, - error: - error instanceof Error ? error.message : "Failed to fetch log facets", - }; - } + } + }) } diff --git a/apps/web/src/api/tinybird/metrics.ts b/apps/web/src/api/tinybird/metrics.ts index e8f9f42..d078990 100644 --- a/apps/web/src/api/tinybird/metrics.ts +++ b/apps/web/src/api/tinybird/metrics.ts @@ -1,33 +1,52 @@ -import { z } from "zod"; -import { getTinybird, type ListMetricsOutput, type MetricTimeSeriesSumOutput, type MetricsSummaryOutput } from "@/lib/tinybird"; - -// Input validation schemas -const ListMetricsInput = z.object({ - limit: z.number().min(1).max(1000).optional(), - offset: z.number().min(0).optional(), - service: z.string().optional(), - metricType: z.enum(["sum", "gauge", "histogram", "exponential_histogram"]).optional(), - startTime: z.string().datetime().optional(), - endTime: z.string().datetime().optional(), - search: z.string().optional(), -}); - -export type ListMetricsInput = z.infer; +import { Effect, Schema } from "effect" +import { + getTinybird, + type ListMetricsOutput, + type MetricTimeSeriesSumOutput, + type MetricsSummaryOutput, +} from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + invalidTinybirdInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" + +const MetricTypeSchema = Schema.Literal( + "sum", + "gauge", + "histogram", + "exponential_histogram", +) + +const ListMetricsInputSchema = Schema.Struct({ + limit: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1), Schema.lessThanOrEqualTo(1000)), + ), + offset: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0))), + service: Schema.optional(Schema.String), + metricType: Schema.optional(MetricTypeSchema), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + search: Schema.optional(Schema.String), +}) + +export type ListMetricsInput = Schema.Schema.Type export interface Metric { - metricName: string; - metricType: string; - serviceName: string; - metricDescription: string; - metricUnit: string; - dataPointCount: number; - firstSeen: string; - lastSeen: string; + metricName: string + metricType: string + serviceName: string + metricDescription: string + metricUnit: string + dataPointCount: number + firstSeen: string + lastSeen: string } export interface MetricsResponse { - data: Metric[]; - error: string | null; + data: Metric[] } function transformMetric(raw: ListMetricsOutput): Metric { @@ -40,67 +59,61 @@ function transformMetric(raw: ListMetricsOutput): Metric { dataPointCount: Number(raw.dataPointCount), firstSeen: String(raw.firstSeen), lastSeen: String(raw.lastSeen), - }; + } } -export async function listMetrics({ +export function listMetrics({ data, }: { data: ListMetricsInput -}): Promise { - data = ListMetricsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - - const result = await tinybird.query.list_metrics({ - limit: data.limit, - offset: data.offset, - service: data.service, - metric_type: data.metricType, - start_time: data.startTime, - end_time: data.endTime, - search: data.search, - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(ListMetricsInputSchema, data ?? {}, "listMetrics") + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("list_metrics", () => + tinybird.query.list_metrics({ + limit: input.limit, + offset: input.offset, + service: input.service, + metric_type: input.metricType, + start_time: input.startTime, + end_time: input.endTime, + search: input.search, + }), + ) return { data: result.data.map(transformMetric), - error: null, - }; - } catch (error) { - console.error("[Tinybird] listMetrics failed:", error); - return { - data: [], - error: error instanceof Error ? error.message : "Failed to fetch metrics", - }; - } + } + }) } -// Time series input -const GetMetricTimeSeriesInput = z.object({ - metricName: z.string(), - metricType: z.enum(["sum", "gauge", "histogram", "exponential_histogram"]), - service: z.string().optional(), - startTime: z.string().datetime().optional(), - endTime: z.string().datetime().optional(), - bucketSeconds: z.number().min(1).optional(), -}); +const GetMetricTimeSeriesInputSchema = Schema.Struct({ + metricName: Schema.String, + metricType: MetricTypeSchema, + service: Schema.optional(Schema.String), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + bucketSeconds: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1)), + ), +}) -export type GetMetricTimeSeriesInput = z.infer; +export type GetMetricTimeSeriesInput = Schema.Schema.Type export interface MetricTimeSeriesPoint { - bucket: string; - serviceName: string; - avgValue: number; - minValue: number; - maxValue: number; - sumValue: number; - dataPointCount: number; + bucket: string + serviceName: string + avgValue: number + minValue: number + maxValue: number + sumValue: number + dataPointCount: number } export interface MetricTimeSeriesResponse { - data: MetricTimeSeriesPoint[]; - error: string | null; + data: MetricTimeSeriesPoint[] } function transformTimeSeriesPoint(raw: MetricTimeSeriesSumOutput): MetricTimeSeriesPoint { @@ -112,79 +125,110 @@ function transformTimeSeriesPoint(raw: MetricTimeSeriesSumOutput): MetricTimeSer maxValue: raw.maxValue, sumValue: raw.sumValue, dataPointCount: Number(raw.dataPointCount), - }; + } } -export async function getMetricTimeSeries({ +export function getMetricTimeSeries({ data, }: { data: GetMetricTimeSeriesInput -}): Promise { - data = GetMetricTimeSeriesInput.parse(data) - - try { - const tinybird = getTinybird(); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetMetricTimeSeriesInputSchema, + data, + "getMetricTimeSeries", + ) + const tinybird = getTinybird() const params = { - metric_name: data.metricName, - service: data.service, - start_time: data.startTime, - end_time: data.endTime, - bucket_seconds: data.bucketSeconds, - }; - - let result; - switch (data.metricType) { + metric_name: input.metricName, + service: input.service, + start_time: input.startTime, + end_time: input.endTime, + bucket_seconds: input.bucketSeconds, + } + + let operation = "" + let execute: + | (() => Effect.Effect<{ data: MetricTimeSeriesSumOutput[] }, unknown, any>) + | null = null + + switch (input.metricType) { case "sum": - result = await tinybird.query.metric_time_series_sum(params); - break; + operation = "metric_time_series_sum" + execute = () => + tinybird.query.metric_time_series_sum(params) as Effect.Effect< + { data: MetricTimeSeriesSumOutput[] }, + unknown, + any + > + break case "gauge": - result = await tinybird.query.metric_time_series_gauge(params); - break; + operation = "metric_time_series_gauge" + execute = () => + tinybird.query.metric_time_series_gauge(params) as Effect.Effect< + { data: MetricTimeSeriesSumOutput[] }, + unknown, + any + > + break case "histogram": - result = await tinybird.query.metric_time_series_histogram(params); - break; + operation = "metric_time_series_histogram" + execute = () => + tinybird.query.metric_time_series_histogram(params) as Effect.Effect< + { data: MetricTimeSeriesSumOutput[] }, + unknown, + any + > + break case "exponential_histogram": - result = await tinybird.query.metric_time_series_exp_histogram(params); - break; + operation = "metric_time_series_exp_histogram" + execute = () => + tinybird.query.metric_time_series_exp_histogram(params) as Effect.Effect< + { data: MetricTimeSeriesSumOutput[] }, + unknown, + any + > + break default: - return { - data: [], - error: `Unknown metric type: ${data.metricType}`, - }; + return yield* invalidTinybirdInput( + "getMetricTimeSeries", + `Unknown metric type: ${String(input.metricType)}`, + ) + } + + if (!execute) { + return yield* invalidTinybirdInput( + "getMetricTimeSeries", + `Unknown metric type: ${String(input.metricType)}`, + ) } + const result = yield* runTinybirdQuery(operation, execute) + return { data: result.data.map(transformTimeSeriesPoint), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getMetricTimeSeries failed:", error); - return { - data: [], - error: error instanceof Error ? error.message : "Failed to fetch metric time series", - }; - } + } + }) } -// Summary input -const GetMetricsSummaryInput = z.object({ - service: z.string().optional(), - startTime: z.string().datetime().optional(), - endTime: z.string().datetime().optional(), -}); +const GetMetricsSummaryInputSchema = Schema.Struct({ + service: Schema.optional(Schema.String), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) -export type GetMetricsSummaryInput = z.infer; +export type GetMetricsSummaryInput = Schema.Schema.Type export interface MetricTypeSummary { - metricType: string; - metricCount: number; - dataPointCount: number; + metricType: string + metricCount: number + dataPointCount: number } export interface MetricsSummaryResponse { - data: MetricTypeSummary[]; - error: string | null; + data: MetricTypeSummary[] } function transformSummary(raw: MetricsSummaryOutput): MetricTypeSummary { @@ -192,34 +236,32 @@ function transformSummary(raw: MetricsSummaryOutput): MetricTypeSummary { metricType: raw.metricType, metricCount: Number(raw.metricCount), dataPointCount: Number(raw.dataPointCount), - }; + } } -export async function getMetricsSummary({ +export function getMetricsSummary({ data, }: { data: GetMetricsSummaryInput -}): Promise { - data = GetMetricsSummaryInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetMetricsSummaryInputSchema, + data ?? {}, + "getMetricsSummary", + ) - const result = await tinybird.query.metrics_summary({ - service: data.service, - start_time: data.startTime, - end_time: data.endTime, - }); + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("metrics_summary", () => + tinybird.query.metrics_summary({ + service: input.service, + start_time: input.startTime, + end_time: input.endTime, + }), + ) return { data: result.data.map(transformSummary), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getMetricsSummary failed:", error); - return { - data: [], - error: error instanceof Error ? error.message : "Failed to fetch metrics summary", - }; - } + } + }) } diff --git a/apps/web/src/api/tinybird/query-builder-timeseries.ts b/apps/web/src/api/tinybird/query-builder-timeseries.ts index e5c7dae..6ddb5b8 100644 --- a/apps/web/src/api/tinybird/query-builder-timeseries.ts +++ b/apps/web/src/api/tinybird/query-builder-timeseries.ts @@ -1,7 +1,5 @@ -import { z } from "zod" import { Effect, Schema } from "effect" import { QueryEngineExecuteRequest, type QuerySpec } from "@maple/domain" -import { runtime } from "@/lib/services/common/runtime" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { formatForTinybird } from "@/lib/time-utils" import { @@ -14,10 +12,15 @@ import { buildTimeseriesQuerySpec, type QueryBuilderMetricType, } from "@/lib/query-builder/model" +import { + decodeInput, + type TinybirdApiError, + TinybirdApiError as TinybirdApiErrorClass, +} from "@/api/tinybird/effect-utils" -const dateTimeString = z - .string() - .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, "Invalid datetime format") +const dateTimeString = Schema.String.pipe( + Schema.pattern(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/), +) const METRIC_TYPES_TUPLE = [ "sum", @@ -34,61 +37,63 @@ const DEFAULT_STRATEGY = { maxFallbackRangeSeconds: 31 * 24 * 60 * 60, } as const -const QueryDraftSchema = z.object({ - id: z.string(), - name: z.string(), - enabled: z.boolean(), - dataSource: z.enum(["traces", "logs", "metrics"]), - signalSource: z.enum(["default", "meter"]), - metricName: z.string(), - metricType: z.enum(METRIC_TYPES_TUPLE), - whereClause: z.string(), - aggregation: z.string(), - stepInterval: z.string(), - orderByDirection: z.enum(["desc", "asc"]), - addOns: z.object({ - groupBy: z.boolean(), - having: z.boolean(), - orderBy: z.boolean(), - limit: z.boolean(), - legend: z.boolean(), +const QueryDraftSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + enabled: Schema.Boolean, + dataSource: Schema.Literal("traces", "logs", "metrics"), + signalSource: Schema.Literal("default", "meter"), + metricName: Schema.String, + metricType: Schema.Literal(...METRIC_TYPES_TUPLE), + whereClause: Schema.String, + aggregation: Schema.String, + stepInterval: Schema.String, + orderByDirection: Schema.Literal("desc", "asc"), + addOns: Schema.Struct({ + groupBy: Schema.Boolean, + having: Schema.Boolean, + orderBy: Schema.Boolean, + limit: Schema.Boolean, + legend: Schema.Boolean, }), - groupBy: z.string(), - having: z.string(), - orderBy: z.string(), - limit: z.string(), - legend: z.string(), + groupBy: Schema.String, + having: Schema.String, + orderBy: Schema.String, + limit: Schema.String, + legend: Schema.String, }) -const FormulaSchema = z.object({ - id: z.string(), - name: z.string(), - expression: z.string(), - legend: z.string(), +const FormulaSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + expression: Schema.String, + legend: Schema.String, }) -const ComparisonSchema = z.object({ - mode: z.enum(COMPARISON_MODES).default("none"), - includePercentChange: z.boolean().default(true), +const ComparisonSchema = Schema.Struct({ + mode: Schema.optional(Schema.Literal(...COMPARISON_MODES)), + includePercentChange: Schema.optional(Schema.Boolean), }) -const StrategySchema = z.object({ - enableEmptyRangeFallback: z.boolean().optional(), - fallbackWindowSeconds: z.array(z.number().int().positive()).optional(), - maxFallbackRangeSeconds: z.number().int().positive().optional(), +const StrategySchema = Schema.Struct({ + enableEmptyRangeFallback: Schema.optional(Schema.Boolean), + fallbackWindowSeconds: Schema.optional( + Schema.mutable(Schema.Array(Schema.Number.pipe(Schema.int(), Schema.greaterThan(0)))), + ), + maxFallbackRangeSeconds: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.greaterThan(0))), }) -const QueryBuilderTimeseriesInput = z.object({ +const QueryBuilderTimeseriesInputSchema = Schema.Struct({ startTime: dateTimeString, endTime: dateTimeString, - queries: z.array(QueryDraftSchema).min(1), - formulas: z.array(FormulaSchema).optional(), - comparison: ComparisonSchema.optional(), - strategy: StrategySchema.optional(), - debug: z.boolean().optional(), + queries: Schema.mutable(Schema.Array(QueryDraftSchema)), + formulas: Schema.optional(Schema.mutable(Schema.Array(FormulaSchema))), + comparison: Schema.optional(ComparisonSchema), + strategy: Schema.optional(StrategySchema), + debug: Schema.optional(Schema.Boolean), }) -export type QueryBuilderTimeseriesInput = z.infer +export type QueryBuilderTimeseriesInput = Schema.Schema.Type interface QueryExecutionAttempt { startTime: string @@ -131,7 +136,6 @@ interface QueryBuilderTimeseriesDebug { export interface QueryBuilderTimeseriesResponse { data: Array> - error: string | null debug?: QueryBuilderTimeseriesDebug } @@ -302,11 +306,11 @@ async function executeTimeseriesQuery( query: spec, }) - const response = await runtime.runPromise( + const response = await Effect.runPromise( Effect.gen(function* () { const client = yield* MapleApiAtomClient return yield* client.queryEngine.execute({ payload }) - }), + }).pipe(Effect.provide(MapleApiAtomClient.layer)), ) if (response.result.kind !== "timeseries") { @@ -733,12 +737,9 @@ export const __testables = { appendPercentChangeSeries, } -export async function getQueryBuilderTimeseries({ - data, -}: { - data: QueryBuilderTimeseriesInput -}): Promise { - const input = QueryBuilderTimeseriesInput.parse(data) +async function getQueryBuilderTimeseriesInternal( + input: QueryBuilderTimeseriesInput, +): Promise { const formulas: FormulaDraft[] = (input.formulas ?? []).map((formula) => ({ id: formula.id, @@ -754,10 +755,7 @@ export async function getQueryBuilderTimeseries({ const enabledQueries = input.queries.filter((query) => query.enabled) if (enabledQueries.length === 0) { - return { - data: [], - error: "No enabled queries to run", - } + throw new Error("No enabled queries to run") } try { @@ -771,10 +769,7 @@ export async function getQueryBuilderTimeseries({ ) const successfulQueryCount = countSuccessfulQuerySeries(currentWindow.queryResults) if (successfulQueryCount === 0) { - return { - data: [], - error: noQueryDataMessage(currentWindow.queryResults), - } + throw new Error(noQueryDataMessage(currentWindow.queryResults)) } const allResults = currentWindow.allResults @@ -785,10 +780,7 @@ export async function getQueryBuilderTimeseries({ if (successfulCount === 0) { const firstError = allResults.find((result) => result.error)?.error - return { - data: [], - error: firstError ?? "No successful query results", - } + throw new Error(firstError ?? "No successful query results") } const displayNameById = toDisplayNameById([ @@ -881,17 +873,37 @@ export async function getQueryBuilderTimeseries({ return { data: mergedRows, - error: null, ...(input.debug === true ? { debug: debugInfo } : {}), } } catch (error) { - console.error("[QueryBuilder] getQueryBuilderTimeseries failed:", error) - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch query-builder timeseries", - } + throw error instanceof Error ? error : new Error("Failed to fetch query-builder timeseries") } } + +export function getQueryBuilderTimeseries({ + data, +}: { + data: QueryBuilderTimeseriesInput +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + QueryBuilderTimeseriesInputSchema, + data, + "getQueryBuilderTimeseries", + ) + + return yield* Effect.tryPromise({ + try: () => getQueryBuilderTimeseriesInternal(input), + catch: (cause) => + new TinybirdApiErrorClass({ + operation: "getQueryBuilderTimeseries", + stage: "query", + message: + cause instanceof Error + ? cause.message + : "Failed to fetch query-builder timeseries", + cause, + }), + }) + }) +} diff --git a/apps/web/src/api/tinybird/service-map.ts b/apps/web/src/api/tinybird/service-map.ts index 2f9759e..d2f000c 100644 --- a/apps/web/src/api/tinybird/service-map.ts +++ b/apps/web/src/api/tinybird/service-map.ts @@ -1,5 +1,12 @@ +import { Effect, Schema } from "effect" import { getTinybird, type ServiceDependenciesOutput } from "@/lib/tinybird" import { estimateThroughput } from "@/lib/sampling" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" export interface ServiceEdge { sourceService: string @@ -16,14 +23,15 @@ export interface ServiceEdge { export interface ServiceMapResponse { edges: ServiceEdge[] - error: string | null } -export interface GetServiceMapInput { - startTime?: string - endTime?: string - deploymentEnv?: string -} +const GetServiceMapInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + deploymentEnv: Schema.optional(Schema.String), +}) + +export type GetServiceMapInput = Schema.Schema.Type function transformEdge(row: ServiceDependenciesOutput, durationSeconds: number): ServiceEdge { const callCount = Number(row.callCount) @@ -49,24 +57,27 @@ function transformEdge(row: ServiceDependenciesOutput, durationSeconds: number): } } -export async function getServiceMap({ +export function getServiceMap({ data, }: { data: GetServiceMapInput -}): Promise { - try { +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetServiceMapInputSchema, data ?? {}, "getServiceMap") const tinybird = getTinybird() - const result = await tinybird.query.service_dependencies({ - start_time: data.startTime, - end_time: data.endTime, - deployment_env: data.deploymentEnv, - }) + const result = yield* runTinybirdQuery("service_dependencies", () => + tinybird.query.service_dependencies({ + start_time: input.startTime, + end_time: input.endTime, + deployment_env: input.deploymentEnv, + }), + ) - const startMs = data.startTime - ? new Date(data.startTime.replace(" ", "T") + "Z").getTime() + const startMs = input.startTime + ? new Date(input.startTime.replace(" ", "T") + "Z").getTime() : 0 - const endMs = data.endTime - ? new Date(data.endTime.replace(" ", "T") + "Z").getTime() + const endMs = input.endTime + ? new Date(input.endTime.replace(" ", "T") + "Z").getTime() : 0 const durationSeconds = startMs > 0 && endMs > 0 @@ -75,13 +86,6 @@ export async function getServiceMap({ return { edges: result.data.map((row) => transformEdge(row, durationSeconds)), - error: null, } - } catch (error) { - console.error("[Tinybird] getServiceMap failed:", error) - return { - edges: [], - error: error instanceof Error ? error.message : "Failed to fetch service map", - } - } + }) } diff --git a/apps/web/src/api/tinybird/service-usage.ts b/apps/web/src/api/tinybird/service-usage.ts index 1be6fb6..cf8d03e 100644 --- a/apps/web/src/api/tinybird/service-usage.ts +++ b/apps/web/src/api/tinybird/service-usage.ts @@ -1,55 +1,54 @@ -import { z } from "zod"; -import { getTinybird } from "@/lib/tinybird"; +import { Effect, Schema } from "effect" +import { getTinybird } from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" export interface ServiceUsage { - serviceName: string; - totalLogs: number; - totalTraces: number; - totalMetrics: number; - dataSizeBytes: number; - logSizeBytes: number; - traceSizeBytes: number; - metricSizeBytes: number; + serviceName: string + totalLogs: number + totalTraces: number + totalMetrics: number + dataSizeBytes: number + logSizeBytes: number + traceSizeBytes: number + metricSizeBytes: number } export interface ServiceUsageResponse { - data: ServiceUsage[]; - error: string | null; + data: ServiceUsage[] } -const dateTimeString = z - .string() - .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, "Invalid datetime format"); +const GetServiceUsageInput = Schema.Struct({ + service: Schema.optional(Schema.String), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) -const GetServiceUsageInput = z.object({ - service: z.string().optional(), - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), -}); +export type GetServiceUsageInput = Schema.Schema.Type -export type GetServiceUsageInput = z.infer; - -export async function getServiceUsage({ +export function getServiceUsage({ data, }: { data: GetServiceUsageInput -}): Promise { - data = GetServiceUsageInput.parse(data ?? {}) +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetServiceUsageInput, data ?? {}, "getServiceUsage") - try { - const tinybird = getTinybird(); - const result = await tinybird.query.get_service_usage({ - service: data.service, - start_time: data.startTime, - end_time: data.endTime, - }); + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("get_service_usage", () => + tinybird.query.get_service_usage({ + service: input.service, + start_time: input.startTime, + end_time: input.endTime, + }), + ) - // Handle empty results if (!result.data || result.data.length === 0) { - return { - data: [], - error: null, - }; + return { data: [] } } return { @@ -71,16 +70,6 @@ export async function getServiceUsage({ Number(row.totalHistogramMetricSizeBytes ?? 0) + Number(row.totalExpHistogramMetricSizeBytes ?? 0), })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getServiceUsage failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch service usage", - }; - } + } + }) } diff --git a/apps/web/src/api/tinybird/services.ts b/apps/web/src/api/tinybird/services.ts index cf5d951..dfd0b63 100644 --- a/apps/web/src/api/tinybird/services.ts +++ b/apps/web/src/api/tinybird/services.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { Effect, Schema } from "effect" import { getTinybird, type ServiceOverviewOutput, type ServicesFacetsOutput } from "@/lib/tinybird"; import { buildBucketTimeline, @@ -6,11 +6,15 @@ import { toIsoBucket, } from "@/api/tinybird/timeseries-utils"; import { estimateThroughput } from "@/lib/sampling"; +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" // Date format: "YYYY-MM-DD HH:mm:ss" (Tinybird/ClickHouse compatible) -const dateTimeString = z - .string() - .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, "Invalid datetime format"); +const dateTimeString = TinybirdDateTimeString // Service overview types export interface CommitBreakdown { @@ -35,17 +39,16 @@ export interface ServiceOverview { export interface ServiceOverviewResponse { data: ServiceOverview[]; - error: string | null; } -const GetServiceOverviewInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - environments: z.array(z.string()).optional(), - commitShas: z.array(z.string()).optional(), -}); +const GetServiceOverviewInput = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + environments: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + commitShas: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), +}) -export type GetServiceOverviewInput = z.infer; +export type GetServiceOverviewInput = Schema.Schema.Type interface CoercedRow { serviceName: string; @@ -158,48 +161,40 @@ function aggregateByServiceEnvironment( return results; } -export async function getServiceOverview({ +export function getServiceOverview({ data, }: { data: GetServiceOverviewInput -}): Promise { - data = GetServiceOverviewInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.service_overview({ - start_time: data.startTime, - end_time: data.endTime, - environments: data.environments?.join(","), - commit_shas: data.commitShas?.join(","), - }); - - const startMs = data.startTime - ? new Date(data.startTime.replace(" ", "T") + "Z").getTime() - : 0; - const endMs = data.endTime - ? new Date(data.endTime.replace(" ", "T") + "Z").getTime() - : 0; +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetServiceOverviewInput, data ?? {}, "getServiceOverview") + + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("service_overview", () => + tinybird.query.service_overview({ + start_time: input.startTime, + end_time: input.endTime, + environments: input.environments?.join(","), + commit_shas: input.commitShas?.join(","), + }), + ) + + const startMs = input.startTime + ? new Date(input.startTime.replace(" ", "T") + "Z").getTime() + : 0 + const endMs = input.endTime + ? new Date(input.endTime.replace(" ", "T") + "Z").getTime() + : 0 const durationSeconds = startMs > 0 && endMs > 0 ? Math.max((endMs - startMs) / 1000, 1) - : 3600; // fallback to 1 hour + : 3600 - const coercedRows = result.data.map(coerceRow); + const coercedRows = result.data.map(coerceRow) return { data: aggregateByServiceEnvironment(coercedRows, durationSeconds), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getServiceOverview failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch service overview", - }; - } + } + }) } // Service overview time series types @@ -211,7 +206,6 @@ export interface ServiceTimeSeriesPoint { export interface ServiceOverviewTimeSeriesResponse { data: Record; - error: string | null; } function sortByBucket(rows: T[]): T[] { @@ -261,15 +255,14 @@ export interface ServicesFacets { export interface ServicesFacetsResponse { data: ServicesFacets; - error: string | null; } -const GetServicesFacetsInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), -}); +const GetServicesFacetsInput = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), +}) -export type GetServicesFacetsInput = z.infer; +export type GetServicesFacetsInput = Schema.Schema.Type function transformServicesFacets(facetsData: ServicesFacetsOutput[]): ServicesFacets { const environments: FacetItem[] = []; @@ -290,37 +283,25 @@ function transformServicesFacets(facetsData: ServicesFacetsOutput[]): ServicesFa return { environments, commitShas }; } -export async function getServicesFacets({ +export function getServicesFacets({ data, }: { data: GetServicesFacetsInput -}): Promise { - data = GetServicesFacetsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.services_facets({ - start_time: data.startTime, - end_time: data.endTime, - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetServicesFacetsInput, data ?? {}, "getServicesFacets") + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("services_facets", () => + tinybird.query.services_facets({ + start_time: input.startTime, + end_time: input.endTime, + }), + ) return { data: transformServicesFacets(result.data), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getServicesFacets failed:", error); - return { - data: { - environments: [], - commitShas: [], - }, - error: - error instanceof Error - ? error.message - : "Failed to fetch services facets", - }; - } + } + }) } // Service detail types @@ -335,7 +316,6 @@ export interface ServiceDetailTimeSeriesPoint { export interface ServiceDetailTimeSeriesResponse { data: ServiceDetailTimeSeriesPoint[]; - error: string | null; } export interface ServiceApdexTimeSeriesPoint { @@ -346,57 +326,47 @@ export interface ServiceApdexTimeSeriesPoint { export interface ServiceApdexTimeSeriesResponse { data: ServiceApdexTimeSeriesPoint[]; - error: string | null; } -const GetServiceDetailInput = z.object({ - serviceName: z.string(), - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), -}); +const GetServiceDetailInput = Schema.Struct({ + serviceName: Schema.String, + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), +}) -export type GetServiceDetailInput = z.infer; +export type GetServiceDetailInput = Schema.Schema.Type -export async function getServiceApdexTimeSeries({ +export function getServiceApdexTimeSeries({ data, }: { data: GetServiceDetailInput -}): Promise { - data = GetServiceDetailInput.parse(data) - - try { - const tinybird = getTinybird(); - const bucketSeconds = computeBucketSeconds(data.startTime, data.endTime); - const result = await tinybird.query.service_apdex_time_series({ - service_name: data.serviceName, - start_time: data.startTime, - end_time: data.endTime, - bucket_seconds: bucketSeconds, - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetServiceDetailInput, data, "getServiceApdexTimeSeries") + const tinybird = getTinybird() + const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) + const result = yield* runTinybirdQuery("service_apdex_time_series", () => + tinybird.query.service_apdex_time_series({ + service_name: input.serviceName, + start_time: input.startTime, + end_time: input.endTime, + bucket_seconds: bucketSeconds, + }), + ) const points = result.data.map((row) => ({ bucket: toIsoBucket(row.bucket), apdexScore: Number(row.apdexScore), totalCount: Number(row.totalCount), - })); + })) return { data: fillServiceApdexPoints( points, - data.startTime, - data.endTime, + input.startTime, + input.endTime, bucketSeconds, ), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getServiceApdexTimeSeries failed:", error); - return { - data: [], - error: - error instanceof Error - ? error.message - : "Failed to fetch service apdex time series", - }; - } + } + }) } diff --git a/apps/web/src/api/tinybird/timeseries-adapters.test.ts b/apps/web/src/api/tinybird/timeseries-adapters.test.ts index 23746e9..5e7adb6 100644 --- a/apps/web/src/api/tinybird/timeseries-adapters.test.ts +++ b/apps/web/src/api/tinybird/timeseries-adapters.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest" +import { Effect } from "effect" import { getCustomChartServiceDetail, getCustomChartServiceSparklines, @@ -25,7 +26,7 @@ describe("timeseries adapters", () => { }) it("fills overview/detail buckets without flattening existing points", async () => { - customTracesTimeseriesMock.mockResolvedValue({ + customTracesTimeseriesMock.mockReturnValue(Effect.succeed({ data: [ { bucket: "2026-01-01 00:00:00", @@ -37,24 +38,22 @@ describe("timeseries adapters", () => { p99Duration: 30, }, ], - }) + })) - const overview = await getOverviewTimeSeries({ + const overview = await Effect.runPromise(getOverviewTimeSeries({ data: { startTime: "2026-01-01 00:00:00", endTime: "2026-01-01 00:05:00", }, - }) - const detail = await getCustomChartServiceDetail({ + })) + const detail = await Effect.runPromise(getCustomChartServiceDetail({ data: { serviceName: "checkout", startTime: "2026-01-01 00:00:00", endTime: "2026-01-01 00:05:00", }, - }) + })) - expect(overview.error).toBeNull() - expect(detail.error).toBeNull() expect(overview.data).toHaveLength(6) expect(detail.data).toHaveLength(6) expect(overview.data[0]).toMatchObject({ @@ -75,7 +74,7 @@ describe("timeseries adapters", () => { }) it("fills service sparklines per service across the selected timeline", async () => { - customTracesTimeseriesMock.mockResolvedValue({ + customTracesTimeseriesMock.mockReturnValue(Effect.succeed({ data: [ { bucket: "2026-01-01 00:00:00", @@ -90,16 +89,15 @@ describe("timeseries adapters", () => { errorRate: 0, }, ], - }) + })) - const response = await getCustomChartServiceSparklines({ + const response = await Effect.runPromise(getCustomChartServiceSparklines({ data: { startTime: "2026-01-01 00:00:00", endTime: "2026-01-01 00:02:00", }, - }) + })) - expect(response.error).toBeNull() expect(response.data.checkout).toHaveLength(3) expect(response.data.checkout[0]).toMatchObject({ bucket: "2026-01-01T00:00:00.000Z", @@ -119,7 +117,7 @@ describe("timeseries adapters", () => { }) it("fills service apdex buckets while preserving real values", async () => { - serviceApdexTimeseriesMock.mockResolvedValue({ + serviceApdexTimeseriesMock.mockReturnValue(Effect.succeed({ data: [ { bucket: "2026-01-01 00:00:00", @@ -127,17 +125,16 @@ describe("timeseries adapters", () => { totalCount: 100, }, ], - }) + })) - const response = await getServiceApdexTimeSeries({ + const response = await Effect.runPromise(getServiceApdexTimeSeries({ data: { serviceName: "checkout", startTime: "2026-01-01 00:00:00", endTime: "2026-01-01 00:05:00", }, - }) + })) - expect(response.error).toBeNull() expect(response.data).toHaveLength(6) expect(response.data[0]).toMatchObject({ bucket: "2026-01-01T00:00:00.000Z", diff --git a/apps/web/src/api/tinybird/traces.ts b/apps/web/src/api/tinybird/traces.ts index 194e093..a9a860b 100644 --- a/apps/web/src/api/tinybird/traces.ts +++ b/apps/web/src/api/tinybird/traces.ts @@ -1,51 +1,58 @@ -import { z } from "zod"; -import { getTinybird, type ListTracesOutput, type SpanHierarchyOutput, type TracesFacetsOutput, type TracesDurationStatsOutput } from "@/lib/tinybird"; - -// Input validation schemas -// Date format: "YYYY-MM-DD HH:mm:ss" (Tinybird/ClickHouse compatible) -const dateTimeString = z.string().regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, "Invalid datetime format") - -const ListTracesInput = z.object({ - limit: z.number().min(1).max(1000).optional(), - offset: z.number().min(0).optional(), - service: z.string().optional(), - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - spanName: z.string().optional(), - hasError: z.boolean().optional(), - minDurationMs: z.number().optional(), - maxDurationMs: z.number().optional(), - httpMethod: z.string().optional(), - httpStatusCode: z.string().optional(), - deploymentEnv: z.string().optional(), - rootOnly: z.boolean().optional(), -}); - -export type ListTracesInput = z.infer; - -// Default values applied at runtime -const DEFAULT_LIMIT = 100; -const DEFAULT_OFFSET = 0; - -// Transformed trace for client use +import { Effect, Schema } from "effect" +import { + getTinybird, + type ListTracesOutput, + type SpanHierarchyOutput, + type TracesDurationStatsOutput, + type TracesFacetsOutput, +} from "@/lib/tinybird" +import { + TinybirdDateTimeString, + decodeInput, + runTinybirdQuery, + type TinybirdApiError, +} from "@/api/tinybird/effect-utils" + +const ListTracesInputSchema = Schema.Struct({ + limit: Schema.optional( + Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(1), Schema.lessThanOrEqualTo(1000)), + ), + offset: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.greaterThanOrEqualTo(0))), + service: Schema.optional(Schema.String), + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + spanName: Schema.optional(Schema.String), + hasError: Schema.optional(Schema.Boolean), + minDurationMs: Schema.optional(Schema.Number), + maxDurationMs: Schema.optional(Schema.Number), + httpMethod: Schema.optional(Schema.String), + httpStatusCode: Schema.optional(Schema.String), + deploymentEnv: Schema.optional(Schema.String), + rootOnly: Schema.optional(Schema.Boolean), +}) + +export type ListTracesInput = Schema.Schema.Type + +const DEFAULT_LIMIT = 100 +const DEFAULT_OFFSET = 0 + export interface Trace { - traceId: string; - startTime: string; - endTime: string; - durationMs: number; - spanCount: number; - services: string[]; - rootSpanName: string; - hasError: boolean; + traceId: string + startTime: string + endTime: string + durationMs: number + spanCount: number + services: string[] + rootSpanName: string + hasError: boolean } export interface TracesResponse { - data: Trace[]; + data: Trace[] meta: { - limit: number; - offset: number; - }; - error: string | null; + limit: number + offset: number + } } function transformTrace(raw: ListTracesOutput): Trace { @@ -58,94 +65,87 @@ function transformTrace(raw: ListTracesOutput): Trace { services: raw.services, rootSpanName: raw.rootSpanName, hasError: Number(raw.hasError) === 1, - }; + } } -export async function listTraces({ +export function listTraces({ data, }: { data: ListTracesInput -}): Promise { - data = ListTracesInput.parse(data ?? {}) - const limit = data.limit ?? DEFAULT_LIMIT; - const offset = data.offset ?? DEFAULT_OFFSET; - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.list_traces({ - limit, - offset, - service: data.service, - start_time: data.startTime, - end_time: data.endTime, - span_name: data.spanName, - has_error: data.hasError, - min_duration_ms: data.minDurationMs, - max_duration_ms: data.maxDurationMs, - http_method: data.httpMethod, - http_status_code: data.httpStatusCode, - deployment_env: data.deploymentEnv, - root_only: data.rootOnly, - }); - - return { - data: result.data.map(transformTrace), - meta: { +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(ListTracesInputSchema, data ?? {}, "listTraces") + const limit = input.limit ?? DEFAULT_LIMIT + const offset = input.offset ?? DEFAULT_OFFSET + + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("list_traces", () => + tinybird.query.list_traces({ limit, offset, - }, - error: null, - }; - } catch (error) { - console.error("[Tinybird] listTraces failed:", error); + service: input.service, + start_time: input.startTime, + end_time: input.endTime, + span_name: input.spanName, + has_error: input.hasError, + min_duration_ms: input.minDurationMs, + max_duration_ms: input.maxDurationMs, + http_method: input.httpMethod, + http_status_code: input.httpStatusCode, + deployment_env: input.deploymentEnv, + root_only: input.rootOnly, + }), + ) + return { - data: [], + data: result.data.map(transformTrace), meta: { limit, offset, }, - error: - error instanceof Error ? error.message : "Failed to fetch traces", - }; - } + } + }) } -// Transformed span for client use export interface Span { - traceId: string; - spanId: string; - parentSpanId: string; - spanName: string; - serviceName: string; - spanKind: string; - durationMs: number; - startTime: string; - statusCode: string; - statusMessage: string; - spanAttributes: Record; - resourceAttributes: Record; + traceId: string + spanId: string + parentSpanId: string + spanName: string + serviceName: string + spanKind: string + durationMs: number + startTime: string + statusCode: string + statusMessage: string + spanAttributes: Record + resourceAttributes: Record } -// Span with children for tree structure export interface SpanNode extends Span { - children: SpanNode[]; - depth: number; + children: SpanNode[] + depth: number } export interface SpanHierarchyResponse { - traceId: string; - spans: Span[]; - rootSpans: SpanNode[]; - totalDurationMs: number; - error: string | null; + traceId: string + spans: Span[] + rootSpans: SpanNode[] + totalDurationMs: number } -const GetSpanHierarchyInput = z.object({ - traceId: z.string().min(1, "traceId is required"), - spanId: z.string().optional(), -}); +const GetSpanHierarchyInputSchema = Schema.Struct({ + traceId: Schema.String.pipe(Schema.minLength(1)), + spanId: Schema.optional(Schema.String), +}) -export type GetSpanHierarchyInput = z.infer; +export type GetSpanHierarchyInput = Schema.Schema.Type + +function parseAttributes(value: string | null | undefined): Record { + if (!value) return {} + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" ? (parsed as Record) : {} +} function transformSpan(raw: SpanHierarchyOutput): Span { return { @@ -159,193 +159,167 @@ function transformSpan(raw: SpanHierarchyOutput): Span { startTime: String(raw.startTime), statusCode: raw.statusCode, statusMessage: raw.statusMessage, - spanAttributes: raw.spanAttributes ? JSON.parse(raw.spanAttributes) : {}, - resourceAttributes: raw.resourceAttributes ? JSON.parse(raw.resourceAttributes) : {}, - }; + spanAttributes: parseAttributes(raw.spanAttributes), + resourceAttributes: parseAttributes(raw.resourceAttributes), + } } function buildSpanTree(spans: Span[]): SpanNode[] { - const spanMap = new Map(); - const rootSpans: SpanNode[] = []; + const spanMap = new Map() + const rootSpans: SpanNode[] = [] - // Create nodes for all spans for (const span of spans) { - spanMap.set(span.spanId, { ...span, children: [], depth: 0 }); + spanMap.set(span.spanId, { ...span, children: [], depth: 0 }) } - // Build tree structure for (const span of spans) { - const node = spanMap.get(span.spanId)!; + const node = spanMap.get(span.spanId) + if (!node) { + continue + } if (span.parentSpanId && spanMap.has(span.parentSpanId)) { - const parent = spanMap.get(span.parentSpanId)!; - parent.children.push(node); + const parent = spanMap.get(span.parentSpanId) + parent?.children.push(node) } else { - rootSpans.push(node); + rootSpans.push(node) } } - // Set depths function setDepth(node: SpanNode, depth: number) { - node.depth = depth; + node.depth = depth for (const child of node.children) { - setDepth(child, depth + 1); + setDepth(child, depth + 1) } } for (const root of rootSpans) { - setDepth(root, 0); + setDepth(root, 0) } - // Sort children by start time function sortChildren(node: SpanNode) { - node.children.sort( - (a, b) => - new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); + node.children.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) for (const child of node.children) { - sortChildren(child); + sortChildren(child) } } for (const root of rootSpans) { - sortChildren(root); + sortChildren(root) } - // Sort root spans by start time - rootSpans.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() - ); - - return rootSpans; + rootSpans.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + return rootSpans } -// Server function to fetch span hierarchy -export async function getSpanHierarchy({ +export function getSpanHierarchy({ data, }: { data: GetSpanHierarchyInput -}): Promise { - data = GetSpanHierarchyInput.parse(data) - - try { - const tinybird = getTinybird(); - - const result = await tinybird.query.span_hierarchy({ - trace_id: data.traceId, - span_id: data.spanId, - }); - - const spans = result.data.map(transformSpan); - const rootSpans = buildSpanTree(spans); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetSpanHierarchyInputSchema, data, "getSpanHierarchy") + const tinybird = getTinybird() + + const result = yield* runTinybirdQuery("span_hierarchy", () => + tinybird.query.span_hierarchy({ + trace_id: input.traceId, + span_id: input.spanId, + }), + ) - const totalDurationMs = - spans.length > 0 ? Math.max(...spans.map((s) => s.durationMs)) : 0; + const spans = result.data.map(transformSpan) + const rootSpans = buildSpanTree(spans) + const totalDurationMs = spans.length > 0 ? Math.max(...spans.map((span) => span.durationMs)) : 0 return { - traceId: data.traceId, + traceId: input.traceId, spans, rootSpans, totalDurationMs, - error: null, - }; - } catch (error) { - console.error("[Tinybird] getSpanHierarchy failed:", error); - return { - traceId: data.traceId, - spans: [], - rootSpans: [], - totalDurationMs: 0, - error: - error instanceof Error - ? error.message - : "Failed to fetch span hierarchy", - }; - } + } + }) } -// Facets types export interface FacetItem { - name: string; - count: number; + name: string + count: number } export interface TracesFacets { - services: FacetItem[]; - spanNames: FacetItem[]; - httpMethods: FacetItem[]; - httpStatusCodes: FacetItem[]; - deploymentEnvs: FacetItem[]; - errorCount: number; + services: FacetItem[] + spanNames: FacetItem[] + httpMethods: FacetItem[] + httpStatusCodes: FacetItem[] + deploymentEnvs: FacetItem[] + errorCount: number durationStats: { - minDurationMs: number; - maxDurationMs: number; - p50DurationMs: number; - p95DurationMs: number; - }; + minDurationMs: number + maxDurationMs: number + p50DurationMs: number + p95DurationMs: number + } } export interface TracesFacetsResponse { - data: TracesFacets; - error: string | null; + data: TracesFacets } export interface TracesDurationStatsResponse { data: Array<{ - minDurationMs: number; - maxDurationMs: number; - p50DurationMs: number; - p95DurationMs: number; - }>; - error: string | null; + minDurationMs: number + maxDurationMs: number + p50DurationMs: number + p95DurationMs: number + }> } -const GetTracesFacetsInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - service: z.string().optional(), - spanName: z.string().optional(), - hasError: z.boolean().optional(), - minDurationMs: z.number().optional(), - maxDurationMs: z.number().optional(), - httpMethod: z.string().optional(), - httpStatusCode: z.string().optional(), - deploymentEnv: z.string().optional(), -}); - -export type GetTracesFacetsInput = z.infer; +const GetTracesFacetsInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + service: Schema.optional(Schema.String), + spanName: Schema.optional(Schema.String), + hasError: Schema.optional(Schema.Boolean), + minDurationMs: Schema.optional(Schema.Number), + maxDurationMs: Schema.optional(Schema.Number), + httpMethod: Schema.optional(Schema.String), + httpStatusCode: Schema.optional(Schema.String), + deploymentEnv: Schema.optional(Schema.String), +}) + +export type GetTracesFacetsInput = Schema.Schema.Type function transformFacets( facetsData: TracesFacetsOutput[], - durationStatsData: TracesDurationStatsOutput[] + durationStatsData: TracesDurationStatsOutput[], ): TracesFacets { - const services: FacetItem[] = []; - const spanNames: FacetItem[] = []; - const httpMethods: FacetItem[] = []; - const httpStatusCodes: FacetItem[] = []; - const deploymentEnvs: FacetItem[] = []; - let errorCount = 0; + const services: FacetItem[] = [] + const spanNames: FacetItem[] = [] + const httpMethods: FacetItem[] = [] + const httpStatusCodes: FacetItem[] = [] + const deploymentEnvs: FacetItem[] = [] + let errorCount = 0 for (const row of facetsData) { - const item = { name: row.name, count: Number(row.count) }; + const item = { name: row.name, count: Number(row.count) } switch (row.facetType) { case "service": - services.push(item); - break; + services.push(item) + break case "spanName": - spanNames.push(item); - break; + spanNames.push(item) + break case "httpMethod": - httpMethods.push(item); - break; + httpMethods.push(item) + break case "httpStatus": - httpStatusCodes.push(item); - break; + httpStatusCodes.push(item) + break case "deploymentEnv": - deploymentEnvs.push(item); - break; + deploymentEnvs.push(item) + break case "errorCount": - errorCount = Number(row.count); - break; + errorCount = Number(row.count) + break } } @@ -356,7 +330,7 @@ function transformFacets( p50DurationMs: Number(durationStatsData[0].p50DurationMs), p95DurationMs: Number(durationStatsData[0].p95DurationMs), } - : { minDurationMs: 0, maxDurationMs: 0, p50DurationMs: 0, p95DurationMs: 0 }; + : { minDurationMs: 0, maxDurationMs: 0, p50DurationMs: 0, p95DurationMs: 0 } return { services, @@ -366,85 +340,77 @@ function transformFacets( deploymentEnvs, errorCount, durationStats, - }; + } } -export async function getTracesFacets({ +export function getTracesFacets({ data, }: { data: GetTracesFacetsInput -}): Promise { - data = GetTracesFacetsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - - const [facetsResult, durationStatsResult] = await Promise.all([ - tinybird.query.traces_facets({ - start_time: data.startTime, - end_time: data.endTime, - service: data.service, - span_name: data.spanName, - has_error: data.hasError, - min_duration_ms: data.minDurationMs, - max_duration_ms: data.maxDurationMs, - http_method: data.httpMethod, - http_status_code: data.httpStatusCode, - deployment_env: data.deploymentEnv, - }), - tinybird.query.traces_duration_stats({ - start_time: data.startTime, - end_time: data.endTime, - service: data.service, - span_name: data.spanName, - has_error: data.hasError, - http_method: data.httpMethod, - http_status_code: data.httpStatusCode, - deployment_env: data.deploymentEnv, - }), - ]); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput(GetTracesFacetsInputSchema, data ?? {}, "getTracesFacets") + const tinybird = getTinybird() + + const [facetsResult, durationStatsResult] = yield* Effect.all([ + runTinybirdQuery("traces_facets", () => + tinybird.query.traces_facets({ + start_time: input.startTime, + end_time: input.endTime, + service: input.service, + span_name: input.spanName, + has_error: input.hasError, + min_duration_ms: input.minDurationMs, + max_duration_ms: input.maxDurationMs, + http_method: input.httpMethod, + http_status_code: input.httpStatusCode, + deployment_env: input.deploymentEnv, + }), + ), + runTinybirdQuery("traces_duration_stats", () => + tinybird.query.traces_duration_stats({ + start_time: input.startTime, + end_time: input.endTime, + service: input.service, + span_name: input.spanName, + has_error: input.hasError, + http_method: input.httpMethod, + http_status_code: input.httpStatusCode, + deployment_env: input.deploymentEnv, + }), + ), + ]) return { data: transformFacets(facetsResult.data, durationStatsResult.data), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getTracesFacets failed:", error); - return { - data: { - services: [], - spanNames: [], - httpMethods: [], - httpStatusCodes: [], - deploymentEnvs: [], - errorCount: 0, - durationStats: { minDurationMs: 0, maxDurationMs: 0, p50DurationMs: 0, p95DurationMs: 0 }, - }, - error: - error instanceof Error ? error.message : "Failed to fetch traces facets", - }; - } + } + }) } -export async function getTracesDurationStats({ +export function getTracesDurationStats({ data, }: { data: GetTracesFacetsInput -}): Promise { - data = GetTracesFacetsInput.parse(data ?? {}) - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.traces_duration_stats({ - start_time: data.startTime, - end_time: data.endTime, - service: data.service, - span_name: data.spanName, - has_error: data.hasError, - http_method: data.httpMethod, - http_status_code: data.httpStatusCode, - deployment_env: data.deploymentEnv, - }); +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetTracesFacetsInputSchema, + data ?? {}, + "getTracesDurationStats", + ) + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("traces_duration_stats", () => + tinybird.query.traces_duration_stats({ + start_time: input.startTime, + end_time: input.endTime, + service: input.service, + span_name: input.spanName, + has_error: input.hasError, + http_method: input.httpMethod, + http_status_code: input.httpStatusCode, + deployment_env: input.deploymentEnv, + }), + ) return { data: result.data.map((row) => ({ @@ -453,114 +419,91 @@ export async function getTracesDurationStats({ p50DurationMs: Number(row.p50DurationMs), p95DurationMs: Number(row.p95DurationMs), })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getTracesDurationStats failed:", error); - return { - data: [ - { - minDurationMs: 0, - maxDurationMs: 0, - p50DurationMs: 0, - p95DurationMs: 0, - }, - ], - error: error instanceof Error ? error.message : "Failed to fetch trace duration stats", - }; - } + } + }) } -// --- Span Attribute Keys --- - -const GetSpanAttributeKeysInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), -}); +const GetSpanAttributeKeysInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), +}) -export type GetSpanAttributeKeysInput = z.infer; +export type GetSpanAttributeKeysInput = Schema.Schema.Type export interface SpanAttributeKeysResponse { - data: Array<{ attributeKey: string; usageCount: number }>; - error: string | null; + data: Array<{ attributeKey: string; usageCount: number }> } -export async function getSpanAttributeKeys({ +export function getSpanAttributeKeys({ data, }: { - data: GetSpanAttributeKeysInput; -}): Promise { - data = GetSpanAttributeKeysInput.parse(data ?? {}); - - try { - const tinybird = getTinybird(); - const result = await tinybird.query.span_attribute_keys({ - start_time: data.startTime, - end_time: data.endTime, - }); + data: GetSpanAttributeKeysInput +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetSpanAttributeKeysInputSchema, + data ?? {}, + "getSpanAttributeKeys", + ) + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("span_attribute_keys", () => + tinybird.query.span_attribute_keys({ + start_time: input.startTime, + end_time: input.endTime, + }), + ) return { data: result.data.map((row) => ({ attributeKey: row.attributeKey, usageCount: Number(row.usageCount), })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getSpanAttributeKeys failed:", error); - return { - data: [], - error: error instanceof Error ? error.message : "Failed to fetch span attribute keys", - }; - } + } + }) } -// --- Span Attribute Values --- - -const GetSpanAttributeValuesInput = z.object({ - startTime: dateTimeString.optional(), - endTime: dateTimeString.optional(), - attributeKey: z.string(), -}); +const GetSpanAttributeValuesInputSchema = Schema.Struct({ + startTime: Schema.optional(TinybirdDateTimeString), + endTime: Schema.optional(TinybirdDateTimeString), + attributeKey: Schema.String, +}) -export type GetSpanAttributeValuesInput = z.infer; +export type GetSpanAttributeValuesInput = Schema.Schema.Type export interface SpanAttributeValuesResponse { - data: Array<{ attributeValue: string; usageCount: number }>; - error: string | null; + data: Array<{ attributeValue: string; usageCount: number }> } -export async function getSpanAttributeValues({ +export function getSpanAttributeValues({ data, }: { - data: GetSpanAttributeValuesInput; -}): Promise { - data = GetSpanAttributeValuesInput.parse(data ?? {}); - - if (!data.attributeKey) { - return { data: [], error: null }; - } + data: GetSpanAttributeValuesInput +}): Effect.Effect { + return Effect.gen(function* () { + const input = yield* decodeInput( + GetSpanAttributeValuesInputSchema, + data ?? {}, + "getSpanAttributeValues", + ) + + if (!input.attributeKey) { + return { data: [] } + } - try { - const tinybird = getTinybird(); - const result = await tinybird.query.span_attribute_values({ - start_time: data.startTime, - end_time: data.endTime, - attribute_key: data.attributeKey, - }); + const tinybird = getTinybird() + const result = yield* runTinybirdQuery("span_attribute_values", () => + tinybird.query.span_attribute_values({ + start_time: input.startTime, + end_time: input.endTime, + attribute_key: input.attributeKey, + }), + ) return { data: result.data.map((row) => ({ attributeValue: row.attributeValue, usageCount: Number(row.usageCount), })), - error: null, - }; - } catch (error) { - console.error("[Tinybird] getSpanAttributeValues failed:", error); - return { - data: [], - error: error instanceof Error ? error.message : "Failed to fetch span attribute values", - }; - } + } + }) } diff --git a/apps/web/src/components/dashboard-builder/data-source-registry.ts b/apps/web/src/components/dashboard-builder/data-source-registry.ts index ed0bf64..41da8b6 100644 --- a/apps/web/src/components/dashboard-builder/data-source-registry.ts +++ b/apps/web/src/components/dashboard-builder/data-source-registry.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect" import type { DataSourceEndpoint } from "@/components/dashboard-builder/types" import { getServiceUsage } from "@/api/tinybird/service-usage" @@ -25,7 +26,7 @@ import { import { getQueryBuilderTimeseries } from "@/api/tinybird/query-builder-timeseries" // eslint-disable-next-line @typescript-eslint/no-explicit-any -type ServerFunction = (opts: { data: any }) => Promise +type ServerFunction = (opts: { data: any }) => Effect.Effect export const serverFunctionMap: Record = { service_usage: getServiceUsage, diff --git a/apps/web/src/hooks/use-widget-data.ts b/apps/web/src/hooks/use-widget-data.ts index 9a9344f..8dcfde3 100644 --- a/apps/web/src/hooks/use-widget-data.ts +++ b/apps/web/src/hooks/use-widget-data.ts @@ -228,46 +228,35 @@ function encodeKey(value: unknown): string { return JSON.stringify(normalized === undefined ? null : normalized) } -async function parseErrorField(output: Output): Promise { - if (output && typeof output === "object" && "error" in output) { - const errorValue = (output as { error?: unknown }).error - if (typeof errorValue === "string" && errorValue.length > 0) { - throw new WidgetDataAtomError({ - message: errorValue, - }) - } - } - - return output -} - const widgetDataResultFamily = Atom.family((key: string) => Atom.make( - Effect.tryPromise({ - try: async () => { - const { - endpoint, - params, - transform, - } = JSON.parse(key) as { + Effect.try({ + try: () => + JSON.parse(key) as { endpoint: DashboardWidget["dataSource"]["endpoint"] params: Record transform: WidgetDataSource["transform"] - } - + }, + catch: toWidgetDataAtomError, + }).pipe( + Effect.flatMap(({ endpoint, params, transform }) => { const serverFn = serverFunctionMap[endpoint] if (!serverFn) { - throw new WidgetDataAtomError({ - message: `Unknown endpoint: ${endpoint}`, - }) + return Effect.fail( + new WidgetDataAtomError({ + message: `Unknown endpoint: ${endpoint}`, + }), + ) } - const response = await parseErrorField(await serverFn({ data: params })) - const rawData = response?.data ?? response - return applyTransform(rawData, transform) - }, - catch: toWidgetDataAtomError, - }).pipe( + return serverFn({ data: params }).pipe( + Effect.map((response) => { + const rawData = response?.data ?? response + return applyTransform(rawData, transform) + }), + ) + }), + Effect.mapError(toWidgetDataAtomError), Effect.retry(Schedule.recurs(1)), ), ).pipe(Atom.setIdleTTL(30_000)), 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 0a51909..0f0997f 100644 --- a/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/tinybird-query-atoms.ts @@ -23,7 +23,8 @@ import { } from "@/api/tinybird/services" import { getSpanAttributeKeys, getSpanAttributeValues, getSpanHierarchy, getTracesFacets, listTraces } from "@/api/tinybird/traces" import { getQueryBuilderTimeseries } from "@/api/tinybird/query-builder-timeseries" -type AsyncQuery = (input: Input) => Promise + +type QueryEffect = (input: Input) => Effect.Effect interface QueryAtomOptions { staleTime?: number @@ -75,33 +76,19 @@ function encodeKey(value: unknown): string { return JSON.stringify(normalized === undefined ? null : normalized) } -async function parseErrorField(output: Output): Promise { - if (output && typeof output === "object" && "error" in output) { - const errorValue = (output as { error?: unknown }).error - if (typeof errorValue === "string" && errorValue.length > 0) { - throw new QueryAtomError({ - message: errorValue, - }) - } - } - - return output -} - function makeQueryAtomFamily( - query: AsyncQuery, + query: QueryEffect, options?: QueryAtomOptions, ) { const family = Atom.family((key: string) => { let resultAtom = Atom.make( - Effect.tryPromise({ - try: async () => { - const input = JSON.parse(key) as Input - const output = await query(input) - return parseErrorField(output) - }, + Effect.try({ + try: () => JSON.parse(key) as Input, catch: toQueryAtomError, - }), + }).pipe( + Effect.flatMap((input) => query(input)), + Effect.mapError(toQueryAtomError), + ), ) if (options?.staleTime !== undefined) { diff --git a/apps/web/src/lib/tinybird.ts b/apps/web/src/lib/tinybird.ts index 2e778d2..d424fd4 100644 --- a/apps/web/src/lib/tinybird.ts +++ b/apps/web/src/lib/tinybird.ts @@ -2,7 +2,6 @@ import type { TinybirdPipe } from "@maple/domain" import { Effect } from "effect" import { MapleApiAtomClient } from "./services/common/atom-client" import { setMapleAuthHeaders } from "./services/common/auth-headers" -import { runtime } from "./services/common/runtime" import type { CustomLogsBreakdownOutput, @@ -107,17 +106,15 @@ type QueryResponse = { export { setMapleAuthHeaders } const queryTinybird = (pipe: TinybirdPipe, params?: Record) => - runtime.runPromise( - Effect.gen(function* () { - const client = yield* MapleApiAtomClient - return (yield* client.tinybird.query({ - payload: { - pipe, - params, - }, - })) as QueryResponse - }), - ) + Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return (yield* client.tinybird.query({ + payload: { + pipe, + params, + }, + })) as QueryResponse + }) const query = { list_traces: (params?: Record) => diff --git a/bun.lock b/bun.lock index a1d1b41..8a75f81 100644 --- a/bun.lock +++ b/bun.lock @@ -105,7 +105,6 @@ "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "web-vitals": "^5.1.0", - "zod": "^3.25.76", }, "devDependencies": { "@cloudflare/vite-plugin": "^1.21.2", From 8200666f6883058e9e48b78ecf6083dfcf380fa1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 19 Feb 2026 14:22:04 +0100 Subject: [PATCH 4/4] feat: imprve tinybird query usage --- apps/web/src/api/tinybird/custom-charts.ts | 92 +++++++++++++------ apps/web/src/api/tinybird/error-rates.ts | 17 ++-- apps/web/src/api/tinybird/errors.ts | 57 ++++++++---- apps/web/src/api/tinybird/logs.ts | 43 ++++++--- apps/web/src/api/tinybird/metrics.ts | 43 ++++++--- .../api/tinybird/query-builder-timeseries.ts | 29 ++++-- apps/web/src/api/tinybird/service-map.ts | 17 ++-- apps/web/src/api/tinybird/service-usage.ts | 17 ++-- apps/web/src/api/tinybird/services.ts | 45 ++++++--- apps/web/src/api/tinybird/traces.ts | 87 +++++++++++++----- apps/web/src/lib/tinybird.ts | 11 ++- 11 files changed, 312 insertions(+), 146 deletions(-) diff --git a/apps/web/src/api/tinybird/custom-charts.ts b/apps/web/src/api/tinybird/custom-charts.ts index 8b132a8..2665c18 100644 --- a/apps/web/src/api/tinybird/custom-charts.ts +++ b/apps/web/src/api/tinybird/custom-charts.ts @@ -17,8 +17,6 @@ import { } from "@/api/tinybird/effect-utils" import type { ServiceDetailTimeSeriesPoint, - ServiceDetailTimeSeriesResponse, - ServiceOverviewTimeSeriesResponse, ServiceTimeSeriesPoint, } from "@/api/tinybird/services" @@ -144,10 +142,7 @@ const metricsMetrics = new Set(["avg", "sum", "min", "max", "coun const metricsBreakdownMetrics = new Set<"avg" | "sum" | "count">(["avg", "sum", "count"]) function executeQueryEngine(payload: QueryEngineExecuteRequest) { - return Effect.gen(function* () { - const client = yield* MapleApiAtomClient - return yield* client.queryEngine.execute({ payload }) - }).pipe( + return executeQueryEngineEffect(payload).pipe( Effect.provide(MapleApiAtomClient.layer), Effect.mapError( (cause) => @@ -161,6 +156,13 @@ function executeQueryEngine(payload: QueryEngineExecuteRequest) { ) } +const executeQueryEngineEffect = Effect.fn("Tinybird.executeQueryEngine")( + function* (payload: QueryEngineExecuteRequest) { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.execute({ payload }) + }, +) + function buildTimeseriesQuerySpec(data: CustomChartTimeSeriesInput): QuerySpec | string { if (data.source === "traces") { if (!tracesMetrics.has(data.metric as TracesMetric)) { @@ -249,8 +251,16 @@ export function getCustomChartTimeSeries({ data, }: { data: CustomChartTimeSeriesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getCustomChartTimeSeriesEffect({ data }) +} + +const getCustomChartTimeSeriesEffect = Effect.fn("Tinybird.getCustomChartTimeSeries")( + function* ({ + data, + }: { + data: CustomChartTimeSeriesInput + }) { const input = yield* decodeInput( CustomChartTimeSeriesInputSchema, data, @@ -286,8 +296,8 @@ export function getCustomChartTimeSeries({ series: { ...point.series }, })), } - }) -} + }, +) const CustomChartBreakdownInputSchema = Schema.Struct({ source: Schema.Literal("traces", "logs", "metrics"), @@ -397,8 +407,16 @@ export function getCustomChartBreakdown({ data, }: { data: CustomChartBreakdownInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getCustomChartBreakdownEffect({ data }) +} + +const getCustomChartBreakdownEffect = Effect.fn("Tinybird.getCustomChartBreakdown")( + function* ({ + data, + }: { + data: CustomChartBreakdownInput + }) { const input = yield* decodeInput( CustomChartBreakdownInputSchema, data, @@ -434,8 +452,8 @@ export function getCustomChartBreakdown({ value: item.value, })), } - }) -} + }, +) const GetCustomChartServiceDetailInputSchema = Schema.Struct({ serviceName: Schema.String, @@ -449,8 +467,16 @@ export function getCustomChartServiceDetail({ data, }: { data: GetCustomChartServiceDetailInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getCustomChartServiceDetailEffect({ data }) +} + +const getCustomChartServiceDetailEffect = Effect.fn("Tinybird.getCustomChartServiceDetail")( + function* ({ + data, + }: { + data: GetCustomChartServiceDetailInput + }) { const input = yield* decodeInput( GetCustomChartServiceDetailInputSchema, data, @@ -483,8 +509,8 @@ export function getCustomChartServiceDetail({ return { data: fillServiceDetailPoints(points, input.startTime, input.endTime, bucketSeconds), } - }) -} + }, +) const GetOverviewTimeSeriesInputSchema = Schema.Struct({ startTime: Schema.optional(dateTimeString), @@ -498,8 +524,15 @@ export function getOverviewTimeSeries({ data, }: { data: GetOverviewTimeSeriesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getOverviewTimeSeriesEffect({ data }) +} + +const getOverviewTimeSeriesEffect = Effect.fn("Tinybird.getOverviewTimeSeries")(function* ({ + data, +}: { + data: GetOverviewTimeSeriesInput +}) { const input = yield* decodeInput( GetOverviewTimeSeriesInputSchema, data ?? {}, @@ -532,8 +565,7 @@ export function getOverviewTimeSeries({ return { data: fillServiceDetailPoints(points, input.startTime, input.endTime, bucketSeconds), } - }) -} +}) const GetCustomChartServiceSparklinesInputSchema = Schema.Struct({ startTime: Schema.optional(dateTimeString), @@ -550,8 +582,16 @@ export function getCustomChartServiceSparklines({ data, }: { data: GetCustomChartServiceSparklinesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getCustomChartServiceSparklinesEffect({ data }) +} + +const getCustomChartServiceSparklinesEffect = Effect.fn("Tinybird.getCustomChartServiceSparklines")( + function* ({ + data, + }: { + data: GetCustomChartServiceSparklinesInput + }) { const input = yield* decodeInput( GetCustomChartServiceSparklinesInputSchema, data ?? {}, @@ -595,5 +635,5 @@ export function getCustomChartServiceSparklines({ ) return { data: filledGrouped } - }) -} + }, +) diff --git a/apps/web/src/api/tinybird/error-rates.ts b/apps/web/src/api/tinybird/error-rates.ts index 43a51e1..d3865e1 100644 --- a/apps/web/src/api/tinybird/error-rates.ts +++ b/apps/web/src/api/tinybird/error-rates.ts @@ -4,7 +4,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" export interface ErrorRateByService { @@ -25,12 +24,12 @@ const GetErrorRateByServiceInput = Schema.Struct({ export type GetErrorRateByServiceInput = Schema.Schema.Type -export function getErrorRateByService({ - data, -}: { - data: GetErrorRateByServiceInput -}): Effect.Effect { - return Effect.gen(function* () { +export const getErrorRateByService = Effect.fn("Tinybird.getErrorRateByService")( + function* ({ + data, + }: { + data: GetErrorRateByServiceInput + }) { const input = yield* decodeInput( GetErrorRateByServiceInput, data ?? {}, @@ -53,5 +52,5 @@ export function getErrorRateByService({ errorRatePercent: Number(row.errorRatePercent), })), } - }) -} + }, +) diff --git a/apps/web/src/api/tinybird/errors.ts b/apps/web/src/api/tinybird/errors.ts index 88a7a55..2e0cafd 100644 --- a/apps/web/src/api/tinybird/errors.ts +++ b/apps/web/src/api/tinybird/errors.ts @@ -11,7 +11,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" const OptionalStringArray = Schema.optional(Schema.mutable(Schema.Array(Schema.String))) @@ -56,8 +55,15 @@ export function getErrorsByType({ data, }: { data: GetErrorsByTypeInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getErrorsByTypeEffect({ data }) +} + +const getErrorsByTypeEffect = Effect.fn("Tinybird.getErrorsByType")(function* ({ + data, +}: { + data: GetErrorsByTypeInput +}) { const input = yield* decodeInput(GetErrorsByTypeInputSchema, data ?? {}, "getErrorsByType") const tinybird = getTinybird() @@ -76,8 +82,7 @@ export function getErrorsByType({ return { data: result.data.map(transformErrorByType), } - }) -} +}) export interface FacetItem { name: string @@ -132,8 +137,15 @@ export function getErrorsFacets({ data, }: { data: GetErrorsFacetsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getErrorsFacetsEffect({ data }) +} + +const getErrorsFacetsEffect = Effect.fn("Tinybird.getErrorsFacets")(function* ({ + data, +}: { + data: GetErrorsFacetsInput +}) { const input = yield* decodeInput(GetErrorsFacetsInputSchema, data ?? {}, "getErrorsFacets") const tinybird = getTinybird() @@ -151,8 +163,7 @@ export function getErrorsFacets({ return { data: transformErrorsFacets(result.data), } - }) -} +}) export interface ErrorsSummary { totalErrors: number @@ -191,8 +202,15 @@ export function getErrorsSummary({ data, }: { data: GetErrorsSummaryInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getErrorsSummaryEffect({ data }) +} + +const getErrorsSummaryEffect = Effect.fn("Tinybird.getErrorsSummary")(function* ({ + data, +}: { + data: GetErrorsSummaryInput +}) { const input = yield* decodeInput(GetErrorsSummaryInputSchema, data ?? {}, "getErrorsSummary") const tinybird = getTinybird() @@ -211,8 +229,7 @@ export function getErrorsSummary({ return { data: summary ? transformErrorsSummary(summary) : null, } - }) -} +}) export interface ErrorDetailTrace { traceId: string @@ -255,8 +272,15 @@ export function getErrorDetailTraces({ data, }: { data: GetErrorDetailTracesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getErrorDetailTracesEffect({ data }) +} + +const getErrorDetailTracesEffect = Effect.fn("Tinybird.getErrorDetailTraces")(function* ({ + data, +}: { + data: GetErrorDetailTracesInput +}) { const input = yield* decodeInput( GetErrorDetailTracesInputSchema, data ?? {}, @@ -278,5 +302,4 @@ export function getErrorDetailTraces({ return { data: result.data.map(transformErrorDetailTrace), } - }) -} +}) diff --git a/apps/web/src/api/tinybird/logs.ts b/apps/web/src/api/tinybird/logs.ts index 9cedaaa..f975ef5 100644 --- a/apps/web/src/api/tinybird/logs.ts +++ b/apps/web/src/api/tinybird/logs.ts @@ -4,7 +4,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" const ListLogsInputSchema = Schema.Struct({ @@ -77,8 +76,15 @@ export function listLogs({ data, }: { data: ListLogsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return listLogsEffect({ data }) +} + +const listLogsEffect = Effect.fn("Tinybird.listLogs")(function* ({ + data, +}: { + data: ListLogsInput +}) { const input = yield* decodeInput(ListLogsInputSchema, data ?? {}, "listLogs") const limit = input.limit ?? DEFAULT_LIMIT const tinybird = getTinybird() @@ -122,15 +128,21 @@ export function listLogs({ cursor, }, } - }) -} +}) export function getLogsCount({ data, }: { data: ListLogsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getLogsCountEffect({ data }) +} + +const getLogsCountEffect = Effect.fn("Tinybird.getLogsCount")(function* ({ + data, +}: { + data: ListLogsInput +}) { const input = yield* decodeInput(ListLogsInputSchema, data ?? {}, "getLogsCount") const tinybird = getTinybird() @@ -148,8 +160,7 @@ export function getLogsCount({ return { data: [{ total: Number(countResult.data[0]?.total ?? 0) }], } - }) -} +}) export interface FacetItem { name: string @@ -178,8 +189,15 @@ export function getLogsFacets({ data, }: { data: GetLogsFacetsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getLogsFacetsEffect({ data }) +} + +const getLogsFacetsEffect = Effect.fn("Tinybird.getLogsFacets")(function* ({ + data, +}: { + data: GetLogsFacetsInput +}) { const input = yield* decodeInput(GetLogsFacetsInputSchema, data ?? {}, "getLogsFacets") const tinybird = getTinybird() @@ -207,5 +225,4 @@ export function getLogsFacets({ return { data: { services, severities }, } - }) -} +}) diff --git a/apps/web/src/api/tinybird/metrics.ts b/apps/web/src/api/tinybird/metrics.ts index d078990..049161e 100644 --- a/apps/web/src/api/tinybird/metrics.ts +++ b/apps/web/src/api/tinybird/metrics.ts @@ -10,7 +10,6 @@ import { decodeInput, invalidTinybirdInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" const MetricTypeSchema = Schema.Literal( @@ -66,8 +65,15 @@ export function listMetrics({ data, }: { data: ListMetricsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return listMetricsEffect({ data }) +} + +const listMetricsEffect = Effect.fn("Tinybird.listMetrics")(function* ({ + data, +}: { + data: ListMetricsInput +}) { const input = yield* decodeInput(ListMetricsInputSchema, data ?? {}, "listMetrics") const tinybird = getTinybird() @@ -86,8 +92,7 @@ export function listMetrics({ return { data: result.data.map(transformMetric), } - }) -} +}) const GetMetricTimeSeriesInputSchema = Schema.Struct({ metricName: Schema.String, @@ -132,8 +137,15 @@ export function getMetricTimeSeries({ data, }: { data: GetMetricTimeSeriesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getMetricTimeSeriesEffect({ data }) +} + +const getMetricTimeSeriesEffect = Effect.fn("Tinybird.getMetricTimeSeries")(function* ({ + data, +}: { + data: GetMetricTimeSeriesInput +}) { const input = yield* decodeInput( GetMetricTimeSeriesInputSchema, data, @@ -210,8 +222,7 @@ export function getMetricTimeSeries({ return { data: result.data.map(transformTimeSeriesPoint), } - }) -} +}) const GetMetricsSummaryInputSchema = Schema.Struct({ service: Schema.optional(Schema.String), @@ -243,8 +254,15 @@ export function getMetricsSummary({ data, }: { data: GetMetricsSummaryInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getMetricsSummaryEffect({ data }) +} + +const getMetricsSummaryEffect = Effect.fn("Tinybird.getMetricsSummary")(function* ({ + data, +}: { + data: GetMetricsSummaryInput +}) { const input = yield* decodeInput( GetMetricsSummaryInputSchema, data ?? {}, @@ -263,5 +281,4 @@ export function getMetricsSummary({ return { data: result.data.map(transformSummary), } - }) -} +}) diff --git a/apps/web/src/api/tinybird/query-builder-timeseries.ts b/apps/web/src/api/tinybird/query-builder-timeseries.ts index 6ddb5b8..56dcc3f 100644 --- a/apps/web/src/api/tinybird/query-builder-timeseries.ts +++ b/apps/web/src/api/tinybird/query-builder-timeseries.ts @@ -14,7 +14,6 @@ import { } from "@/lib/query-builder/model" import { decodeInput, - type TinybirdApiError, TinybirdApiError as TinybirdApiErrorClass, } from "@/api/tinybird/effect-utils" @@ -307,10 +306,7 @@ async function executeTimeseriesQuery( }) const response = await Effect.runPromise( - Effect.gen(function* () { - const client = yield* MapleApiAtomClient - return yield* client.queryEngine.execute({ payload }) - }).pipe(Effect.provide(MapleApiAtomClient.layer)), + executeTimeseriesQueryEffect(payload).pipe(Effect.provide(MapleApiAtomClient.layer)), ) if (response.result.kind !== "timeseries") { @@ -323,6 +319,13 @@ async function executeTimeseriesQuery( })) } +const executeTimeseriesQueryEffect = Effect.fn("Tinybird.executeTimeseriesQuery")( + function* (payload: QueryEngineExecuteRequest) { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.execute({ payload }) + }, +) + async function executeTimeseriesQueryWithFallback( startTime: string, endTime: string, @@ -884,8 +887,16 @@ export function getQueryBuilderTimeseries({ data, }: { data: QueryBuilderTimeseriesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getQueryBuilderTimeseriesEffect({ data }) +} + +const getQueryBuilderTimeseriesEffect = Effect.fn("Tinybird.getQueryBuilderTimeseries")( + function* ({ + data, + }: { + data: QueryBuilderTimeseriesInput + }) { const input = yield* decodeInput( QueryBuilderTimeseriesInputSchema, data, @@ -905,5 +916,5 @@ export function getQueryBuilderTimeseries({ cause, }), }) - }) -} + }, +) diff --git a/apps/web/src/api/tinybird/service-map.ts b/apps/web/src/api/tinybird/service-map.ts index d2f000c..c1a7cd2 100644 --- a/apps/web/src/api/tinybird/service-map.ts +++ b/apps/web/src/api/tinybird/service-map.ts @@ -5,7 +5,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" export interface ServiceEdge { @@ -57,12 +56,12 @@ function transformEdge(row: ServiceDependenciesOutput, durationSeconds: number): } } -export function getServiceMap({ - data, -}: { - data: GetServiceMapInput -}): Effect.Effect { - return Effect.gen(function* () { +export const getServiceMap = Effect.fn("Tinybird.getServiceMap")( + function* ({ + data, + }: { + data: GetServiceMapInput + }) { const input = yield* decodeInput(GetServiceMapInputSchema, data ?? {}, "getServiceMap") const tinybird = getTinybird() const result = yield* runTinybirdQuery("service_dependencies", () => @@ -87,5 +86,5 @@ export function getServiceMap({ return { edges: result.data.map((row) => transformEdge(row, durationSeconds)), } - }) -} + }, +) diff --git a/apps/web/src/api/tinybird/service-usage.ts b/apps/web/src/api/tinybird/service-usage.ts index cf8d03e..7fb6139 100644 --- a/apps/web/src/api/tinybird/service-usage.ts +++ b/apps/web/src/api/tinybird/service-usage.ts @@ -4,7 +4,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" export interface ServiceUsage { @@ -30,12 +29,12 @@ const GetServiceUsageInput = Schema.Struct({ export type GetServiceUsageInput = Schema.Schema.Type -export function getServiceUsage({ - data, -}: { - data: GetServiceUsageInput -}): Effect.Effect { - return Effect.gen(function* () { +export const getServiceUsage = Effect.fn("Tinybird.getServiceUsage")( + function* ({ + data, + }: { + data: GetServiceUsageInput + }) { const input = yield* decodeInput(GetServiceUsageInput, data ?? {}, "getServiceUsage") const tinybird = getTinybird() @@ -71,5 +70,5 @@ export function getServiceUsage({ Number(row.totalExpHistogramMetricSizeBytes ?? 0), })), } - }) -} + }, +) diff --git a/apps/web/src/api/tinybird/services.ts b/apps/web/src/api/tinybird/services.ts index dfd0b63..b909ab4 100644 --- a/apps/web/src/api/tinybird/services.ts +++ b/apps/web/src/api/tinybird/services.ts @@ -10,7 +10,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" // Date format: "YYYY-MM-DD HH:mm:ss" (Tinybird/ClickHouse compatible) @@ -165,8 +164,15 @@ export function getServiceOverview({ data, }: { data: GetServiceOverviewInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getServiceOverviewEffect({ data }) +} + +const getServiceOverviewEffect = Effect.fn("Tinybird.getServiceOverview")(function* ({ + data, +}: { + data: GetServiceOverviewInput +}) { const input = yield* decodeInput(GetServiceOverviewInput, data ?? {}, "getServiceOverview") const tinybird = getTinybird() @@ -194,8 +200,7 @@ export function getServiceOverview({ return { data: aggregateByServiceEnvironment(coercedRows, durationSeconds), } - }) -} +}) // Service overview time series types export interface ServiceTimeSeriesPoint { @@ -287,8 +292,15 @@ export function getServicesFacets({ data, }: { data: GetServicesFacetsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getServicesFacetsEffect({ data }) +} + +const getServicesFacetsEffect = Effect.fn("Tinybird.getServicesFacets")(function* ({ + data, +}: { + data: GetServicesFacetsInput +}) { const input = yield* decodeInput(GetServicesFacetsInput, data ?? {}, "getServicesFacets") const tinybird = getTinybird() const result = yield* runTinybirdQuery("services_facets", () => @@ -301,8 +313,7 @@ export function getServicesFacets({ return { data: transformServicesFacets(result.data), } - }) -} +}) // Service detail types export interface ServiceDetailTimeSeriesPoint { @@ -340,8 +351,16 @@ export function getServiceApdexTimeSeries({ data, }: { data: GetServiceDetailInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getServiceApdexTimeSeriesEffect({ data }) +} + +const getServiceApdexTimeSeriesEffect = Effect.fn("Tinybird.getServiceApdexTimeSeries")( + function* ({ + data, + }: { + data: GetServiceDetailInput + }) { const input = yield* decodeInput(GetServiceDetailInput, data, "getServiceApdexTimeSeries") const tinybird = getTinybird() const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) @@ -368,5 +387,5 @@ export function getServiceApdexTimeSeries({ bucketSeconds, ), } - }) -} + }, +) diff --git a/apps/web/src/api/tinybird/traces.ts b/apps/web/src/api/tinybird/traces.ts index a9a860b..dd770a7 100644 --- a/apps/web/src/api/tinybird/traces.ts +++ b/apps/web/src/api/tinybird/traces.ts @@ -10,7 +10,6 @@ import { TinybirdDateTimeString, decodeInput, runTinybirdQuery, - type TinybirdApiError, } from "@/api/tinybird/effect-utils" const ListTracesInputSchema = Schema.Struct({ @@ -72,8 +71,15 @@ export function listTraces({ data, }: { data: ListTracesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return listTracesEffect({ data }) +} + +const listTracesEffect = Effect.fn("Tinybird.listTraces")(function* ({ + data, +}: { + data: ListTracesInput +}) { const input = yield* decodeInput(ListTracesInputSchema, data ?? {}, "listTraces") const limit = input.limit ?? DEFAULT_LIMIT const offset = input.offset ?? DEFAULT_OFFSET @@ -104,8 +110,7 @@ export function listTraces({ offset, }, } - }) -} +}) export interface Span { traceId: string @@ -215,8 +220,15 @@ export function getSpanHierarchy({ data, }: { data: GetSpanHierarchyInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getSpanHierarchyEffect({ data }) +} + +const getSpanHierarchyEffect = Effect.fn("Tinybird.getSpanHierarchy")(function* ({ + data, +}: { + data: GetSpanHierarchyInput +}) { const input = yield* decodeInput(GetSpanHierarchyInputSchema, data, "getSpanHierarchy") const tinybird = getTinybird() @@ -237,8 +249,7 @@ export function getSpanHierarchy({ rootSpans, totalDurationMs, } - }) -} +}) export interface FacetItem { name: string @@ -347,8 +358,15 @@ export function getTracesFacets({ data, }: { data: GetTracesFacetsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getTracesFacetsEffect({ data }) +} + +const getTracesFacetsEffect = Effect.fn("Tinybird.getTracesFacets")(function* ({ + data, +}: { + data: GetTracesFacetsInput +}) { const input = yield* decodeInput(GetTracesFacetsInputSchema, data ?? {}, "getTracesFacets") const tinybird = getTinybird() @@ -384,15 +402,21 @@ export function getTracesFacets({ return { data: transformFacets(facetsResult.data, durationStatsResult.data), } - }) -} +}) export function getTracesDurationStats({ data, }: { data: GetTracesFacetsInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getTracesDurationStatsEffect({ data }) +} + +const getTracesDurationStatsEffect = Effect.fn("Tinybird.getTracesDurationStats")(function* ({ + data, +}: { + data: GetTracesFacetsInput +}) { const input = yield* decodeInput( GetTracesFacetsInputSchema, data ?? {}, @@ -420,8 +444,7 @@ export function getTracesDurationStats({ p95DurationMs: Number(row.p95DurationMs), })), } - }) -} +}) const GetSpanAttributeKeysInputSchema = Schema.Struct({ startTime: Schema.optional(TinybirdDateTimeString), @@ -438,8 +461,15 @@ export function getSpanAttributeKeys({ data, }: { data: GetSpanAttributeKeysInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getSpanAttributeKeysEffect({ data }) +} + +const getSpanAttributeKeysEffect = Effect.fn("Tinybird.getSpanAttributeKeys")(function* ({ + data, +}: { + data: GetSpanAttributeKeysInput +}) { const input = yield* decodeInput( GetSpanAttributeKeysInputSchema, data ?? {}, @@ -459,8 +489,7 @@ export function getSpanAttributeKeys({ usageCount: Number(row.usageCount), })), } - }) -} +}) const GetSpanAttributeValuesInputSchema = Schema.Struct({ startTime: Schema.optional(TinybirdDateTimeString), @@ -478,8 +507,16 @@ export function getSpanAttributeValues({ data, }: { data: GetSpanAttributeValuesInput -}): Effect.Effect { - return Effect.gen(function* () { +}) { + return getSpanAttributeValuesEffect({ data }) +} + +const getSpanAttributeValuesEffect = Effect.fn("Tinybird.getSpanAttributeValues")( + function* ({ + data, + }: { + data: GetSpanAttributeValuesInput + }) { const input = yield* decodeInput( GetSpanAttributeValuesInputSchema, data ?? {}, @@ -505,5 +542,5 @@ export function getSpanAttributeValues({ usageCount: Number(row.usageCount), })), } - }) -} + }, +) diff --git a/apps/web/src/lib/tinybird.ts b/apps/web/src/lib/tinybird.ts index d424fd4..0d7a642 100644 --- a/apps/web/src/lib/tinybird.ts +++ b/apps/web/src/lib/tinybird.ts @@ -105,8 +105,10 @@ type QueryResponse = { export { setMapleAuthHeaders } -const queryTinybird = (pipe: TinybirdPipe, params?: Record) => - Effect.gen(function* () { +const queryTinybirdEffect = Effect.fn("Tinybird.queryPipe")(function* ( + pipe: TinybirdPipe, + params?: Record, +) { const client = yield* MapleApiAtomClient return (yield* client.tinybird.query({ payload: { @@ -114,7 +116,10 @@ const queryTinybird = (pipe: TinybirdPipe, params?: Record) params, }, })) as QueryResponse - }) +}) + +const queryTinybird = (pipe: TinybirdPipe, params?: Record) => + queryTinybirdEffect(pipe, params) const query = { list_traces: (params?: Record) =>