UnifiedLogs.queries.ts406 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | |
| 3 | import { DEFAULT_LOG_TYPES } from './UnifiedLogs.constants' |
| 4 | import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types' |
| 5 | import { |
| 6 | joinSqlFragments, |
| 7 | analyticsLiteral as lit, |
| 8 | safeSql, |
| 9 | type SafeLogSqlFragment, |
| 10 | } from '@/data/logs/safe-analytics-sql' |
| 11 | |
| 12 | // Pagination and control parameters |
| 13 | const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const |
| 14 | |
| 15 | // Special filter parameters that need custom handling |
| 16 | const SPECIAL_FILTER_PARAMS = ['date'] as const |
| 17 | |
| 18 | // Combined list of all parameters to exclude from standard filtering |
| 19 | const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const |
| 20 | |
| 21 | // Facets the count query is allowed to be invoked for. Reject anything else |
| 22 | // at the entry point rather than letting an unsupported value reach |
| 23 | // `log_attributes[…]` lookups. |
| 24 | const FACET_FIELDS = ['log_type', 'level', 'method', 'status', 'pathname'] as const |
| 25 | |
| 26 | // OTEL log_attributes keys for HTTP-style fields. Centralized so they can be |
| 27 | // adjusted in one place if the backend conventions change. |
| 28 | const ATTR = { |
| 29 | method: safeSql`log_attributes['request.method']`, |
| 30 | status: safeSql`log_attributes['response.status_code']`, |
| 31 | path: safeSql`log_attributes['request.path']`, |
| 32 | } as const |
| 33 | |
| 34 | /** |
| 35 | * Predicate that matches rows belonging to a given log_type. Mirrors the |
| 36 | * shape of the original BigQuery unified-logs CTEs: edge gateway traffic |
| 37 | * (`source = 'edge_logs'`) is split between `edge`, `postgrest` and `storage` |
| 38 | * based on URL path. Other types map straight to a single source. |
| 39 | * |
| 40 | * The OTEL `postgrest_logs` and `storage_logs` sources contain process-level |
| 41 | * logs from postgREST / storage-api and are intentionally not part of unified |
| 42 | * logs; the UI surfaces gateway HTTP traffic for those buckets. |
| 43 | */ |
| 44 | const LOG_TYPE_PREDICATE: Record<string, SafeLogSqlFragment> = { |
| 45 | edge: safeSql`source = 'edge_logs' AND ${ATTR.path} NOT LIKE '%/rest/%' AND ${ATTR.path} NOT LIKE '%/storage/%'`, |
| 46 | postgrest: safeSql`source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%'`, |
| 47 | storage: safeSql`source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%'`, |
| 48 | postgres: safeSql`source = 'postgres_logs'`, |
| 49 | 'edge function': safeSql`source = 'function_edge_logs'`, |
| 50 | auth: safeSql`source = 'auth_logs'`, |
| 51 | } |
| 52 | |
| 53 | // Derived `log_type` column for SELECT / GROUP BY / countIf use. |
| 54 | const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE |
| 55 | WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' |
| 56 | WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage' |
| 57 | WHEN source = 'edge_logs' THEN 'edge' |
| 58 | WHEN source = 'postgres_logs' THEN 'postgres' |
| 59 | WHEN source = 'function_edge_logs' THEN 'edge function' |
| 60 | WHEN source = 'auth_logs' THEN 'auth' |
| 61 | ELSE source |
| 62 | END` |
| 63 | |
| 64 | // Status code is sourced from the HTTP response for gateway-style rows and |
| 65 | // from the Postgres `parsed.sql_state_code` (e.g. `42P01`) for postgres rows. |
| 66 | const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE |
| 67 | WHEN source = 'postgres_logs' THEN toString(log_attributes['parsed.sql_state_code']) |
| 68 | ELSE toString(${ATTR.status}) |
| 69 | END` |
| 70 | |
| 71 | // SQL expression for derived `level`. Used inline (not as alias reference) |
| 72 | // because the OTEL endpoint can't resolve aliases inside countIf when the |
| 73 | // alias is not in GROUP BY. |
| 74 | // |
| 75 | // HTTP status is checked first so gateway rows (which always carry an |
| 76 | // `severity_text` of `INFO` regardless of response code) bucket as |
| 77 | // success/warning/error by status. Postgres-style severity is the |
| 78 | // fallback for rows without a status code. |
| 79 | const LEVEL_EXPR: SafeLogSqlFragment = safeSql`CASE |
| 80 | WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) >= 500 THEN 'error' |
| 81 | WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) BETWEEN 400 AND 499 THEN 'warning' |
| 82 | WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) BETWEEN 200 AND 299 THEN 'success' |
| 83 | WHEN severity_text IN ('ERROR','FATAL','CRITICAL','ALERT','EMERGENCY') THEN 'error' |
| 84 | WHEN severity_text IN ('WARN','WARNING') THEN 'warning' |
| 85 | WHEN severity_text IN ('TRACE','DEBUG','INFO','LOG','NOTICE') THEN 'success' |
| 86 | ELSE 'success' |
| 87 | END` |
| 88 | |
| 89 | const logTypeWherePredicate = (logTypes: string[]): SafeLogSqlFragment => { |
| 90 | const effective = logTypes.filter((t) => t in LOG_TYPE_PREDICATE) |
| 91 | const types = effective.length ? effective : [...DEFAULT_LOG_TYPES] |
| 92 | const branches = types.map((t) => safeSql`(${LOG_TYPE_PREDICATE[t]})`) |
| 93 | return safeSql`(${joinSqlFragments(branches, ' OR ')})` |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Translates a frontend filter key/value pair into an underlying SQL predicate. |
| 98 | * The OTEL endpoint won't accept queries that reference derived aliases like |
| 99 | * `log_type` or `level` in WHERE for some shapes, so we always emit raw-column |
| 100 | * predicates (source/severity_text/log_attributes[…]). |
| 101 | */ |
| 102 | const translateFilter = (key: string, value: unknown): SafeLogSqlFragment | null => { |
| 103 | if (value === null || value === undefined) return null |
| 104 | |
| 105 | const arr = Array.isArray(value) ? (value.length > 0 ? value : null) : null |
| 106 | if (Array.isArray(value) && !arr) return null |
| 107 | |
| 108 | const inList = (values: readonly unknown[]): SafeLogSqlFragment => |
| 109 | safeSql`(${joinSqlFragments( |
| 110 | values.map((v) => lit(String(v))), |
| 111 | ',' |
| 112 | )})` |
| 113 | |
| 114 | switch (key) { |
| 115 | case 'log_type': { |
| 116 | const types = (arr ?? [value]).map((v) => String(v)) |
| 117 | const branches = types.map( |
| 118 | (t) => safeSql`(${LOG_TYPE_PREDICATE[t] ?? safeSql`source = ${lit(t)}`})` |
| 119 | ) |
| 120 | return safeSql`(${joinSqlFragments(branches, ' OR ')})` |
| 121 | } |
| 122 | case 'level': { |
| 123 | // No simple raw column for level; reference the inline CASE expression. |
| 124 | const levels = arr ?? [value] |
| 125 | return safeSql`(${LEVEL_EXPR}) IN ${inList(levels.map((v) => String(v)))}` |
| 126 | } |
| 127 | case 'method': |
| 128 | return arr |
| 129 | ? safeSql`${ATTR.method} IN ${inList(arr)}` |
| 130 | : safeSql`${ATTR.method} = ${lit(String(value))}` |
| 131 | case 'status': { |
| 132 | // Match the displayed status: HTTP response code for gateway rows, |
| 133 | // Postgres SQLSTATE for postgres rows. Inline STATUS_EXPR so e.g. |
| 134 | // filtering on '00000' picks up postgres success rows. |
| 135 | const statuses = arr ?? [value] |
| 136 | return safeSql`(${STATUS_EXPR}) IN ${inList(statuses.map((v) => String(v)))}` |
| 137 | } |
| 138 | case 'pathname': |
| 139 | return arr |
| 140 | ? safeSql`(${joinSqlFragments( |
| 141 | arr.map((v) => safeSql`${ATTR.path} LIKE ${lit('%' + String(v) + '%')}`), |
| 142 | ' OR ' |
| 143 | )})` |
| 144 | : safeSql`${ATTR.path} LIKE ${lit('%' + String(value) + '%')}` |
| 145 | case 'host': |
| 146 | // Best-effort: use full request URL since `host` isn't a top-level field. |
| 147 | return arr |
| 148 | ? safeSql`(${joinSqlFragments( |
| 149 | arr.map( |
| 150 | (v) => safeSql`log_attributes['request.url'] LIKE ${lit('%' + String(v) + '%')}` |
| 151 | ), |
| 152 | ' OR ' |
| 153 | )})` |
| 154 | : safeSql`log_attributes['request.url'] LIKE ${lit('%' + String(value) + '%')}` |
| 155 | default: |
| 156 | // Unknown filter key — fall back to a generic equality on log_attributes. |
| 157 | return arr |
| 158 | ? safeSql`log_attributes[${lit(key)}] IN ${inList(arr)}` |
| 159 | : safeSql`log_attributes[${lit(key)}] = ${lit(String(value))}` |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Builds an array of WHERE predicate fragments from search params, optionally |
| 165 | * skipping a specific facet field (used when computing faceted counts). |
| 166 | * `log_type` is always handled separately (see `logTypeWherePredicate`). |
| 167 | */ |
| 168 | const buildPredicates = ( |
| 169 | search: QuerySearchParamsType, |
| 170 | excludeField?: string |
| 171 | ): SafeLogSqlFragment[] => { |
| 172 | const predicates: SafeLogSqlFragment[] = [] |
| 173 | Object.entries(search).forEach(([key, value]) => { |
| 174 | if (key === excludeField) return |
| 175 | if (key === 'log_type') return |
| 176 | if ((EXCLUDED_QUERY_PARAMS as readonly string[]).includes(key)) return |
| 177 | try { |
| 178 | const predicate = translateFilter(key, value) |
| 179 | if (predicate) predicates.push(predicate) |
| 180 | } catch { |
| 181 | // analyticsLiteral rejected an unsupported input — drop the predicate. |
| 182 | } |
| 183 | }) |
| 184 | return predicates |
| 185 | } |
| 186 | |
| 187 | const whereClause = (predicates: SafeLogSqlFragment[]): SafeLogSqlFragment => |
| 188 | predicates.length > 0 ? safeSql`WHERE ${joinSqlFragments(predicates, ' AND ')}` : safeSql`` |
| 189 | |
| 190 | /** |
| 191 | * Calculates the chart bucketing level (minute/hour/day) given the date range. |
| 192 | */ |
| 193 | const calculateChartBucketing = ( |
| 194 | search: SearchParamsType | Record<string, unknown> |
| 195 | ): 'MINUTE' | 'HOUR' | 'DAY' => { |
| 196 | const dateRange = (search.date as Array<Date | string | number | null | undefined>) || [] |
| 197 | |
| 198 | const convertToMillis = (timestamp: Date | string | number | null | undefined) => { |
| 199 | if (!timestamp) return null |
| 200 | if (timestamp instanceof Date) return timestamp.getTime() |
| 201 | if (typeof timestamp === 'string') return dayjs(timestamp).valueOf() |
| 202 | if (typeof timestamp === 'number') { |
| 203 | const str = timestamp.toString() |
| 204 | if (str.length >= 16) return Math.floor(timestamp / 1000) |
| 205 | return timestamp |
| 206 | } |
| 207 | return null |
| 208 | } |
| 209 | |
| 210 | let startMillis = convertToMillis(dateRange[0]) |
| 211 | let endMillis = convertToMillis(dateRange[1]) |
| 212 | |
| 213 | if (!startMillis) startMillis = dayjs().subtract(1, 'hour').valueOf() |
| 214 | if (!endMillis) endMillis = dayjs().valueOf() |
| 215 | |
| 216 | const startTime = dayjs(startMillis) |
| 217 | const endTime = dayjs(endMillis) |
| 218 | |
| 219 | const hourDiff = endTime.diff(startTime, 'hour') |
| 220 | const dayDiff = endTime.diff(startTime, 'day') |
| 221 | |
| 222 | if (dayDiff >= 2) return 'DAY' |
| 223 | if (hourDiff >= 12) return 'HOUR' |
| 224 | return 'MINUTE' |
| 225 | } |
| 226 | |
| 227 | const truncationFunction = (level: 'MINUTE' | 'HOUR' | 'DAY'): SafeLogSqlFragment => { |
| 228 | switch (level) { |
| 229 | case 'DAY': |
| 230 | return safeSql`toStartOfDay` |
| 231 | case 'HOUR': |
| 232 | return safeSql`toStartOfHour` |
| 233 | case 'MINUTE': |
| 234 | default: |
| 235 | return safeSql`toStartOfMinute` |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Returns the projection list for a unified-logs row. All derivations are |
| 241 | * inlined so the result can be referenced (or filtered) at the same query |
| 242 | * level — the OTEL endpoint rejects subqueries. |
| 243 | */ |
| 244 | const rowProjection = (): SafeLogSqlFragment => safeSql` |
| 245 | id, |
| 246 | null AS source_id, |
| 247 | timestamp, |
| 248 | ${LOG_TYPE_EXPR} AS log_type, |
| 249 | ${STATUS_EXPR} AS status, |
| 250 | ${LEVEL_EXPR} AS level, |
| 251 | ${ATTR.path} AS pathname, |
| 252 | event_message, |
| 253 | ${ATTR.method} AS method, |
| 254 | null AS log_count, |
| 255 | null AS logs |
| 256 | ` |
| 257 | |
| 258 | const buildBaseWhere = ( |
| 259 | search: QuerySearchParamsType, |
| 260 | excludeField?: string |
| 261 | ): SafeLogSqlFragment[] => { |
| 262 | const effectiveLogTypes = search.log_type?.length ? search.log_type : [...DEFAULT_LOG_TYPES] |
| 263 | const parts: SafeLogSqlFragment[] = [] |
| 264 | if (excludeField !== 'log_type') { |
| 265 | parts.push(logTypeWherePredicate(effectiveLogTypes)) |
| 266 | } |
| 267 | parts.push(...buildPredicates(search, excludeField)) |
| 268 | return parts |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Unified logs row query — flat SELECT, no subquery wrapper. |
| 273 | */ |
| 274 | export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { |
| 275 | const predicates = buildBaseWhere(search) |
| 276 | return safeSql` |
| 277 | SELECT ${rowProjection()} |
| 278 | FROM logs |
| 279 | ${whereClause(predicates)} |
| 280 | ` |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Single-facet count query — a complete flat SELECT with GROUP BY. |
| 285 | */ |
| 286 | export const getFacetCountQuery = ({ |
| 287 | search, |
| 288 | facet, |
| 289 | facetSearch, |
| 290 | }: { |
| 291 | search: QuerySearchParamsType |
| 292 | facet: string |
| 293 | facetSearch?: string |
| 294 | }): SafeLogSqlFragment => { |
| 295 | if (!(FACET_FIELDS as readonly string[]).includes(facet)) { |
| 296 | throw new Error('Invalid unified logs facet') |
| 297 | } |
| 298 | |
| 299 | const MAX_FACETS_QUANTITY = 20 |
| 300 | |
| 301 | const facetExpr: SafeLogSqlFragment = |
| 302 | facet === 'log_type' |
| 303 | ? LOG_TYPE_EXPR |
| 304 | : facet === 'level' |
| 305 | ? LEVEL_EXPR |
| 306 | : facet === 'method' |
| 307 | ? ATTR.method |
| 308 | : facet === 'status' |
| 309 | ? STATUS_EXPR |
| 310 | : facet === 'pathname' |
| 311 | ? ATTR.path |
| 312 | : safeSql`log_attributes[${lit(facet)}]` |
| 313 | |
| 314 | const predicates: SafeLogSqlFragment[] = [ |
| 315 | ...buildBaseWhere(search, facet), |
| 316 | safeSql`(${facetExpr}) IS NOT NULL AND (${facetExpr}) != ''`, |
| 317 | ] |
| 318 | if (facetSearch) { |
| 319 | predicates.push(safeSql`(${facetExpr}) LIKE ${lit('%' + facetSearch + '%')}`) |
| 320 | } |
| 321 | |
| 322 | return safeSql` |
| 323 | SELECT ${lit(facet)} AS dimension, (${facetExpr}) AS value, count() AS count |
| 324 | FROM logs |
| 325 | ${whereClause(predicates)} |
| 326 | GROUP BY value |
| 327 | LIMIT ${lit(MAX_FACETS_QUANTITY)} |
| 328 | ` |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Bundled count query — UNION ALL of (dimension, value, count) rows so the |
| 333 | * frontend can render facet counts and total in one round trip. |
| 334 | */ |
| 335 | export const getLogsCountQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { |
| 336 | // When no predicates remain, fall back to `1` so we emit a valid |
| 337 | // tautology rather than a bare `WHERE`. |
| 338 | const baseFiltersFor = (excludeField?: string): SafeLogSqlFragment => { |
| 339 | const predicates = buildBaseWhere(search, excludeField) |
| 340 | return predicates.length > 0 ? joinSqlFragments(predicates, ' AND ') : safeSql`1` |
| 341 | } |
| 342 | |
| 343 | // The "total" badge should reflect the user's *current* filter set, |
| 344 | // including any active log_type filter. Pass no excludeField so the |
| 345 | // log_type predicate is included. |
| 346 | const totalSql = safeSql` |
| 347 | SELECT 'total' AS dimension, 'all' AS value, count() AS count |
| 348 | FROM logs |
| 349 | WHERE ${baseFiltersFor()} |
| 350 | ` |
| 351 | |
| 352 | const logTypeBranches = joinSqlFragments( |
| 353 | Object.entries(LOG_TYPE_PREDICATE).map( |
| 354 | ([logType, predicate]) => |
| 355 | safeSql` |
| 356 | SELECT 'log_type' AS dimension, ${lit(logType)} AS value, countIf(${predicate}) AS count |
| 357 | FROM logs |
| 358 | WHERE ${baseFiltersFor('log_type')} |
| 359 | ` |
| 360 | ), |
| 361 | ' UNION ALL ' |
| 362 | ) |
| 363 | |
| 364 | const levelBranches = joinSqlFragments( |
| 365 | (['success', 'warning', 'error'] as const).map( |
| 366 | (lvl) => |
| 367 | safeSql` |
| 368 | SELECT 'level' AS dimension, ${lit(lvl)} AS value, countIf((${LEVEL_EXPR}) = ${lit(lvl)}) AS count |
| 369 | FROM logs |
| 370 | WHERE ${baseFiltersFor('level')} |
| 371 | ` |
| 372 | ), |
| 373 | ' UNION ALL ' |
| 374 | ) |
| 375 | |
| 376 | const facetBranches = joinSqlFragments( |
| 377 | (['method', 'status', 'pathname'] as const).map( |
| 378 | (facet) => safeSql`(${getFacetCountQuery({ search, facet })})` |
| 379 | ), |
| 380 | ' UNION ALL ' |
| 381 | ) |
| 382 | |
| 383 | return joinSqlFragments([totalSql, logTypeBranches, levelBranches, facetBranches], ' UNION ALL ') |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Logs chart query with dynamic bucketing based on time range. |
| 388 | */ |
| 389 | export const getLogsChartQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { |
| 390 | const truncationLevel = calculateChartBucketing(search) |
| 391 | const truncFn = truncationFunction(truncationLevel) |
| 392 | const predicates = buildBaseWhere(search) |
| 393 | |
| 394 | return safeSql` |
| 395 | SELECT |
| 396 | ${truncFn}(timestamp) AS time_bucket, |
| 397 | countIf((${LEVEL_EXPR}) = 'success') AS success, |
| 398 | countIf((${LEVEL_EXPR}) = 'warning') AS warning, |
| 399 | countIf((${LEVEL_EXPR}) = 'error') AS error, |
| 400 | count() AS total_per_bucket |
| 401 | FROM logs |
| 402 | ${whereClause(predicates)} |
| 403 | GROUP BY time_bucket |
| 404 | ORDER BY time_bucket ASC |
| 405 | ` |
| 406 | } |