add initial integration for debug session api

This commit is contained in:
Kent Wang committed 2026-10-28 16:34:30 +08:00
1 parent eecc5b004e
commit be42716ddc
3 files changed
+351 -1

No files matched your search

@@ -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<string, string> = {}) =>
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' });
});
});
+1 -1
View File
@@ -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.
@@ -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<string, any>;
}
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<T>(
ctx: DebugSessionApiContext,
method: 'GET' | 'POST',
path: string,
options: { data?: unknown; timeout?: number } = {},
): Promise<T> {
try {
return await insomniaFetch<T>({
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<string, any>;
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<string[]> {
const body = await request<Raw>(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<DebugSession[]> {
const body = await request<Raw>(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<DebugSession> {
const body = await request<Raw>(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<DebugSession> {
const body = await request<Raw>(ctx, 'GET', `${sessionsPath(ctx)}/${sessionId}`, { timeout: POLL_TIMEOUT_MS });
return normalizeSession(body);
}
export async function stopDebugSession(ctx: DebugSessionApiContext, sessionId: string): Promise<void> {
await request(ctx, 'POST', `${sessionsPath(ctx)}/${sessionId}/stop`);
}
export async function listDebugTraces(
ctx: DebugSessionApiContext,
sessionId: string,
limit = 2000,
): Promise<DebugTraceSummary[]> {
const body = await request<Raw>(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<DebugTraceDetail> {
const body = await request<Raw>(ctx, 'GET', `${sessionsPath(ctx)}/${sessionId}/traces/${traceId}`);
return normalizeTraceDetail(traceId, body);
}