Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .claude/scripts/test-api-logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bun
import { desc, eq } from 'drizzle-orm'
import { db } from '#/db'
import { apiCallLogs } from '#/db/schema'
import {
loggedFetch,
withApiLogContext,
} from '#/services/logs/logged-fetch'

const SENTINEL_HOST = 'https://example.test/api-logs-smoke'

async function clearOldRuns(): Promise<void> {
// Best-effort cleanup so repeat runs don't pollute the table.
await db
.delete(apiCallLogs)
.where(eq(apiCallLogs.provider, 'openalex'))
.returning({ id: apiCallLogs.id })
.catch(() => [])
}

function stubFetch(body: string, status: number): void {
globalThis.fetch = async () =>
new Response(body, {
status,
headers: {
'content-type': 'application/json',
'content-length': String(body.length),
},
})
}

function restoreFetch(original: typeof globalThis.fetch): void {
globalThis.fetch = original
}

const originalFetch = globalThis.fetch

async function main(): Promise<void> {
console.log('Smoke-test for loggedFetch + api_call_logs')
console.log('---')

await clearOldRuns()

// Case 1: success with body capture
stubFetch('{"results":[{"id":"W123"}]}', 200)
const fakeJobId = '00000000-0000-0000-0000-000000000000'
try {
await withApiLogContext({ trackJobId: fakeJobId }, async () => {
const res = await loggedFetch(
{ provider: 'openalex' },
`${SENTINEL_HOST}/works?search=test`,
)
const body = await res.json()
console.log('case 1: caller got body', JSON.stringify(body))
})
} catch (err) {
// trackJobId references a fake uuid that doesn't exist in jobs table —
// the FK will reject the insert. That's expected; we'll verify by
// dropping the FK constraint, or by using a real job. For the smoke
// test we just verify the wrapper doesn't break the caller.
console.log('case 1 caller still completed despite FK error:', err)
}

// Case 2: HTTP error
stubFetch('{"error":"forbidden"}', 403)
const res2 = await loggedFetch(
{ provider: 'openalex' },
`${SENTINEL_HOST}/works/forbidden`,
)
console.log('case 2: status', res2.status)

// Case 3: network error
globalThis.fetch = async () => {
throw new Error('econnrefused')
}
try {
await loggedFetch(
{ provider: 'openalex' },
`${SENTINEL_HOST}/works/network-fail`,
)
console.log('case 3 FAILED — should have thrown')
} catch (err) {
console.log('case 3: caller saw network error:', (err as Error).message)
}

restoreFetch(originalFetch)

// Wait for fire-and-forget DB writes.
await new Promise((resolve) => setTimeout(resolve, 1000))

// Verify rows landed for the calls that didn't FK-fail.
const rows = await db
.select()
.from(apiCallLogs)
.where(eq(apiCallLogs.provider, 'openalex'))
.orderBy(desc(apiCallLogs.createdAt))
.limit(10)

console.log('---')
console.log(`Found ${rows.length} log rows (provider='openalex'):`)
for (const row of rows) {
console.log(
` [${row.id}] ${row.outcome} status=${row.status} duration=${row.durationMs}ms url=${row.url}`,
)
if (row.bodyPreview) {
const preview = row.bodyPreview.slice(0, 80)
console.log(
` body (${row.bodySizeBytes}B${row.bodyTruncated ? ', truncated' : ''}): ${preview}`,
)
}
if (row.errorMessage) {
console.log(` error: ${row.errorMessage}`)
}
}

console.log('---')
console.log(
rows.length >= 2
? 'PASS: at least the http_error + network_error cases landed in the DB.'
: 'WARN: fewer rows than expected — check api_call_logs migration was applied (bun run db:migrate or restart docker compose).',
)
}

await main()
25 changes: 25 additions & 0 deletions drizzle/0008_solid_venus.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TYPE "public"."api_call_outcome" AS ENUM('success', 'http_error', 'network_error', 'timeout');--> statement-breakpoint
CREATE TABLE "api_call_logs" (
"id" serial PRIMARY KEY NOT NULL,
"track_job_id" uuid,
"eval_job_id" uuid,
"provider" text NOT NULL,
"method" text DEFAULT 'GET' NOT NULL,
"url" text NOT NULL,
"status" integer,
"response_headers" jsonb,
"body_preview" text,
"body_truncated" boolean DEFAULT false NOT NULL,
"body_size_bytes" integer,
"outcome" "api_call_outcome" NOT NULL,
"error_message" text,
"duration_ms" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "api_call_logs" ADD CONSTRAINT "api_call_logs_track_job_id_jobs_id_fk" FOREIGN KEY ("track_job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "api_call_logs" ADD CONSTRAINT "api_call_logs_eval_job_id_evaluation_jobs_id_fk" FOREIGN KEY ("eval_job_id") REFERENCES "public"."evaluation_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_call_logs_created_idx" ON "api_call_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "api_call_logs_provider_created_idx" ON "api_call_logs" USING btree ("provider","created_at");--> statement-breakpoint
CREATE INDEX "api_call_logs_track_job_idx" ON "api_call_logs" USING btree ("track_job_id");--> statement-breakpoint
CREATE INDEX "api_call_logs_eval_job_idx" ON "api_call_logs" USING btree ("eval_job_id");
Loading