-
Notifications
You must be signed in to change notification settings - Fork 59
refactor(agentex): remove the legacy Postgres spans API, table and UI reader #430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mohammadatallah-scale
wants to merge
4
commits into
main
Choose a base branch
from
mohammad/remove-legacy-spans
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ff44e59
refactor(agentex): remove the legacy Postgres spans API, table and UI…
mohammadatallah-scale 2e95161
fix(agentex-ui): scope the spans cache to the account and abort the p…
mohammadatallah-scale cfe5f6c
fix(agentex-ui): anchor the platform span search on the task and acce…
mohammadatallah-scale caeb229
fix(agentex-ui): retry a span read once after a session refresh
mohammadatallah-scale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
161 changes: 161 additions & 0 deletions
161
agentex-ui/app/api/traces/[traceId]/spans/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { GET } from './route'; | ||
|
|
||
| const bff = vi.hoisted(() => ({ | ||
| baseURL: 'https://sgp.example/api' as string | undefined, | ||
| applyBffCredentials: vi.fn(async (_req: Request, headers: Headers) => { | ||
| headers.set('authorization', 'Bearer server-side'); | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('@/app/api/_lib/bff', () => ({ | ||
| get SGP_BASE_URL() { | ||
| return bff.baseURL; | ||
| }, | ||
| applyBffCredentials: bff.applyBffCredentials, | ||
| })); | ||
|
|
||
| function call(traceId: string, init?: RequestInit, search = '') { | ||
| return GET( | ||
| new Request(`http://ui.local/api/traces/${traceId}/spans${search}`, init), | ||
| { params: Promise.resolve({ traceId }) } | ||
| ); | ||
| } | ||
|
|
||
| function upstreamURL(fetchMock: ReturnType<typeof vi.fn>) { | ||
| return new URL(fetchMock.mock.calls[0]![0] as string); | ||
| } | ||
|
|
||
| describe('GET /api/traces/[traceId]/spans', () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| bff.baseURL = 'https://sgp.example/api'; | ||
| }); | ||
|
|
||
| it('searches the platform for the trace with server-attached credentials', async () => { | ||
| const page = { items: [{ id: 's1', trace_id: 't1' }], has_more: false }; | ||
| const fetchMock = vi.fn().mockResolvedValue( | ||
| new Response(JSON.stringify(page), { | ||
| status: 200, | ||
| headers: { 'content-type': 'application/json' }, | ||
| }) | ||
| ); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| const res = await call('t1'); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(await res.json()).toEqual(page); | ||
| expect(bff.applyBffCredentials).toHaveBeenCalledTimes(1); | ||
| const [url, init] = fetchMock.mock.calls[0]!; | ||
| expect(url).toBe( | ||
| 'https://sgp.example/api/v5/spans/search?limit=100&sort_by=start_timestamp&sort_order=asc&allow_short_pages=true' | ||
| ); | ||
| expect(init.method).toBe('POST'); | ||
| expect(JSON.parse(init.body)).toEqual({ trace_ids: ['t1'] }); | ||
| expect(new Headers(init.headers).get('authorization')).toBe( | ||
| 'Bearer server-side' | ||
| ); | ||
| expect(init.signal).toBeInstanceOf(AbortSignal); | ||
| }); | ||
|
|
||
| it('answers 499 when the browser aborts before the platform replies', async () => { | ||
| const controller = new AbortController(); | ||
| vi.stubGlobal( | ||
| 'fetch', | ||
| vi.fn((_url: string, init: RequestInit) => { | ||
| const signal = init.signal as AbortSignal; | ||
| return new Promise<Response>((_resolve, reject) => { | ||
| if (signal.aborted) reject(signal.reason); | ||
| signal.addEventListener('abort', () => reject(signal.reason)); | ||
| }); | ||
| }) | ||
| ); | ||
|
|
||
| const pending = call('t1', { signal: controller.signal }); | ||
| controller.abort(); | ||
| const res = await pending; | ||
|
|
||
| expect(res.status).toBe(499); | ||
| }); | ||
|
|
||
| it('anchors the search window on the task creation time', async () => { | ||
| const fetchMock = vi | ||
| .fn() | ||
| .mockResolvedValue(new Response('{"items":[]}', { status: 200 })); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
| const from = '2026-01-01T00:00:00.000Z'; | ||
|
|
||
| await call('t1', undefined, `?from=${encodeURIComponent(from)}`); | ||
|
|
||
| const params = upstreamURL(fetchMock).searchParams; | ||
| const fromTs = Date.parse(params.get('from_ts')!); | ||
| const toTs = Date.parse(params.get('to_ts')!); | ||
| expect(fromTs).toBe(Date.parse(from) - 5 * 60 * 1000); | ||
| expect(toTs - fromTs).toBe(90 * 24 * 60 * 60 * 1000 - 60 * 1000); | ||
| }); | ||
|
|
||
| it('caps the window at now for a recent task', async () => { | ||
| const fetchMock = vi | ||
| .fn() | ||
| .mockResolvedValue(new Response('{"items":[]}', { status: 200 })); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
| const from = new Date(Date.now() - 60 * 60 * 1000).toISOString(); | ||
|
|
||
| await call('t1', undefined, `?from=${encodeURIComponent(from)}`); | ||
|
|
||
| const toTs = Date.parse(upstreamURL(fetchMock).searchParams.get('to_ts')!); | ||
| expect(Date.now() - toTs).toBeLessThan(5000); | ||
| }); | ||
|
|
||
| it('sends no window without a creation time', async () => { | ||
| const fetchMock = vi | ||
| .fn() | ||
| .mockResolvedValue(new Response('{"items":[]}', { status: 200 })); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| await call('t1'); | ||
|
|
||
| const params = upstreamURL(fetchMock).searchParams; | ||
| expect(params.has('from_ts')).toBe(false); | ||
| expect(params.has('to_ts')).toBe(false); | ||
| }); | ||
|
|
||
| it('rejects a creation time that is not a timestamp', async () => { | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| const res = await call('t1', undefined, '?from=yesterday'); | ||
|
|
||
| expect(res.status).toBe(400); | ||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('passes the upstream status through', async () => { | ||
| vi.stubGlobal( | ||
| 'fetch', | ||
| vi | ||
| .fn() | ||
| .mockResolvedValue( | ||
| new Response(JSON.stringify({ detail: 'forbidden' }), { status: 403 }) | ||
| ) | ||
| ); | ||
|
|
||
| const res = await call('t1'); | ||
|
|
||
| expect(res.status).toBe(403); | ||
| expect(await res.json()).toEqual({ detail: 'forbidden' }); | ||
| }); | ||
|
|
||
| it('returns 503 when the platform API is not configured', async () => { | ||
| bff.baseURL = undefined; | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| const res = await call('t1'); | ||
|
|
||
| expect(res.status).toBe(503); | ||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { NextResponse } from 'next/server'; | ||
|
|
||
| import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff'; | ||
|
|
||
| /** | ||
| * Scoped BFF proxy for one trace's spans, read from the platform's span search with the | ||
| * credentials attached server-side. Only this path is exposed, not a catch-all, so the | ||
| * browser can't reach arbitrary platform endpoints with those credentials. | ||
| */ | ||
| export const dynamic = 'force-dynamic'; | ||
|
|
||
| // The sidebar shows one page and reports the rest through has_more. | ||
| const PAGE_SIZE = 100; | ||
| // The platform refuses a window wider than 90 days, and it defaults an omitted window to the | ||
| // last 90 days, which hides older tasks. Anchor the window on the task's creation instead. | ||
| const WINDOW_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000; | ||
| const SKEW_MS = 5 * 60 * 1000; | ||
|
|
||
| function searchWindow(from: string | null): Record<string, string> | null { | ||
| if (from === null) return {}; | ||
| const start = Date.parse(from); | ||
| if (Number.isNaN(start)) return null; | ||
| const fromTs = start - SKEW_MS; | ||
| const toTs = Math.min(Date.now(), fromTs + WINDOW_MS); | ||
| return { | ||
| from_ts: new Date(fromTs).toISOString(), | ||
| to_ts: new Date(toTs).toISOString(), | ||
| }; | ||
| } | ||
|
|
||
| export async function GET( | ||
| request: Request, | ||
| ctx: { params: Promise<{ traceId: string }> } | ||
| ): Promise<Response> { | ||
| if (!SGP_BASE_URL) { | ||
| return NextResponse.json( | ||
| { error: 'SGP traces are not configured. Set SGP_API_URL.' }, | ||
| { status: 503 } | ||
| ); | ||
| } | ||
|
|
||
| const { traceId } = await ctx.params; | ||
| const window = searchWindow(new URL(request.url).searchParams.get('from')); | ||
| if (window === null) { | ||
| return NextResponse.json( | ||
| { error: 'from must be an ISO timestamp' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
| const headers = new Headers({ | ||
| 'Content-Type': 'application/json', | ||
| accept: 'application/json', | ||
| }); | ||
| await applyBffCredentials(request, headers); | ||
|
|
||
| const query = new URLSearchParams({ | ||
| limit: String(PAGE_SIZE), | ||
| sort_by: 'start_timestamp', | ||
| sort_order: 'asc', | ||
| // Over the byte budget the platform shortens the page instead of refusing it. | ||
| allow_short_pages: 'true', | ||
| ...window, | ||
| }); | ||
| let upstream: Response; | ||
| try { | ||
| upstream = await fetch(`${SGP_BASE_URL}/v5/spans/search?${query}`, { | ||
| method: 'POST', | ||
| headers, | ||
| body: JSON.stringify({ trace_ids: [traceId] }), | ||
| signal: request.signal, | ||
| }); | ||
| } catch (error) { | ||
| // A browser that navigated away aborts the request, and the abort reason is what the | ||
| // fetch rejects with, so the same identity check the Agentex proxy uses applies here. | ||
| if (error === request.signal.reason) { | ||
| return new Response(null, { status: 499 }); | ||
| } | ||
| throw error; | ||
| } | ||
| return new Response(upstream.body, { | ||
| status: upstream.status, | ||
| headers: { 'content-type': 'application/json' }, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,8 @@ | ||
| export { AgentexProvider, useAgentexClient } from './agentex-provider'; | ||
| export { | ||
| AgentexProvider, | ||
| refreshSession, | ||
| useAgentexClient, | ||
| } from './agentex-provider'; | ||
| export { TaskProvider } from './task-provider'; | ||
| export { ThemeProvider } from './theme-provider'; | ||
| export { QueryProvider } from './query-provider'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.