UnifiedLogs.queries.ts 14 KB

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