Logs.utils.ts845 lines · main
1import { useMonaco } from '@monaco-editor/react'
2import { IS_PLATFORM } from 'common'
3import dayjs, { Dayjs } from 'dayjs'
4import { get } from 'lodash'
5import uniqBy from 'lodash/uniqBy'
6import { useEffect } from 'react'
7import logConstants from 'shared-data/log-constants'
8
9import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
10import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types'
11import { convertResultsToCSV } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils'
12import BackwardIterator from '@/components/ui/CodeEditor/Providers/BackwardIterator'
13
14/**
15 * Convert a micro timestamp from number/string to iso timestamp
16 */
17export const unixMicroToIsoTimestamp = (unix: string | number): string => {
18 return dayjs.utc(Number(unix) / 1000).toISOString()
19}
20
21export const isUnixMicro = (unix: string | number): boolean => {
22 const digitLength = String(unix).length === 16
23 const isNum = !Number.isNaN(Number(unix))
24 return isNum && digitLength
25}
26
27/**
28 * Boolean check to verify that there are 3 columns:
29 * - id
30 * - timestamp
31 * - event_message
32 */
33export const isDefaultLogPreviewFormat = (log: LogData) =>
34 log && log.timestamp && log.event_message && log.id
35
36/**
37 * Recursively retrieve all nested object key paths.
38 *
39 * TODO: move to utils
40 *
41 * @param obj any object
42 * @param parent a string representing the parent key
43 * @returns string[] all dot paths for keys.
44 */
45const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => {
46 const keys = Object.keys(obj).filter((k) => obj[k])
47 return keys.flatMap((k) => {
48 const currKey = parent ? `${parent}.${k}` : k
49 if (typeof obj[k] === 'object') {
50 return getDotKeys(obj[k] as any, currKey)
51 } else {
52 return [currKey]
53 }
54 })
55}
56
57/**
58 * Root keys in the filter object are considered to be AND filters.
59 * Nested keys under a root key are considered to be OR filters.
60 *
61 * For example:
62 * ```
63 * {my_value: 'something', nested: {id: 123, test: 123 }}
64 * ```
65 * This would be converted into `WHERE (my_value = 'something') and (id = 123 or test = 123)
66 *
67 * The template of the filter determines the actual filter statement. If no template is provided, a generic equality statement will be used.
68 * This only applies for root keys of the filter.
69 * For example:
70 * ```
71 * {'my.nested.value': 123}
72 * ```
73 * with no template, it will be converted into `WHERE (my.nested.value = 123)
74 *
75 * @returns a where statement with WHERE clause.
76 */
77const genWhereStatement = (table: LogsTableName, filters: Filters) => {
78 const keys = Object.keys(filters)
79 const filterTemplates = SQL_FILTER_TEMPLATES[table]
80
81 const _resolveTemplateToStatement = (dotKey: string): string | null => {
82 const template = filterTemplates[dotKey]
83 const value = get(filters, dotKey)
84 if (value !== undefined && typeof template === 'function') {
85 return template(value)
86 } else if (template === undefined) {
87 // resolve unknown filters (possibly from filter overrides)
88 // no template, set a default
89 if (typeof value === 'string') {
90 return `${dotKey} = '${value}'`
91 } else {
92 return `${dotKey} = ${value}`
93 }
94 } else if (value === undefined && typeof template === 'function') {
95 return null
96 } else if (template && value === false) {
97 // template present, but value is false
98 return null
99 } else {
100 return template
101 }
102 }
103
104 const statement = keys
105 .map((rootKey) => {
106 if (
107 filters[rootKey] === undefined ||
108 (typeof filters[rootKey] === 'string' && (filters[rootKey] as string).length === 0)
109 ) {
110 return null
111 } else if (typeof filters[rootKey] === 'object') {
112 // join all statements with an OR
113 const nestedStatements = getDotKeys(filters[rootKey] as Filters, rootKey)
114 .map(_resolveTemplateToStatement)
115 .filter(Boolean)
116
117 if (nestedStatements.length > 0) {
118 return `(${nestedStatements.join(' or ')})`
119 } else {
120 return null
121 }
122 } else {
123 const nestedStatement = _resolveTemplateToStatement(rootKey)
124 if (nestedStatement === null) return null
125 return `(${nestedStatement})`
126 }
127 })
128 .filter(Boolean)
129 // join all root statements with AND
130 .join(' and ')
131
132 if (statement) {
133 return 'where ' + statement
134 } else {
135 return ''
136 }
137}
138
139export const genDefaultQuery = (table: LogsTableName, filters: Filters, limit: number = 100) => {
140 const where = genWhereStatement(table, filters)
141 const joins = genCrossJoinUnnests(table)
142 const orderBy = 'order by timestamp desc'
143
144 switch (table) {
145 case 'edge_logs':
146 if (!IS_PLATFORM) {
147 return `
148-- local dev edge_logs query
149select id, edge_logs.timestamp, event_message, request.method, request.path, request.search, response.status_code
150from edge_logs
151${joins}
152${where}
153${orderBy}
154limit ${limit};
155`
156 }
157 return `select id, identifier, timestamp, event_message, request.method, request.path, request.search, response.status_code
158 from ${table}
159 ${joins}
160 ${where}
161 ${orderBy}
162 limit ${limit}
163 `
164
165 case 'postgres_logs':
166 if (!IS_PLATFORM) {
167 return `
168select postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.detail, parsed.hint
169from postgres_logs
170${joins}
171${where}
172${orderBy}
173limit ${limit}
174 `
175 }
176 return `select identifier, postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.detail, parsed.hint from ${table}
177 ${joins}
178 ${where}
179 ${orderBy}
180 limit ${limit}
181 `
182
183 case 'function_logs':
184 return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from ${table}
185 ${joins}
186 ${where}
187 ${orderBy}
188 limit ${limit}
189 `
190
191 case 'auth_logs':
192 return `select id, ${table}.timestamp, event_message, metadata.level, metadata.status, metadata.path, metadata.msg as msg, metadata.error from ${table}
193 ${joins}
194 ${where}
195 ${orderBy}
196 limit ${limit}
197 `
198
199 case 'function_edge_logs':
200 if (!IS_PLATFORM) {
201 return `
202select id, function_edge_logs.timestamp, event_message
203from function_edge_logs
204${orderBy}
205limit ${limit}
206`
207 }
208 return `select id, ${table}.timestamp, event_message, response.status_code, request.method, request.pathname, m.function_id, m.execution_id, m.execution_time_ms, m.deployment_id, m.version from ${table}
209 ${joins}
210 ${where}
211 ${orderBy}
212 limit ${limit}
213 `
214 case 'supavisor_logs':
215 return `select id, ${table}.timestamp, event_message from ${table} ${joins} ${where} ${orderBy} limit ${limit}`
216
217 case 'pg_upgrade_logs':
218 return `select id, ${table}.timestamp, event_message from ${table} ${joins} ${where} ${orderBy} limit 100`
219
220 default:
221 return `select id, ${table}.timestamp, event_message from ${table}
222 ${where}
223 ${orderBy}
224 limit ${limit}
225 `
226
227 case 'pg_cron_logs':
228 const pgCronWhere = where ? `${basePgCronWhere} AND ${where.substring(6)}` : basePgCronWhere
229
230 return `select id, postgres_logs.timestamp, event_message, parsed.error_severity, parsed.query
231from postgres_logs
232${joins}
233${pgCronWhere}
234${orderBy}
235limit ${limit}
236`
237 }
238}
239
240/**
241 * Hardcoded cross join unnests and aliases for each table.
242 * Should be used together with the getWhereStatements to allow for filtering on aliases
243 */
244const genCrossJoinUnnests = (table: LogsTableName) => {
245 switch (table) {
246 case 'edge_logs':
247 return `cross join unnest(metadata) as m
248 cross join unnest(m.request) as request
249 cross join unnest(m.response) as response`
250
251 case 'pg_cron_logs':
252 case 'postgres_logs':
253 return `cross join unnest(metadata) as m
254 cross join unnest(m.parsed) as parsed`
255
256 case 'function_logs':
257 return `cross join unnest(metadata) as metadata`
258
259 case 'auth_logs':
260 return `cross join unnest(metadata) as metadata`
261
262 case 'function_edge_logs':
263 return `cross join unnest(metadata) as m
264 cross join unnest(m.response) as response
265 cross join unnest(m.request) as request`
266
267 case 'supavisor_logs':
268 return `cross join unnest(metadata) as m`
269
270 default:
271 return ''
272 }
273}
274
275/**
276 * SQL query to retrieve only one log
277 */
278export const genSingleLogQuery = (table: LogsTableName, id: string) =>
279 `select id, timestamp, event_message, metadata from ${table} where id = '${id}' limit 1`
280
281/**
282 * Determine if we should show the user an upgrade prompt while browsing logs
283 */
284export const maybeShowUpgradePromptIfNotEntitled = (
285 from: string | null | undefined,
286 entitledToDays: number | undefined
287) => {
288 if (!entitledToDays) return false
289 const day = Math.abs(dayjs().diff(dayjs(from), 'day'))
290 return day > entitledToDays
291}
292
293export const genCountQuery = (table: LogsTableName, filters: Filters): string => {
294 let where = genWhereStatement(table, filters)
295 // pg_cron logs are a subset of postgres logs
296 // to calculate the chart, we need to query postgres logs
297 if (table === LogsTableName.PG_CRON) {
298 table = LogsTableName.POSTGRES
299 where = basePgCronWhere
300 }
301 const joins = genCrossJoinUnnests(table)
302 return `SELECT count(*) as count FROM ${table} ${joins} ${where}`
303}
304
305/** calculates how much the chart start datetime should be offset given the current datetime filter params */
306const calcChartStart = (
307 params: Partial<LogsEndpointParams>
308): [Dayjs, 'minute' | 'hour' | 'day'] => {
309 const ite = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs()
310 // todo @TzeYiing needs typing
311 const its: any = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs()
312
313 let trunc: 'minute' | 'hour' | 'day' = 'minute'
314 let extendValue = 60 * 6
315 const minuteDiff = ite.diff(its, 'minute')
316 const hourDiff = ite.diff(its, 'hour')
317 if (minuteDiff > 60 * 12) {
318 trunc = 'hour'
319 extendValue = 24 * 5
320 } else if (hourDiff > 24 * 3) {
321 trunc = 'day'
322 extendValue = 7
323 }
324 return [its.add(-extendValue, trunc), trunc]
325}
326
327// TODO(qiao): workaround for self-hosted cron logs error until logflare is fixed
328const basePgCronWhere = IS_PLATFORM
329 ? `where ( parsed.application_name = 'pg_cron' or regexp_contains(event_message, 'cron job') )`
330 : `where ( parsed.application_name = 'pg_cron' or event_message::text LIKE '%cron job%' )`
331/**
332 *
333 * generates log event chart query
334 */
335export const genChartQuery = (
336 table: LogsTableName,
337 params: LogsEndpointParams,
338 filters: Filters
339) => {
340 const [startOffset, trunc] = calcChartStart(params)
341 let where = genWhereStatement(table, filters)
342 const errorCondition = getErrorCondition(table)
343 const warningCondition = getWarningCondition(table)
344
345 // pg_cron logs are a subset of postgres logs
346 // to calculate the chart, we need to query postgres logs
347 if (table === LogsTableName.PG_CRON) {
348 table = LogsTableName.POSTGRES
349 where = basePgCronWhere
350 }
351
352 let joins = genCrossJoinUnnests(table)
353
354 const q = `
355SELECT
356-- log-event-chart
357 timestamp_trunc(t.timestamp, ${trunc}) as timestamp,
358 count(CASE WHEN NOT (${errorCondition} OR ${warningCondition}) THEN 1 END) as ok_count,
359 count(CASE WHEN ${errorCondition} THEN 1 END) as error_count,
360 count(CASE WHEN ${warningCondition} THEN 1 END) as warning_count,
361FROM
362 ${table} t
363 ${joins}
364 ${
365 where
366 ? where + ` and t.timestamp > '${startOffset.toISOString()}'`
367 : `where t.timestamp > '${startOffset.toISOString()}'`
368 }
369GROUP BY
370timestamp
371ORDER BY
372 timestamp ASC
373 `
374 return q
375}
376
377type TsPair = [string | '', string | '']
378export const ensureNoTimestampConflict = (
379 [initialStart, initialEnd]: TsPair,
380 [nextStart, nextEnd]: TsPair
381): TsPair => {
382 if (initialStart && initialEnd && nextEnd && !nextStart) {
383 const resolvedDiff = dayjs(nextEnd).diff(dayjs(initialStart))
384 let start = dayjs(initialStart)
385
386 if (resolvedDiff <= 0) {
387 // start ts is definitely before end ts
388 const currDiff = Math.abs(dayjs(initialEnd).diff(start, 'minute'))
389 // shift start ts backwards by the current ts difference
390 start = dayjs(nextEnd).subtract(currDiff, 'minute')
391 }
392 return [start.toISOString(), nextEnd]
393 } else if (!nextEnd && nextStart) {
394 return [nextStart, initialEnd]
395 } else {
396 return [nextStart, nextEnd]
397 }
398}
399
400/**
401 * Adds SQL code hints to logs explorer code editor
402 */
403export const useEditorHints = () => {
404 const monaco = useMonaco()
405
406 useEffect(() => {
407 if (monaco) {
408 const competionProvider = {
409 triggerCharacters: ['`', ' ', '.'],
410 provideCompletionItems: function (model: any, position: any, context: any) {
411 let iterator = new BackwardIterator(model, position.column - 2, position.lineNumber - 1)
412 if (iterator.isNextDQuote()) return { suggestions: [] }
413 let suggestions: { label: string; kind: any; insertText: string }[] = []
414
415 let schemasInUse = logConstants.schemas.filter((schema) =>
416 iterator._text.includes(schema.reference)
417 )
418 if (schemasInUse.length === 0) {
419 schemasInUse = logConstants.schemas
420 }
421
422 if (iterator.isNextPeriod()) {
423 // should be nested key reference, suggest all tail endings of available fields
424 const fields = schemasInUse.flatMap((schema) => schema.fields)
425 const trailingKeys = fields.flatMap((field) => {
426 const [_head, ...rest] = field.path.split('.')
427 return rest
428 })
429
430 const trailingToAdd = trailingKeys.map((key) => ({
431 label: key,
432 kind: monaco.languages.CompletionItemKind.Property,
433 insertText: key,
434 }))
435 suggestions = suggestions.concat(trailingToAdd)
436 }
437
438 if (context.triggerCharacter === '`' || context.triggerCharacter === ' ') {
439 // should be reference or start of key
440 const referencesToAdd = logConstants.schemas.map((schema) => ({
441 label: schema.reference,
442 kind: monaco.languages.CompletionItemKind.Class,
443 insertText: schema.reference,
444 }))
445
446 const fields = schemasInUse.flatMap((schema) => schema.fields)
447 const leadingKeys = fields.flatMap((field) => {
448 const splitPath = field.path.split('.')
449
450 return splitPath.slice(0, -1)
451 })
452
453 const leadingToAdd = leadingKeys.map((key) => ({
454 label: key,
455 kind: monaco.languages.CompletionItemKind.Property,
456 insertText: key,
457 }))
458 suggestions = suggestions.concat(leadingToAdd)
459 suggestions = suggestions.concat(referencesToAdd)
460 }
461 return {
462 suggestions: uniqBy(suggestions, 'label'),
463 }
464 },
465 } as any
466
467 // register completion item provider for pgsql
468 const completeProvider = monaco.languages.registerCompletionItemProvider(
469 'pgsql',
470 competionProvider
471 )
472
473 return () => {
474 completeProvider.dispose()
475 }
476 }
477 }, [monaco])
478}
479
480/**
481 * Assumes that all timestamps are in ISO-8601 UTC timezone.
482 *
483 * min/max are the datetime strings that extend beyond the given timeseries data.
484 */
485export const fillTimeseries = (
486 timeseriesData: any[],
487 timestampKey: string,
488 valueKey: string | string[],
489 defaultValue: number,
490 min?: string,
491 max?: string,
492 minPointsToFill: number = 20,
493 interval?: string
494) => {
495 if (timeseriesData.length === 0 && !(min && max)) {
496 return []
497 }
498 // If we have more points than minPointsToFill, just normalize timestamps and return
499 if (timeseriesData.length > minPointsToFill) {
500 return timeseriesData.map((datum) => {
501 const timestamp = datum[timestampKey]
502 const iso = isUnixMicro(timestamp)
503 ? unixMicroToIsoTimestamp(timestamp)
504 : dayjs.utc(timestamp).toISOString()
505 datum[timestampKey] = iso
506 return datum
507 })
508 }
509
510 if (timeseriesData.length <= 1 && !(min && max)) return timeseriesData
511 const dates: unknown[] = timeseriesData.map((datum) => dayjs.utc(datum[timestampKey]))
512
513 const maxDate = max ? dayjs.utc(max) : dayjs.utc(Math.max.apply(null, dates as number[]))
514 const minDate = min ? dayjs.utc(min) : dayjs.utc(Math.min.apply(null, dates as number[]))
515
516 // When no data exists but min/max are provided, we need to determine truncation from the time range
517 const truncationSamples = timeseriesData.length > 0 ? dates : [minDate, maxDate]
518 let truncation: 'second' | 'minute' | 'hour' | 'day'
519 let step = 1
520
521 if (interval) {
522 const match = interval.match(/^(\d+)(m|h|d|s)$/)
523 if (match) {
524 step = parseInt(match[1], 10)
525 const unitChar = match[2] as 'm' | 'h' | 'd' | 's'
526 const unitMap = { s: 'second', m: 'minute', h: 'hour', d: 'day' } as const
527 truncation = unitMap[unitChar]
528 } else {
529 // Fallback for invalid format
530 truncation = getTimestampTruncation(truncationSamples as Dayjs[])
531 }
532 } else {
533 truncation = getTimestampTruncation(truncationSamples as Dayjs[])
534 }
535
536 // If no data exists and no interval specified, default to minute precision
537 if (timeseriesData.length === 0 && !interval) {
538 truncation = 'minute'
539 }
540
541 const newData = timeseriesData.map((datum) => {
542 const timestamp = datum[timestampKey]
543 const iso = isUnixMicro(timestamp)
544 ? unixMicroToIsoTimestamp(timestamp)
545 : dayjs.utc(timestamp).toISOString()
546
547 if (Array.isArray(valueKey) && valueKey.length === 0) {
548 return { [timestampKey]: iso }
549 }
550
551 datum[timestampKey] = iso
552 return datum
553 })
554
555 let currentDate = minDate
556 while (currentDate.isBefore(maxDate) || currentDate.isSame(maxDate)) {
557 const found = dates.find((d) => {
558 const d_date = d as Dayjs
559 return (
560 d_date.year() === currentDate.year() &&
561 d_date.month() === currentDate.month() &&
562 d_date.date() === currentDate.date() &&
563 d_date.hour() === currentDate.hour() &&
564 d_date.minute() === currentDate.minute() &&
565 d_date.second() === currentDate.second()
566 )
567 })
568 if (!found) {
569 const keys = typeof valueKey === 'string' ? [valueKey] : valueKey
570
571 const toMerge = keys.reduce(
572 (acc, key) => ({
573 ...acc,
574 [key]: defaultValue,
575 }),
576 {}
577 )
578 newData.push({
579 [timestampKey]: currentDate.toISOString(),
580 ...toMerge,
581 })
582 }
583 currentDate = currentDate.add(step, truncation)
584 }
585
586 return newData
587}
588
589const getTimestampTruncation = (samples: Dayjs[]): 'second' | 'minute' | 'hour' | 'day' => {
590 const truncationCounts = samples.reduce(
591 (acc, sample) => {
592 const truncation = _getTruncation(sample)
593 acc[truncation] += 1
594
595 return acc
596 },
597 {
598 second: 0,
599 minute: 0,
600 hour: 0,
601 day: 0,
602 }
603 )
604
605 const mostLikelyTruncation = (
606 Object.keys(truncationCounts) as (keyof typeof truncationCounts)[]
607 ).reduce((a, b) => (truncationCounts[a] > truncationCounts[b] ? a : b))
608 return mostLikelyTruncation
609}
610
611const _getTruncation = (date: Dayjs) => {
612 const values = ['second', 'minute', 'hour'].map((key) => date.get(key as dayjs.UnitType))
613 const zeroCount = values.reduce((acc, value) => {
614 if (value === 0) {
615 acc += 1
616 }
617 return acc
618 }, 0)
619 const truncation = {
620 0: 'second' as const,
621 1: 'minute' as const,
622 2: 'hour' as const,
623 3: 'day' as const,
624 }[zeroCount]!
625 return truncation
626}
627
628export function checkForWithClause(query: string) {
629 const queryWithoutComments = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//gm, '')
630
631 const withClauseRegex = /\b(WITH)\b(?=(?:[^']*'[^']*')*[^']*$)/i
632 return withClauseRegex.test(queryWithoutComments)
633}
634
635export function checkForILIKEClause(query: string) {
636 const queryWithoutComments = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//gm, '')
637
638 const ilikeClauseRegex = /\b(ILIKE)\b(?=(?:[^']*'[^']*')*[^']*$)/i
639 return ilikeClauseRegex.test(queryWithoutComments)
640}
641
642export function checkForWildcard(query: string) {
643 const queryWithoutComments = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//gm, '')
644
645 const queryWithoutCount = queryWithoutComments.replace(/count\(\*\)/gi, '')
646
647 const wildcardRegex = /\*/
648 return wildcardRegex.test(queryWithoutCount)
649}
650
651function getErrorCondition(table: LogsTableName): string {
652 switch (table) {
653 case 'edge_logs':
654 return 'response.status_code >= 500'
655 case 'postgres_logs':
656 return "parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')"
657 case 'auth_logs':
658 return "metadata.level = 'error' OR SAFE_CAST(metadata.status AS INT64) >= 400"
659 case 'function_edge_logs':
660 return 'response.status_code >= 500'
661 case 'function_logs':
662 return "metadata.level IN ('error', 'fatal')"
663 case 'pg_cron_logs':
664 return "parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')"
665 default:
666 return 'false'
667 }
668}
669
670function getWarningCondition(table: LogsTableName): string {
671 switch (table) {
672 case 'edge_logs':
673 return 'response.status_code >= 400 AND response.status_code < 500'
674 case 'postgres_logs':
675 return "parsed.error_severity IN ('WARNING')"
676 case 'auth_logs':
677 return "metadata.level = 'warning'"
678 case 'function_edge_logs':
679 return 'response.status_code >= 400 AND response.status_code < 500'
680 case 'function_logs':
681 return "metadata.level IN ('warning')"
682 default:
683 return 'false'
684 }
685}
686
687export function jwtAPIKey(metadata: any) {
688 const apikeyHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.jwt?.[0]?.apikey?.[0]
689 if (!apikeyHeader) {
690 return undefined
691 }
692
693 if (apikeyHeader.invalid) {
694 return '<invalid>'
695 }
696
697 const payload = apikeyHeader?.payload?.[0]
698 if (!payload) {
699 return '<unrecognized>'
700 }
701
702 if (
703 payload.algorithm === 'HS256' &&
704 payload.issuer === 'briven' &&
705 ['anon', 'service_role'].includes(payload.role) &&
706 !payload.subject
707 ) {
708 return payload.role
709 }
710
711 return '<unrecognized>'
712}
713
714export function apiKey(metadata: any) {
715 const apikeyHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.apikey?.[0]?.apikey?.[0]
716 if (!apikeyHeader) {
717 return undefined
718 }
719
720 if (apikeyHeader.error) {
721 return `${apikeyHeader.prefix}... <invalid: ${apikeyHeader.error}>`
722 }
723
724 return `${apikeyHeader.prefix}...`
725}
726
727export function role(metadata: any) {
728 const authorizationHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.jwt?.[0]?.authorization?.[0]
729 if (!authorizationHeader) {
730 return undefined
731 }
732
733 if (authorizationHeader.invalid) {
734 return undefined
735 }
736
737 const payload = authorizationHeader?.payload?.[0]
738 if (!payload || !payload.role) {
739 return undefined
740 }
741
742 return payload.role
743}
744
745export function formatLogsAsJson(rows: LogData[]): string {
746 return JSON.stringify(rows, null, 2)
747}
748
749export function formatLogsAsCsv(rows: LogData[]): string {
750 return convertResultsToCSV(rows as unknown as Record<string, unknown>[]) ?? ''
751}
752
753export function formatLogsAsMarkdown(rows: LogData[]): string {
754 return rows
755 .map((row, i) => {
756 const lines: string[] = [`## Log ${i + 1}`]
757 if (row.timestamp) {
758 const numTs = Number(row.timestamp)
759 let tsString: string
760 if (isFinite(numTs)) {
761 tsString = new Date(numTs / 1000).toISOString()
762 } else if (typeof row.timestamp === 'string') {
763 const d = new Date(row.timestamp)
764 tsString = isNaN(d.getTime()) ? row.timestamp : d.toISOString()
765 } else {
766 tsString = String(row.timestamp)
767 }
768 lines.push(`**Timestamp:** ${tsString}`)
769 }
770 if (row.event_message) {
771 lines.push(`**Message:** ${row.event_message}`)
772 }
773 const { id: _id, timestamp: _ts, event_message: _msg, ...rest } = row as any
774 if (Object.keys(rest).length > 0) {
775 lines.push('', '**Details:**', '```json', JSON.stringify(rest, null, 2), '```')
776 }
777 return lines.join('\n')
778 })
779 .join('\n\n---\n\n')
780}
781
782const QUERY_TYPE_LABELS: Record<QueryType, string> = {
783 api: 'API Gateway (Edge Network)',
784 database: 'Postgres Database',
785 functions: 'Edge Functions',
786 fn_edge: 'Edge Functions (edge runtime)',
787 auth: 'Auth',
788 realtime: 'Realtime',
789 storage: 'Storage',
790 supavisor: 'Supavisor (connection pooling)',
791 postgrest: 'PostgREST',
792 pg_upgrade: 'Postgres upgrade',
793 pg_cron: 'pg_cron',
794 pgbouncer: 'PgBouncer',
795 etl: 'ETL',
796}
797
798const LOG_TABLE_TO_SERVICE_LABEL: Record<LogsTableName, string> = {
799 edge_logs: 'API Gateway (Edge Network)',
800 postgres_logs: 'Postgres Database',
801 function_logs: 'Edge Functions',
802 function_edge_logs: 'Edge Functions (edge runtime)',
803 auth_logs: 'Auth',
804 auth_audit_logs: 'Auth (audit)',
805 realtime_logs: 'Realtime',
806 storage_logs: 'Storage',
807 postgrest_logs: 'PostgREST',
808 supavisor_logs: 'Supavisor (connection pooling)',
809 pgbouncer_logs: 'PgBouncer',
810 pg_upgrade_logs: 'Postgres upgrade',
811 pg_cron_logs: 'pg_cron',
812 etl_replication_logs: 'ETL',
813}
814
815const isLogsTableName = (value: string): value is LogsTableName =>
816 value in LOG_TABLE_TO_SERVICE_LABEL
817const isQueryType = (value: string): value is QueryType => value in QUERY_TYPE_LABELS
818
819export function extractEdgeFunctionName(pathname: unknown): string {
820 if (typeof pathname !== 'string' || !pathname) return ''
821 const parts = pathname.split('/').filter(Boolean)
822 return parts[parts.length - 1] ?? ''
823}
824
825function extractServiceLabelFromSql(sql: string): string | null {
826 const match = sql.match(/\bfrom\s+(\w+)/i)
827 const tableName = match?.[1]
828 return tableName && isLogsTableName(tableName) ? LOG_TABLE_TO_SERVICE_LABEL[tableName] : null
829}
830
831export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string {
832 const serviceLabel =
833 (queryType && isQueryType(queryType) ? QUERY_TYPE_LABELS[queryType] : null) ??
834 (sqlQuery ? extractServiceLabelFromSql(sqlQuery) : null)
835 const serviceContext = serviceLabel ? ` from the **${serviceLabel}** service` : ''
836 const sqlContext = sqlQuery ? `\n\n**Query used:**\n\`\`\`sql\n${sqlQuery.trim()}\n\`\`\`` : ''
837 const header = `I have ${rows.length} Briven log entr${rows.length === 1 ? 'y' : 'ies'}${serviceContext} I'd like help debugging:\n\n`
838 const body = formatLogsAsMarkdown(rows)
839 return (
840 header +
841 body +
842 sqlContext +
843 '\n\nWhat do these logs indicate? What steps can I take to resolve it? Keep your answer very concise and actionable. Max 2 or 3 bullet points.'
844 )
845}