-
Notifications
You must be signed in to change notification settings - Fork 392
feat: add query_logs tool for custom log queries #333
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
base: main
Are you sure you want to change the base?
Changes from all commits
c7b17e4
e45c78c
776a694
67f51d2
3fa70e2
86acca1
f275527
8d9ddcf
fe33002
8128967
09e521e
0f27120
ab36b09
59526f0
a8e5a98
286e9dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ import { | |
| deployEdgeFunctionOptionsSchema, | ||
| executeSqlOptionsSchema, | ||
| getLogsOptionsSchema, | ||
| queryLogsOptionsSchema, | ||
| resetBranchOptionsSchema, | ||
| type AccountOperations, | ||
| type ApiKey, | ||
|
|
@@ -38,6 +39,7 @@ import { | |
| type EdgeFunctionWithBody, | ||
| type ExecuteSqlOptions, | ||
| type GetLogsOptions, | ||
| type QueryLogsOptions, | ||
| type ResetBranchOptions, | ||
| type StorageConfig, | ||
| type StorageOperations, | ||
|
|
@@ -277,6 +279,30 @@ export function createSupabaseApiPlatform( | |
|
|
||
| return response.data; | ||
| }, | ||
| async queryLogs(projectId: string, options: QueryLogsOptions) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: Does |
||
| const { sql, iso_timestamp_start, iso_timestamp_end } = | ||
| queryLogsOptionsSchema.parse(options); | ||
|
|
||
| const response = await managementApiClient.GET( | ||
| '/v1/projects/{ref}/analytics/endpoints/logs', | ||
| { | ||
| params: { | ||
| path: { | ||
| ref: projectId, | ||
| }, | ||
| query: { | ||
| sql, | ||
| iso_timestamp_start, | ||
| iso_timestamp_end, | ||
| }, | ||
| }, | ||
| } | ||
| ); | ||
|
|
||
| assertSuccess(response, 'Failed to query logs'); | ||
|
|
||
| return response.data; | ||
| }, | ||
| async getSecurityAdvisors(projectId: string) { | ||
| const response = await managementApiClient.GET( | ||
| '/v1/projects/{ref}/advisors/security', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2072,6 +2072,259 @@ describe('tools', () => { | |
| ); | ||
| }); | ||
|
|
||
| test('query logs forwards custom sql and defaults the timestamp window', async () => { | ||
| const { callTool } = await setup(); | ||
|
|
||
| const org = await createOrganization({ | ||
| name: 'My Org', | ||
| plan: 'free', | ||
| allowed_release_channels: ['ga'], | ||
| }); | ||
|
|
||
| const project = await createProject({ | ||
| name: 'Project 1', | ||
| region: 'us-east-1', | ||
| organization_id: org.id, | ||
| }); | ||
| project.status = 'ACTIVE_HEALTHY'; | ||
|
|
||
| const capturedSearchParams: URLSearchParams[] = []; | ||
|
|
||
| mockServer?.use( | ||
| http.get<{ projectId: string }>( | ||
| `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, | ||
| ({ params, request }) => { | ||
| expect(params.projectId).toBe(project.id); | ||
| capturedSearchParams.push(new URL(request.url).searchParams); | ||
|
|
||
| return HttpResponse.json([]); | ||
| } | ||
| ) | ||
| ); | ||
|
|
||
| const sql = | ||
| "select id, timestamp, event_message from logs where source = 'postgres_logs' order by timestamp desc limit 10"; | ||
|
|
||
| const before = Date.now(); | ||
| const { result } = await callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql, | ||
| }, | ||
| }); | ||
| const after = Date.now(); | ||
|
|
||
| expect(result).toContain('untrusted-data'); | ||
| expect(capturedSearchParams).toHaveLength(1); | ||
| expect(capturedSearchParams[0]?.get('sql')).toBe(sql); | ||
|
|
||
| const end = capturedSearchParams[0]?.get('iso_timestamp_end'); | ||
| const start = capturedSearchParams[0]?.get('iso_timestamp_start'); | ||
| const endMs = Date.parse(end!); | ||
|
|
||
| expect(endMs).toBeGreaterThanOrEqual(before); | ||
| expect(endMs).toBeLessThanOrEqual(after); | ||
| expect(start).toBe(new Date(endMs - 24 * 60 * 60 * 1000).toISOString()); | ||
| }); | ||
|
|
||
| test('query logs forwards a custom timestamp window', async () => { | ||
| const { callTool } = await setup(); | ||
|
|
||
| const org = await createOrganization({ | ||
| name: 'My Org', | ||
| plan: 'free', | ||
| allowed_release_channels: ['ga'], | ||
| }); | ||
|
|
||
| const project = await createProject({ | ||
| name: 'Project 1', | ||
| region: 'us-east-1', | ||
| organization_id: org.id, | ||
| }); | ||
| project.status = 'ACTIVE_HEALTHY'; | ||
|
|
||
| const capturedSearchParams: URLSearchParams[] = []; | ||
|
|
||
| mockServer?.use( | ||
| http.get<{ projectId: string }>( | ||
| `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, | ||
| ({ request }) => { | ||
| capturedSearchParams.push(new URL(request.url).searchParams); | ||
| return HttpResponse.json([]); | ||
| } | ||
| ) | ||
| ); | ||
|
|
||
| const isoTimestampStart = '2024-02-01T10:00:00.000Z'; | ||
| const isoTimestampEnd = '2024-02-01T11:00:00.000Z'; | ||
|
|
||
| await callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql: 'select id from logs limit 1', | ||
| iso_timestamp_start: isoTimestampStart, | ||
| iso_timestamp_end: isoTimestampEnd, | ||
| }, | ||
| }); | ||
|
|
||
| expect(capturedSearchParams).toHaveLength(1); | ||
| expect(capturedSearchParams[0]?.get('iso_timestamp_start')).toBe( | ||
| isoTimestampStart | ||
| ); | ||
| expect(capturedSearchParams[0]?.get('iso_timestamp_end')).toBe( | ||
| isoTimestampEnd | ||
| ); | ||
| }); | ||
|
|
||
| test('query logs anchors the default start to a supplied end', async () => { | ||
| const { callTool } = await setup(); | ||
|
|
||
| const org = await createOrganization({ | ||
| name: 'My Org', | ||
| plan: 'free', | ||
| allowed_release_channels: ['ga'], | ||
| }); | ||
|
|
||
| const project = await createProject({ | ||
| name: 'Project 1', | ||
| region: 'us-east-1', | ||
| organization_id: org.id, | ||
| }); | ||
| project.status = 'ACTIVE_HEALTHY'; | ||
|
|
||
| const capturedSearchParams: URLSearchParams[] = []; | ||
|
|
||
| mockServer?.use( | ||
| http.get<{ projectId: string }>( | ||
| `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, | ||
| ({ request }) => { | ||
| capturedSearchParams.push(new URL(request.url).searchParams); | ||
| return HttpResponse.json([]); | ||
| } | ||
| ) | ||
| ); | ||
|
|
||
| const isoTimestampEnd = '2024-02-01T11:00:00.000Z'; | ||
|
|
||
| await callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql: 'select id from logs limit 1', | ||
| iso_timestamp_end: isoTimestampEnd, | ||
| }, | ||
| }); | ||
|
|
||
| expect(capturedSearchParams).toHaveLength(1); | ||
| expect(capturedSearchParams[0]?.get('iso_timestamp_end')).toBe( | ||
| isoTimestampEnd | ||
| ); | ||
| const expectedStart = new Date( | ||
| new Date(isoTimestampEnd).getTime() - 24 * 60 * 60 * 1000 | ||
| ).toISOString(); | ||
| expect(capturedSearchParams[0]?.get('iso_timestamp_start')).toBe( | ||
| expectedStart | ||
| ); | ||
| }); | ||
|
|
||
| test('query logs rejects a malformed iso_timestamp_end', async () => { | ||
| const { callTool } = await setup(); | ||
|
|
||
| const org = await createOrganization({ | ||
| name: 'My Org', | ||
| plan: 'free', | ||
| allowed_release_channels: ['ga'], | ||
| }); | ||
|
|
||
| const project = await createProject({ | ||
| name: 'Project 1', | ||
| region: 'us-east-1', | ||
| organization_id: org.id, | ||
| }); | ||
| project.status = 'ACTIVE_HEALTHY'; | ||
|
|
||
| await expect( | ||
| callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql: 'select id from logs limit 1', | ||
| iso_timestamp_end: 'not-a-timestamp', | ||
| }, | ||
| }) | ||
| ).rejects.toThrow(/Invalid ISO datetime/); | ||
| }); | ||
|
|
||
| test('query logs rejects a start at or after the end', async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Adding the equal-timestamps case, and giving these negative tests a message matcher ( Same theme at line 2075: the default-window assertions only check the params are truthy, an exact assertion would catch a wrong default or swapped bounds. |
||
| const { callTool } = await setup(); | ||
|
|
||
| const org = await createOrganization({ | ||
| name: 'My Org', | ||
| plan: 'free', | ||
| allowed_release_channels: ['ga'], | ||
| }); | ||
|
|
||
| const project = await createProject({ | ||
| name: 'Project 1', | ||
| region: 'us-east-1', | ||
| organization_id: org.id, | ||
| }); | ||
| project.status = 'ACTIVE_HEALTHY'; | ||
|
|
||
| await expect( | ||
| callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql: 'select id from logs limit 1', | ||
| iso_timestamp_start: '2024-02-01T11:00:00.000Z', | ||
| iso_timestamp_end: '2024-02-01T10:00:00.000Z', | ||
| }, | ||
| }) | ||
| ).rejects.toThrow(/must be before/); | ||
|
|
||
| await expect( | ||
| callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql: 'select id from logs limit 1', | ||
| iso_timestamp_start: '2024-02-01T10:00:00.000Z', | ||
| iso_timestamp_end: '2024-02-01T10:00:00.000Z', | ||
| }, | ||
| }) | ||
| ).rejects.toThrow(/must be before/); | ||
| }); | ||
|
|
||
| test('query logs rejects an empty sql query', async () => { | ||
| const { callTool } = await setup(); | ||
|
|
||
| const org = await createOrganization({ | ||
| name: 'My Org', | ||
| plan: 'free', | ||
| allowed_release_channels: ['ga'], | ||
| }); | ||
|
|
||
| const project = await createProject({ | ||
| name: 'Project 1', | ||
| region: 'us-east-1', | ||
| organization_id: org.id, | ||
| }); | ||
| project.status = 'ACTIVE_HEALTHY'; | ||
|
|
||
| await expect( | ||
| callTool({ | ||
| name: 'query_logs', | ||
| arguments: { | ||
| project_id: project.id, | ||
| sql: '', | ||
| }, | ||
| }) | ||
| ).rejects.toThrow(/too_small|at least 1 character/); | ||
| }); | ||
|
|
||
| test('get security advisors', async () => { | ||
| const { callTool } = await setup(); | ||
|
|
||
|
|
@@ -3741,6 +3994,28 @@ describe('feature groups', () => { | |
| const { tools } = await client.listTools(); | ||
| const toolNames = tools.map((tool) => tool.name); | ||
|
|
||
| expect(toolNames).toEqual(['get_logs', 'query_logs', 'get_advisors']); | ||
| }); | ||
|
|
||
| test('debugging tools omit query_logs when the platform does not implement it', async () => { | ||
| const platform: SupabasePlatform = { | ||
| debugging: { | ||
| getLogs() { | ||
| throw new Error('Not implemented'); | ||
| }, | ||
| getSecurityAdvisors() { | ||
| throw new Error('Not implemented'); | ||
| }, | ||
| getPerformanceAdvisors() { | ||
| throw new Error('Not implemented'); | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| const { client } = await setup({ platform, features: ['debugging'] }); | ||
| const { tools } = await client.listTools(); | ||
| const toolNames = tools.map((tool) => tool.name); | ||
|
|
||
| expect(toolNames).toEqual(['get_logs', 'get_advisors']); | ||
| }); | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.