feat: add diagnostics and telemetry runtime
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
import type { DiagnosticRecordInput } from "../../src/contracts/diagnostics.js";
|
||||
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||
|
||||
function successResponse(data: unknown) {
|
||||
return Response.json({
|
||||
success: true,
|
||||
data,
|
||||
meta: { requestId: "request-1", traceId: "trace-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function failureResponse(status: number) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "TEMPORARY" },
|
||||
meta: { requestId: "request-1", traceId: "trace-1" },
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
type EmittedEvent = Readonly<{
|
||||
eventName: string;
|
||||
attributes: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
function harness(fetcher: typeof fetch, maxRetryAttempts = 1) {
|
||||
const records: DiagnosticRecordInput[] = [];
|
||||
const emitted: EmittedEvent[] = [];
|
||||
let currentTime = 0;
|
||||
const client = createHttpClient({
|
||||
...TEST_HTTP_CONTRACT,
|
||||
baseUrl: "https://api.test",
|
||||
fetcher,
|
||||
maxRetryAttempts,
|
||||
clock: {
|
||||
now: () => currentTime,
|
||||
sleep: async () => {
|
||||
currentTime += 150;
|
||||
},
|
||||
},
|
||||
scheduler: {
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
correlationIdFactory: () => "correlation-fixed",
|
||||
diagnostics: {
|
||||
record(input) {
|
||||
records.push(structuredClone(input));
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
emit(eventName, attributes) {
|
||||
emitted.push({
|
||||
eventName,
|
||||
attributes: structuredClone(attributes),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
return { client, records, emitted };
|
||||
}
|
||||
|
||||
describe("HTTP diagnostics and terminal telemetry", () => {
|
||||
it("records one successful reference route outcome and no failure event", async () => {
|
||||
const { client, records, emitted } = harness(
|
||||
vi.fn(async () => successResponse([])),
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.execute({
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
level: "info",
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
route_id: "TEST_ROUTE",
|
||||
operation_id: "LIST_ENTITIES",
|
||||
correlation_id: "correlation-fixed",
|
||||
outcome: "success",
|
||||
error_kind: "NONE",
|
||||
http_status_group: "none",
|
||||
attempt_count_bucket: "1",
|
||||
duration_bucket: "lt100ms",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("summarizes retry recovery once without a terminal failure event", async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(failureResponse(503))
|
||||
.mockResolvedValueOnce(successResponse([]));
|
||||
const { client, records, emitted } = harness(fetcher);
|
||||
|
||||
await expect(
|
||||
client.execute({
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({
|
||||
eventId: "http.request.completed",
|
||||
context: {
|
||||
outcome: "recovered",
|
||||
correlation_id: "correlation-fixed",
|
||||
duration_bucket: "100-499ms",
|
||||
},
|
||||
});
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits one bounded terminal failure after all attempts", async () => {
|
||||
const fetcher = vi.fn(async () => failureResponse(503));
|
||||
const { client, records, emitted } = harness(fetcher);
|
||||
|
||||
await expect(
|
||||
client.execute({
|
||||
operationId: "CREATE_ENTITY",
|
||||
routeId: "TEST_ROUTE",
|
||||
body: { name: "private user input" },
|
||||
idempotencyKey: "private-idempotency-key",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SERVER_FAILURE" },
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
eventName: "api.request.failed",
|
||||
attributes: {
|
||||
error_kind: "SERVER_FAILURE",
|
||||
http_status_group: "5xx",
|
||||
attempt_count_bucket: "2",
|
||||
route_id: "TEST_ROUTE",
|
||||
operation_id: "CREATE_ENTITY",
|
||||
duration_bucket: "100-499ms",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify({ records, emitted })).not.toMatch(
|
||||
/private user input|private-idempotency-key/,
|
||||
);
|
||||
});
|
||||
|
||||
it("records navigation abort without failure telemetry", async () => {
|
||||
const caller = new AbortController();
|
||||
caller.abort("navigation");
|
||||
const fetcher = vi.fn(async (request: RequestInfo | URL) => {
|
||||
const signal = (request as Request).signal;
|
||||
if (signal.aborted) throw new DOMException("aborted", "AbortError");
|
||||
return successResponse([]);
|
||||
});
|
||||
const { client, records, emitted } = harness(
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.execute({
|
||||
operationId: "LIST_ENTITIES",
|
||||
routeId: "TEST_ROUTE",
|
||||
signal: caller.signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "REQUEST_ABORTED" },
|
||||
});
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({
|
||||
eventId: "http.request.completed",
|
||||
context: { outcome: "aborted", error_kind: "REQUEST_ABORTED" },
|
||||
});
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user