Files
supabase__supabase/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.test.ts
Jordi Enric 982d860123 feat(functions): migrate last-hour stats query to OTEL ClickHouse (#47693)
## What

Migrates the `edge-functions-last-hour-stats` query (requests + server
error counts shown on the Edge Functions list) from the BigQuery-style
`logs.all` endpoint to the OTEL/ClickHouse `logs.all.otel` endpoint.

<img width="2378" height="958" alt="CleanShot 2026-07-07 at 16 24 43@2x"
src="https://github.com/user-attachments/assets/5bf3f04c-43e1-44a3-af28-d53feee27f68"
/>

## How

- Adds an OTEL SQL builder that reads from the single `logs` table
(`source = 'function_edge_logs'`), using `log_attributes['function_id']`
and `toInt32OrZero(log_attributes['response.status_code'])` instead of
`cross join unnest(metadata)`.
- Gated by the `otelLegacyLogs` flag, matching the rest of the logs
code. The BigQuery path is preserved when the flag is off, and the two
paths cache under separate query keys.

## Testing

- Unit tests cover both endpoints and assert the generated SQL for each
path.
- Go to edge fns list
- stats load correctly

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for using the OTEL logs backend for edge function
last-hour stats when enabled.
* Queries and caching now automatically distinguish between the standard
and OTEL-backed data sources.
* **Bug Fixes**
* Ensured stats results are fetched from the correct endpoint based on
the selected logging backend.
  * Added coverage to verify OTEL-specific SQL and response behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-08 17:16:08 +02:00

141 lines
4.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getEdgeFunctionsLastHourStats } from './edge-functions-last-hour-stats-query'
import { post } from '@/data/fetchers'
vi.mock('@/data/fetchers', () => ({
post: vi.fn(),
handleError: vi.fn(),
}))
type PostResponse = Awaited<ReturnType<typeof post>>
describe('getEdgeFunctionsLastHourStats', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z'))
vi.mocked(post).mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('requests last-hour function stats from logs.all by default', async () => {
vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse)
await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1', 'fn_2'],
})
expect(post).toHaveBeenCalledWith('/platform/projects/{ref}/analytics/endpoints/logs.all', {
params: {
path: { ref: 'project-ref' },
query: { key: 'last-hour-stats' },
},
body: expect.objectContaining({
sql: expect.stringContaining(`and function_id in ('fn_1', 'fn_2')`),
iso_timestamp_start: '2024-01-15T11:00:00.000Z',
iso_timestamp_end: '2024-01-15T12:00:00.000Z',
}),
signal: undefined,
})
const postCalls = vi.mocked(post).mock.calls as Array<
[string, { body?: { sql?: string } } | undefined]
>
expect(postCalls[0]?.[1]?.body?.sql).toContain('from\n function_edge_logs')
})
it('requests last-hour function stats from logs.all.otel when useOtel is set', async () => {
vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse)
await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1', 'fn_2'],
useOtel: true,
})
expect(post).toHaveBeenCalledWith(
'/platform/projects/{ref}/analytics/endpoints/logs.all.otel',
{
params: {
path: { ref: 'project-ref' },
query: { key: 'last-hour-stats' },
},
body: expect.objectContaining({
sql: expect.stringContaining(`and log_attributes['function_id'] in ('fn_1', 'fn_2')`),
iso_timestamp_start: '2024-01-15T11:00:00.000Z',
iso_timestamp_end: '2024-01-15T12:00:00.000Z',
}),
signal: undefined,
}
)
const postCalls = vi.mocked(post).mock.calls as Array<
[string, { body?: { sql?: string } } | undefined]
>
const sql = postCalls[0]?.[1]?.body?.sql ?? ''
expect(sql).toContain('from logs')
expect(sql).toContain("source = 'function_edge_logs'")
expect(sql).toContain("case when toInt32OrZero(log_attributes['response.status_code']) >= 500")
expect(sql).not.toContain('cross join unnest')
})
it('coerces counts to numbers and computes error rates per function', async () => {
vi.mocked(post).mockResolvedValue({
data: {
result: [
{ function_id: 'fn_1', requests_count: '100', server_err_count: '5' },
{ function_id: 'fn_2', requests_count: 8, server_err_count: 0 },
],
},
error: null,
} as PostResponse)
const result = await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1', 'fn_2'],
})
expect(result).toEqual({
fn_1: {
functionId: 'fn_1',
requestsCount: 100,
serverErrorCount: 5,
errorRate: 5,
},
fn_2: {
functionId: 'fn_2',
requestsCount: 8,
serverErrorCount: 0,
errorRate: 0,
},
})
})
it('handles empty results', async () => {
vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse)
const result = await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1'],
})
expect(result).toEqual({})
})
it('skips the logs query when there are no function ids', async () => {
const result = await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: [],
})
expect(result).toEqual({})
expect(post).not.toHaveBeenCalled()
})
})