From e09ffb024b0915496397b917716aa04140fecfd4 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 24 Sep 2026 16:31:07 +1000 Subject: [PATCH] refactor(studio): share cached log attributes with request timelines --- .../UnifiedLogs/components/LogDetail.tsx | 1 + .../logs/unified-log-attributes-query.test.ts | 9 ++- .../data/logs/unified-log-attributes-query.ts | 14 +--- ...unified-log-request-timeline-query.test.ts | 81 +++++++++++++++++++ .../unified-log-request-timeline-query.ts | 24 +++--- 5 files changed, 103 insertions(+), 26 deletions(-) diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogDetail.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogDetail.tsx index 9d2189928ac..711fccf2e7f 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/LogDetail.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogDetail.tsx @@ -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 diff --git a/apps/studio/data/logs/unified-log-attributes-query.test.ts b/apps/studio/data/logs/unified-log-attributes-query.test.ts index 17cde829fdd..22573f5f2b9 100644 --- a/apps/studio/data/logs/unified-log-attributes-query.test.ts +++ b/apps/studio/data/logs/unified-log-attributes-query.test.ts @@ -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({ result }), }) - await expect(fetchAttributes()).resolves.toBeNull() + await expect(fetchAttributes()).resolves.toEqual(result[0] ?? null) } ) diff --git a/apps/studio/data/logs/unified-log-attributes-query.ts b/apps/studio/data/logs/unified-log-attributes-query.ts index aa165cffd7c..ffc1c6dfdd0 100644 --- a/apps/studio/data/logs/unified-log-attributes-query.ts +++ b/apps/studio/data/logs/unified-log-attributes-query.ts @@ -42,8 +42,8 @@ export async function runOtelLogsSql(variables: Omit> diff --git a/apps/studio/data/logs/unified-log-request-timeline-query.test.ts b/apps/studio/data/logs/unified-log-request-timeline-query.test.ts index 17f7caae77a..d2b57aadf75 100644 --- a/apps/studio/data/logs/unified-log-request-timeline-query.test.ts +++ b/apps/studio/data/logs/unified-log-request-timeline-query.test.ts @@ -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) => ( }) 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({ + 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((resolve) => { + releaseResponse = resolve + }) + let markStarted = () => {} + const started = new Promise((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({ 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' }) diff --git a/apps/studio/data/logs/unified-log-request-timeline-query.ts b/apps/studio/data/logs/unified-log-request-timeline-query.ts index 026abdf16b8..15a54b75020 100644 --- a/apps/studio/data/logs/unified-log-request-timeline-query.ts +++ b/apps/studio/data/logs/unified-log-request-timeline-query.ts @@ -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({ 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' &&