diff --git a/packages/insomnia/src/konnect/__tests__/debug-session-api.test.ts b/packages/insomnia/src/konnect/__tests__/debug-session-api.test.ts new file mode 100644 index 0000000000..54e53795be --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/debug-session-api.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setFetchImplementation } from '../../common/insomnia-fetch'; +import { + createDebugSession, + type DebugSessionApiContext, + getDebugTrace, + listDebugSessions, + listDebugTraces, + listNodeIds, + stopDebugSession, +} from '../debug-session-api'; + +const ctx: DebugSessionApiContext = { pat: 'kpat_secret', region: 'us', controlPlaneId: 'cp-1' }; + +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); + +const mockFetch = (response: Response) => { + const impl = vi.fn().mockResolvedValue(response); + setFetchImplementation(impl); + return impl; +}; + +afterEach(() => { + setFetchImplementation((input, init) => globalThis.fetch(input, init)); +}); + +describe('debug-api', () => { + it('sends the PAT as a bearer token to the regional origin', async () => { + const impl = mockFetch(json({ data: [] })); + await listDebugSessions(ctx); + const [url, init] = impl.mock.calls[0]; + expect(url).toMatch(/^https:\/\/us\..+\/v1\/control-planes\/cp-1\/o11y\/debug-sessions\?/); + expect(init.headers.Authorization).toBe(`Bearer ${ctx.pat}`); + expect(init.headers['X-Session-Id']).toBeUndefined(); + }); + + it('creates a session with duration, targets and trace capture', async () => { + const impl = mockFetch(json({ id: 's1', duration_secs: 30, started_at: '2026-01-01T00:00:00Z', targets: ['n1'] })); + const session = await createDebugSession(ctx, 30, ['n1']); + const [url, init] = impl.mock.calls[0]; + expect(init.method).toBe('POST'); + expect(url).toContain('/v1/control-planes/cp-1/o11y/debug-sessions'); + expect(JSON.parse(init.body)).toMatchObject({ duration_secs: 30, targets: ['n1'], capture_traces: true }); + expect(session).toMatchObject({ id: 's1', durationSecs: 30, stoppedAt: null, timedOut: false }); + }); + + it('posts to the stop endpoint', async () => { + const impl = mockFetch(json({})); + await stopDebugSession(ctx, 's1'); + const [url, init] = impl.mock.calls[0]; + expect(url).toContain('/debug-sessions/s1/stop'); + expect(init.method).toBe('POST'); + }); + + it('requests span-scoped fields and reads the unprefixed attributes from the response', async () => { + mockFetch( + json({ + traces: [ + { + trace_id: 't1', + root_trace_name: 'kong', + start_time_unix_nano: '10', + duration_ms: 5, + span_sets: [ + { spans: [{ attributes: { 'url.full': 'http://x/airports', 'http.request.method': 'GET' } }] }, + { spans: [{ attributes: { 'http.response.status_code': 200, 'http.route': '/airports' } }] }, + ], + }, + ], + }), + ); + const [trace] = await listDebugTraces(ctx, 's1'); + expect(trace).toMatchObject({ + traceId: 't1', + method: 'GET', + url: 'http://x/airports', + route: '/airports', + statusCode: 200, + }); + }); + + it('flattens trace detail spans', async () => { + mockFetch( + json({ + resource_spans: [ + { + scope_spans: [ + { spans: [{ span_id: 'a', name: 'kong', start_time_unix_nano: '1', end_time_unix_nano: '2' }] }, + { spans: [{ span_id: 'b', parent_span_id: 'a', name: 'rewrite', start_time_unix_nano: '1' }] }, + ], + }, + ], + }), + ); + const detail = await getDebugTrace(ctx, 's1', 't1'); + expect(detail.spans.map(s => [s.spanId, s.parentSpanId])).toEqual([ + ['a', null], + ['b', 'a'], + ]); + }); + + it('reads node ids from either items or data', async () => { + mockFetch(json({ items: [{ id: 'n1' }, { id: 'n2' }] })); + expect(await listNodeIds(ctx)).toEqual(['n1', 'n2']); + }); + + it.each([ + [401, 'unauthorized'], + [403, 'forbidden'], + [404, 'not_found'], + [500, 'unknown'], + ])('maps HTTP %s to %s', async (status, kind) => { + mockFetch(json({ title: 'x' }, status)); + await expect(listDebugSessions(ctx)).rejects.toMatchObject({ kind }); + }); + + it('maps 429 to rate_limited and keeps Retry-After', async () => { + mockFetch(json({}, 429, { 'Retry-After': '7' })); + await expect(listDebugSessions(ctx)).rejects.toMatchObject({ kind: 'rate_limited', retryAfterMs: 7000 }); + }); + + it('maps thrown fetch errors to network', async () => { + setFetchImplementation(vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); + await expect(listDebugSessions(ctx)).rejects.toMatchObject({ kind: 'network' }); + }); +}); diff --git a/packages/insomnia/src/konnect/api.ts b/packages/insomnia/src/konnect/api.ts index fb3b5295ec..ff0fdd3538 100644 --- a/packages/insomnia/src/konnect/api.ts +++ b/packages/insomnia/src/konnect/api.ts @@ -236,7 +236,7 @@ export async function fetchRoutesForService( return routes.map(normalizeRoute); } -function regionalApiBase(region: string): string { +export function regionalApiBase(region: string): string { const url = getKonnectApiUrl(); // If KONNECT_API_URL is already a full URL (e.g. http://localhost:4010 in tests), // use it as-is without prepending a region subdomain or https scheme. diff --git a/packages/insomnia/src/konnect/debug-session-api.ts b/packages/insomnia/src/konnect/debug-session-api.ts new file mode 100644 index 0000000000..a7bce2c166 --- /dev/null +++ b/packages/insomnia/src/konnect/debug-session-api.ts @@ -0,0 +1,222 @@ +import { isApiError } from 'insomnia-api'; + +import { insomniaFetch } from '../common/insomnia-fetch'; +import { regionalApiBase } from './api'; + +const POLL_TIMEOUT_MS = 10_000; + +export interface DebugSessionApiContext { + pat: string; + region: string; + controlPlaneId: string; +} + +export interface DebugSession { + id: string; + name?: string; + durationSecs: number; + startedAt: string | null; + stoppedAt: string | null; + completedAt: string | null; + timedOut: boolean; + targets: string[]; +} + +export interface DebugTraceSummary { + traceId: string; + rootTraceName: string | null; + startTimeUnixNano: string; + durationMs: number; + method: string | null; + url: string | null; + route: string | null; + statusCode: number | null; +} + +export interface DebugSpan { + spanId: string; + parentSpanId: string | null; + name: string; + startTimeUnixNano: string; + endTimeUnixNano: string; + attributes: Record; +} + +export interface DebugTraceDetail { + traceId: string; + spans: DebugSpan[]; +} + +export type DebugErrorKind = 'unauthorized' | 'forbidden' | 'not_found' | 'rate_limited' | 'network' | 'unknown'; + +export class DebugApiError extends Error { + kind: DebugErrorKind; + retryAfterMs?: number; + constructor(kind: DebugErrorKind, message: string, retryAfterMs?: number) { + super(message); + this.name = 'DebugApiError'; + this.kind = kind; + this.retryAfterMs = retryAfterMs; + } +} + +export function toDebugApiError(err: unknown): DebugApiError { + if (err instanceof DebugApiError) { + return err; + } + if (isApiError(err)) { + const status = err.response.status; + if (status === 401) { + return new DebugApiError('unauthorized', 'Personal access token is invalid or expired.'); + } + if (status === 403) { + return new DebugApiError('forbidden', 'Personal access token lacks permission for the debugger.'); + } + if (status === 404) { + return new DebugApiError('not_found', 'Not found.'); + } + if (status === 429) { + const retryAfter = Number.parseInt(err.response.headers.get('Retry-After') ?? '', 10); + return new DebugApiError( + 'rate_limited', + 'Konnect rate limit reached.', + Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : undefined, + ); + } + return new DebugApiError('unknown', `Konnect returned ${status}.`); + } + const message = err instanceof Error ? err.message : 'Could not reach Konnect.'; + return new DebugApiError('network', message); +} + +async function request( + ctx: DebugSessionApiContext, + method: 'GET' | 'POST', + path: string, + options: { data?: unknown; timeout?: number } = {}, +): Promise { + try { + return await insomniaFetch({ + method, + path, + data: options.data, + timeout: options.timeout, + sessionId: null, + origin: regionalApiBase(ctx.region), + headers: { Authorization: `Bearer ${ctx.pat}` }, + }); + } catch (err) { + throw toDebugApiError(err); + } +} + +const sessionsPath = (ctx: DebugSessionApiContext) => `/v1/control-planes/${ctx.controlPlaneId}/o11y/debug-sessions`; + +type Raw = Record; + +function normalizeSession(raw: Raw): DebugSession { + return { + id: String(raw.id), + name: raw.name ?? undefined, + durationSecs: Number(raw.duration_secs ?? 0), + startedAt: raw.started_at ?? null, + stoppedAt: raw.stopped_at ?? null, + completedAt: raw.completed_at ?? null, + timedOut: raw.timed_out === true, + targets: Array.isArray(raw.targets) ? raw.targets : [], + }; +} + +// Attribute names used in debug traces +const attributeNames = { + HTTP_REQUEST_METHOD: 'http.request.method', + URL_FULL: 'url.full', + HTTP_ROUTE: 'http.route', + HTTP_RESPONSE_STATUS_CODE: 'http.response.status_code', +}; + +function normalizeTraceSummary(raw: Raw): DebugTraceSummary { + const spans: Raw[] = (raw.span_sets ?? []).flatMap((set: Raw) => set?.spans ?? []); + const attributes: Raw = Object.assign({}, ...spans.map(span => span?.attributes ?? {})); + const status = attributes[attributeNames.HTTP_RESPONSE_STATUS_CODE]; + return { + traceId: String(raw.trace_id), + rootTraceName: raw.root_trace_name ?? null, + startTimeUnixNano: String(raw.start_time_unix_nano ?? '0'), + durationMs: Number(raw.duration_ms ?? 0), + method: attributes[attributeNames.HTTP_REQUEST_METHOD] ?? null, + url: attributes[attributeNames.URL_FULL] ?? null, + route: attributes[attributeNames.HTTP_ROUTE] ?? null, + statusCode: status === undefined || status === null ? null : Number(status), + }; +} + +function normalizeTraceDetail(traceId: string, raw: Raw): DebugTraceDetail { + const spans: DebugSpan[] = []; + for (const resourceSpan of raw.resource_spans ?? []) { + for (const scopeSpan of resourceSpan?.scope_spans ?? []) { + for (const span of scopeSpan?.spans ?? []) { + spans.push({ + spanId: String(span.span_id), + parentSpanId: span.parent_span_id || null, + name: String(span.name || ''), + startTimeUnixNano: String(span.start_time_unix_nano ?? '0'), + endTimeUnixNano: String(span.end_time_unix_nano ?? span.start_time_unix_nano ?? '0'), + attributes: span.attributes ?? {}, + }); + } + } + } + return { traceId, spans }; +} + +export async function listNodeIds(ctx: DebugSessionApiContext): Promise { + const body = await request(ctx, 'GET', `/v2/control-planes/${ctx.controlPlaneId}/nodes?size=100`); + const nodes: Raw[] = Array.isArray(body?.items) ? body.items : Array.isArray(body?.data) ? body.data : []; + return nodes.map(node => String(node.id)).filter(Boolean); +} + +export async function listDebugSessions(ctx: DebugSessionApiContext): Promise { + const body = await request(ctx, 'GET', `${sessionsPath(ctx)}?page[size]=50`, { timeout: POLL_TIMEOUT_MS }); + return (Array.isArray(body?.data) ? body.data : []).map(normalizeSession); +} + +export async function createDebugSession( + ctx: DebugSessionApiContext, + durationSecs: number, + targets: string[], +): Promise { + const body = await request(ctx, 'POST', sessionsPath(ctx), { + data: { name: 'Insomnia debugger', duration_secs: durationSecs, targets, capture_traces: true }, + }); + return normalizeSession(body); +} + +export async function getDebugSession(ctx: DebugSessionApiContext, sessionId: string): Promise { + const body = await request(ctx, 'GET', `${sessionsPath(ctx)}/${sessionId}`, { timeout: POLL_TIMEOUT_MS }); + return normalizeSession(body); +} + +export async function stopDebugSession(ctx: DebugSessionApiContext, sessionId: string): Promise { + await request(ctx, 'POST', `${sessionsPath(ctx)}/${sessionId}/stop`); +} + +export async function listDebugTraces( + ctx: DebugSessionApiContext, + sessionId: string, + limit = 2000, +): Promise { + const body = await request(ctx, 'GET', `${sessionsPath(ctx)}/${sessionId}/traces?limit=${limit}`, { + timeout: POLL_TIMEOUT_MS, + }); + return (Array.isArray(body?.traces) ? body.traces : []).map(normalizeTraceSummary); +} + +export async function getDebugTrace( + ctx: DebugSessionApiContext, + sessionId: string, + traceId: string, +): Promise { + const body = await request(ctx, 'GET', `${sessionsPath(ctx)}/${sessionId}/traces/${traceId}`); + return normalizeTraceDetail(traceId, body); +}