mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
refactor(studio): share cached log attributes with request timelines
This commit is contained in:
1 parent
308bf7bd77
commit
e09ffb024b
5 files changed
+103
-26
No files matched your search
@@ -54,6 +54,7 @@ export function LogDetail({
|
||||
})
|
||||
const { data: fetchedAttributes } = useQuery({
|
||||
...attributesQuery,
|
||||
select: (row) => row?.log_attributes ?? null,
|
||||
enabled: canFetchAttributes && attributesQuery.enabled,
|
||||
})
|
||||
const attributes = logsMetadata ? (row.metadata ?? fetchedAttributes) : undefined
|
||||
|
||||
@@ -38,7 +38,10 @@ describe('log attributes', () => {
|
||||
})
|
||||
},
|
||||
})
|
||||
await expect(fetchAttributes()).resolves.toEqual({ 'request.method': 'GET' })
|
||||
await expect(fetchAttributes()).resolves.toEqual({
|
||||
source: 'edge_logs',
|
||||
log_attributes: { 'request.method': 'GET' },
|
||||
})
|
||||
expect(body).toMatchObject({
|
||||
iso_timestamp_start: '2026-01-01T09:59:00.000Z',
|
||||
iso_timestamp_end: '2026-01-01T10:01:00.000Z',
|
||||
@@ -46,14 +49,14 @@ describe('log attributes', () => {
|
||||
})
|
||||
|
||||
it.each([{ result: [] }, { result: [{ source: 'edge_logs', log_attributes: null }] }])(
|
||||
'returns null for absent attributes: %j',
|
||||
'preserves the source when attributes are absent: %j',
|
||||
async ({ result }) => {
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
response: () => HttpResponse.json<LogsResponse>({ result }),
|
||||
})
|
||||
await expect(fetchAttributes()).resolves.toBeNull()
|
||||
await expect(fetchAttributes()).resolves.toEqual(result[0] ?? null)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ export async function runOtelLogsSql(variables: Omit<ExecuteAnalyticsSqlVariable
|
||||
return data?.result ?? []
|
||||
}
|
||||
|
||||
/** One log's source and attributes, or undefined when it can't be found. */
|
||||
export async function getLogAttributesRow(
|
||||
/** One log's source and attributes, or null when it can't be found. */
|
||||
async function getUnifiedLogAttributes(
|
||||
{ projectRef, logId, source, logTimestampMs }: UnifiedLogAttributesVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
@@ -60,15 +60,7 @@ export async function getLogAttributesRow(
|
||||
...getLogTimeWindow(logTimestampMs, LOG_WINDOW_MS),
|
||||
signal,
|
||||
})
|
||||
return z.array(logAttributesRowSchema).parse(result)[0]
|
||||
}
|
||||
|
||||
async function getUnifiedLogAttributes(
|
||||
variables: UnifiedLogAttributesVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const row = await getLogAttributesRow(variables, signal)
|
||||
return row?.log_attributes ?? null
|
||||
return z.array(logAttributesRowSchema).parse(result)[0] ?? null
|
||||
}
|
||||
|
||||
export type UnifiedLogAttributesData = Awaited<ReturnType<typeof getUnifiedLogAttributes>>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { platformComponents } from 'api-types'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { unifiedLogAttributesQueryOptions } from './unified-log-attributes-query'
|
||||
import { unifiedLogRequestTimelineQueryOptions } from './unified-log-request-timeline-query'
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
|
||||
@@ -34,6 +35,86 @@ const step = (id: string, source: string, metadata: Record<string, string>) => (
|
||||
})
|
||||
|
||||
describe('request timeline query', () => {
|
||||
it.each(['attributes first', 'timeline first', 'concurrently'])(
|
||||
'shares the attribute lookup with the overview: %s',
|
||||
async (order) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const root = { source: 'edge_logs', log_attributes: { 'request.headers.cf_ray': 'ray-1' } }
|
||||
const related = step('invocation', 'function_edge_logs', {})
|
||||
const requests: string[] = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
response: async ({ request }) => {
|
||||
const { sql } = (await request.json()) as { sql: string }
|
||||
requests.push(sql)
|
||||
return HttpResponse.json<LogsResponse>({
|
||||
result: sql.includes('log attributes') ? [root] : [related],
|
||||
})
|
||||
},
|
||||
})
|
||||
const attributes = () => client.fetchQuery(unifiedLogAttributesQueryOptions(variables))
|
||||
const timeline = () => client.fetchQuery(unifiedLogRequestTimelineQueryOptions(variables))
|
||||
|
||||
if (order === 'attributes first') {
|
||||
await attributes()
|
||||
await timeline()
|
||||
} else if (order === 'timeline first') {
|
||||
await timeline()
|
||||
await attributes()
|
||||
} else {
|
||||
await Promise.all([attributes(), timeline()])
|
||||
}
|
||||
|
||||
expect(client.getQueryData(unifiedLogAttributesQueryOptions(variables).queryKey)).toEqual(
|
||||
root
|
||||
)
|
||||
expect(
|
||||
client
|
||||
.getQueryData(unifiedLogRequestTimelineQueryOptions(variables).queryKey)
|
||||
?.logs.map((log) => log.id)
|
||||
).toEqual(['invocation'])
|
||||
// Request count is the contract here: consumers share both cached and in-flight data.
|
||||
expect(requests.filter((sql) => sql.includes('log attributes'))).toHaveLength(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('cancels a timeline without cancelling the shared overview lookup', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
let releaseResponse = () => {}
|
||||
const responseReady = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve
|
||||
})
|
||||
let markStarted = () => {}
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve
|
||||
})
|
||||
const root = { source: 'edge_logs', log_attributes: { 'request.headers.cf_ray': 'ray-1' } }
|
||||
const requests: string[] = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
response: async ({ request }) => {
|
||||
const { sql } = (await request.json()) as { sql: string }
|
||||
requests.push(sql)
|
||||
markStarted()
|
||||
await responseReady
|
||||
return HttpResponse.json<LogsResponse>({ result: [root] })
|
||||
},
|
||||
})
|
||||
const timelineOptions = unifiedLogRequestTimelineQueryOptions(variables)
|
||||
const cancelled = expect(client.fetchQuery(timelineOptions)).rejects.toThrow()
|
||||
const attributes = client.fetchQuery(unifiedLogAttributesQueryOptions(variables))
|
||||
await started
|
||||
await client.cancelQueries({ queryKey: timelineOptions.queryKey, exact: true })
|
||||
releaseResponse()
|
||||
|
||||
await cancelled
|
||||
await expect(attributes).resolves.toEqual(root)
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('follows an execution ID from a request and stops after two lookups', async () => {
|
||||
const requests: { sql: string; iso_timestamp_start: string; iso_timestamp_end: string }[] = []
|
||||
const invocation = step('invocation', 'function_edge_logs', { execution_id: 'exec-1' })
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { queryOptions, type QueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { logsKeys } from './keys'
|
||||
import {
|
||||
getLogAttributesRow,
|
||||
getLogTimeWindow,
|
||||
runOtelLogsSql,
|
||||
unifiedLogAttributesQueryOptions,
|
||||
type UnifiedLogAttributesVariables,
|
||||
} from './unified-log-attributes-query'
|
||||
import { UNIFIED_LOGS_QUERY_OPTIONS } from './unified-logs-infinite-query'
|
||||
import { mapUnifiedLogRow, unifiedLogsQueryRowSchema } from './unified-logs.utils'
|
||||
@@ -28,13 +29,7 @@ const MAX_LOOKUPS = 2
|
||||
|
||||
const timelineRowSchema = unifiedLogsQueryRowSchema.extend({ source: z.string() })
|
||||
|
||||
export type UnifiedLogRequestTimelineVariables = {
|
||||
projectRef?: string
|
||||
logId?: string
|
||||
/** Source of the selected log, e.g. `edge_logs`. */
|
||||
source?: string
|
||||
logTimestampMs?: number | null
|
||||
}
|
||||
export type UnifiedLogRequestTimelineVariables = UnifiedLogAttributesVariables
|
||||
|
||||
export type UnifiedLogRequestTimelineError = ResponseError
|
||||
|
||||
@@ -44,6 +39,7 @@ const hasNewIds = (next: RequestCorrelationIds, prev: RequestCorrelationIds) =>
|
||||
|
||||
async function getUnifiedLogRequestTimeline(
|
||||
{ projectRef, logId, source, logTimestampMs }: UnifiedLogRequestTimelineVariables,
|
||||
queryClient: QueryClient,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
@@ -53,7 +49,11 @@ async function getUnifiedLogRequestTimeline(
|
||||
throw new Error('logTimestampMs is required')
|
||||
}
|
||||
|
||||
const root = await getLogAttributesRow({ projectRef, logId, source, logTimestampMs }, signal)
|
||||
const root = await queryClient.fetchQuery(
|
||||
unifiedLogAttributesQueryOptions({ projectRef, logId, source, logTimestampMs })
|
||||
)
|
||||
// A shared attribute lookup may outlive this timeline request.
|
||||
signal?.throwIfAborted()
|
||||
|
||||
let ids = getRequestCorrelationIds(
|
||||
root ? [{ source: root.source, attributes: root.log_attributes }] : []
|
||||
@@ -100,8 +100,8 @@ export const unifiedLogRequestTimelineQueryOptions = ({
|
||||
}: UnifiedLogRequestTimelineVariables) =>
|
||||
queryOptions<UnifiedLogRequestTimelineData, UnifiedLogRequestTimelineError>({
|
||||
queryKey: logsKeys.requestTimeline(projectRef, logId, source, logTimestampMs),
|
||||
queryFn: ({ signal }) =>
|
||||
getUnifiedLogRequestTimeline({ projectRef, logId, source, logTimestampMs }, signal),
|
||||
queryFn: ({ client, signal }) =>
|
||||
getUnifiedLogRequestTimeline({ projectRef, logId, source, logTimestampMs }, client, signal),
|
||||
enabled:
|
||||
IS_PLATFORM &&
|
||||
typeof projectRef !== 'undefined' &&
|
||||
|
||||
Reference in new issue
Block a user