Skip to content

Commit d646388

Browse files
authored
Merge pull request #171 from Benalex8797/feat/Add
Backend: Add structured logging + request correlation IDs
2 parents 702a142 + 7f1d0c4 commit d646388

18 files changed

Lines changed: 424 additions & 56 deletions

frontend/package-lock.json

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useEffect } from "react";
2+
import { useCorrelationId } from "../context/CorrelationIdContext";
3+
import { setCorrelationIdGetter } from "../lib/apiClient";
4+
5+
/**
6+
* Invisible component that keeps the shared API client's correlation ID
7+
* getter in sync with the current `CorrelationIdContext` value.
8+
*
9+
* Must be rendered inside `CorrelationIdProvider`.
10+
*/
11+
export function CorrelationIdSync() {
12+
const { correlationId } = useCorrelationId();
13+
14+
useEffect(() => {
15+
setCorrelationIdGetter(() => correlationId);
16+
}, [correlationId]);
17+
18+
return null;
19+
}

frontend/src/components/Pagination.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ export const Pagination: React.FC<PaginationProps> = ({
4949
return (
5050
<div className="pagination-container" aria-label="Pagination">
5151
<div className="pagination-summary" aria-live="polite">
52-
Showing <strong>{startItem}{endItem}</strong> of{" "}
53-
<strong>{totalItems}</strong> results
52+
{`Page ${page} of ${totalPages}`} &mdash; Showing {startItem}{endItem} of {totalItems} results
5453
</div>
5554

5655
<div className="pagination-controls-wrapper">

frontend/src/components/Tabs.tsx

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,31 +28,28 @@ interface TabsProps {
2828
className?: string;
2929
}
3030

31-
export function Tabs({
31+
/** Inner component that uses useSearchParams — only rendered when syncWithUrl=true */
32+
function TabsWithUrl({
3233
defaultValue,
3334
value: controlledValue,
3435
onValueChange,
35-
syncWithUrl = false,
3636
urlParam = "tab",
3737
children,
3838
className = "",
39-
}: TabsProps) {
39+
}: Omit<TabsProps, "syncWithUrl">) {
4040
const [searchParams, setSearchParams] = useSearchParams();
4141
const [internalValue, setInternalValue] = useState(defaultValue || "");
4242

43-
const urlValue = syncWithUrl ? searchParams.get(urlParam) : null;
43+
const urlValue = searchParams.get(urlParam);
4444
const activeValue =
4545
controlledValue !== undefined
4646
? controlledValue
47-
: syncWithUrl && urlValue
47+
: urlValue
4848
? urlValue
4949
: internalValue;
5050

5151
useEffect(() => {
52-
// If we're syncing with URL but the param isn't there, and we have a defaultValue,
53-
// let's set the default value in the URL implicitly, or just let activeValue handle it.
54-
// Setting it explicitly ensures deep links are predictable.
55-
if (syncWithUrl && !urlValue && defaultValue) {
52+
if (!urlValue && defaultValue) {
5653
setSearchParams(
5754
(prev) => {
5855
const newParams = new URLSearchParams(prev);
@@ -62,24 +59,53 @@ export function Tabs({
6259
{ replace: true }
6360
);
6461
}
65-
}, [syncWithUrl, urlValue, defaultValue, urlParam, setSearchParams]);
62+
}, [urlValue, defaultValue, urlParam, setSearchParams]);
6663

6764
const handleValueChange = (newValue: string) => {
6865
if (controlledValue === undefined) {
6966
setInternalValue(newValue);
7067
}
7168

72-
if (syncWithUrl) {
73-
setSearchParams(
74-
(prev) => {
75-
const newParams = new URLSearchParams(prev);
76-
newParams.set(urlParam, newValue);
77-
return newParams;
78-
},
79-
{ replace: true }
80-
);
69+
setSearchParams(
70+
(prev) => {
71+
const newParams = new URLSearchParams(prev);
72+
newParams.set(urlParam, newValue);
73+
return newParams;
74+
},
75+
{ replace: true }
76+
);
77+
78+
if (onValueChange) {
79+
onValueChange(newValue);
8180
}
81+
};
82+
83+
return (
84+
<TabsContext.Provider value={{ value: activeValue, onValueChange: handleValueChange }}>
85+
<div className={`tabs-root ${className}`} data-state={activeValue}>
86+
{children}
87+
</div>
88+
</TabsContext.Provider>
89+
);
90+
}
91+
92+
/** Inner component for tabs without URL sync */
93+
function TabsWithoutUrl({
94+
defaultValue,
95+
value: controlledValue,
96+
onValueChange,
97+
children,
98+
className = "",
99+
}: Omit<TabsProps, "syncWithUrl" | "urlParam">) {
100+
const [internalValue, setInternalValue] = useState(defaultValue || "");
101+
102+
const activeValue =
103+
controlledValue !== undefined ? controlledValue : internalValue;
82104

105+
const handleValueChange = (newValue: string) => {
106+
if (controlledValue === undefined) {
107+
setInternalValue(newValue);
108+
}
83109
if (onValueChange) {
84110
onValueChange(newValue);
85111
}
@@ -94,6 +120,16 @@ export function Tabs({
94120
);
95121
}
96122

123+
export function Tabs({
124+
syncWithUrl = false,
125+
...props
126+
}: TabsProps) {
127+
if (syncWithUrl) {
128+
return <TabsWithUrl {...props} />;
129+
}
130+
return <TabsWithoutUrl {...props} />;
131+
}
132+
97133
export function TabsList({ children, className = "", style }: { children: ReactNode; className?: string; style?: React.CSSProperties }) {
98134
return (
99135
<div

frontend/src/components/WalletConnect.test.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
1+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
22
import type { ComponentProps } from 'react';
33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44
import WalletConnect from './WalletConnect';
@@ -101,7 +101,10 @@ describe('WalletConnect', () => {
101101
});
102102

103103
it('handles wallet disconnects gracefully during polling', async () => {
104-
vi.useFakeTimers();
104+
// Helper to flush all pending promises
105+
const flushPromises = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
106+
107+
vi.useFakeTimers({ shouldAdvanceTime: false });
105108
mockedFreighter.isAllowed
106109
.mockResolvedValueOnce({ isAllowed: true })
107110
.mockResolvedValueOnce({ isAllowed: false });
@@ -115,14 +118,16 @@ describe('WalletConnect', () => {
115118
/>
116119
);
117120

118-
await waitFor(() => {
119-
expect(mockOnConnect).toHaveBeenCalledWith('GABC123');
120-
});
121+
// Advance timers by 0 to flush the initial synchronous setup,
122+
// then flush microtasks from the async calls
123+
vi.advanceTimersByTime(0);
124+
await vi.advanceTimersByTimeAsync(0);
121125

122-
await vi.advanceTimersByTimeAsync(10000);
126+
expect(mockOnConnect).toHaveBeenCalledWith('GABC123');
123127

124-
await waitFor(() => {
125-
expect(mockOnDisconnect).toHaveBeenCalled();
126-
});
127-
});
128+
// Advance past the 10s polling interval
129+
await vi.advanceTimersByTimeAsync(10001);
130+
131+
expect(mockOnDisconnect).toHaveBeenCalled();
132+
}, 20000);
128133
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { createContext, useCallback, useContext, useState } from 'react';
2+
3+
export interface CorrelationIdContextValue {
4+
correlationId: string;
5+
/** Replace the active ID (e.g. when starting a new user action). */
6+
refreshCorrelationId: () => void;
7+
}
8+
9+
const CorrelationIdContext = createContext<CorrelationIdContextValue | undefined>(undefined);
10+
11+
export const CorrelationIdProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
12+
const [correlationId, setCorrelationId] = useState<string>(() => crypto.randomUUID());
13+
14+
const refreshCorrelationId = useCallback(() => {
15+
setCorrelationId(crypto.randomUUID());
16+
}, []);
17+
18+
return (
19+
<CorrelationIdContext.Provider value={{ correlationId, refreshCorrelationId }}>
20+
{children}
21+
</CorrelationIdContext.Provider>
22+
);
23+
};
24+
25+
// eslint-disable-next-line react-refresh/only-export-components
26+
export function useCorrelationId(): CorrelationIdContextValue {
27+
const context = useContext(CorrelationIdContext);
28+
if (!context) {
29+
throw new Error('useCorrelationId must be used within a CorrelationIdProvider');
30+
}
31+
return context;
32+
}

frontend/src/hooks/useUrlState.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function useUrlState<TFilters extends Record<string, string>>(
1919
const state = useMemo(() => {
2020
const pageRaw = searchParams.get("page");
2121
const pageSizeRaw = searchParams.get("pageSize");
22-
const sortDirectionRaw = searchParams.get("sortDirection");
22+
// Support both "direction" and "sortDirection" as URL param names
23+
const sortDirectionRaw = searchParams.get("direction") ?? searchParams.get("sortDirection");
2324

2425
const pageNum = Number(pageRaw);
2526
const page =
@@ -74,7 +75,7 @@ export function useUrlState<TFilters extends Record<string, string>>(
7475
next.set("sortBy", updates.sortBy);
7576
}
7677
if (updates.sortDirection !== undefined) {
77-
next.set("sortDirection", updates.sortDirection);
78+
next.set("direction", updates.sortDirection);
7879
}
7980

8081
if (updates.filters !== undefined) {

frontend/src/lib/api/client.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,57 @@ describe("ApiClient", () => {
9494
"We could not complete that request. Please review your input and try again.",
9595
});
9696
});
97+
98+
it("sets X-Correlation-ID header on outgoing requests via the request interceptor", async () => {
99+
const correlationId = "test-correlation-id-abc123";
100+
let capturedHeaders: Headers | undefined;
101+
102+
vi.stubGlobal(
103+
"fetch",
104+
vi.fn().mockImplementation((url: string, init: RequestInit) => {
105+
capturedHeaders = init.headers as Headers;
106+
return Promise.resolve(
107+
new Response(JSON.stringify({ ok: true }), {
108+
status: 200,
109+
headers: { "content-type": "application/json" },
110+
}),
111+
);
112+
}),
113+
);
114+
115+
const client = createApiClient({
116+
baseUrl: "http://localhost",
117+
getCorrelationId: () => correlationId,
118+
});
119+
120+
await client.get("/health");
121+
122+
expect(capturedHeaders).toBeDefined();
123+
expect(capturedHeaders!.get("X-Correlation-ID")).toBe(correlationId);
124+
});
125+
126+
it("attaches correlationId to ApiError on HTTP failure", async () => {
127+
const correlationId = "error-correlation-id-xyz789";
128+
129+
vi.stubGlobal(
130+
"fetch",
131+
vi.fn().mockResolvedValue(
132+
new Response(JSON.stringify({ error: "not found" }), {
133+
status: 404,
134+
statusText: "Not Found",
135+
headers: { "content-type": "application/json" },
136+
}),
137+
),
138+
);
139+
140+
const client = createApiClient({
141+
baseUrl: "http://localhost",
142+
getCorrelationId: () => correlationId,
143+
});
144+
145+
const error = await client.get("/missing").catch((e) => e);
146+
147+
expect(error).toBeInstanceOf(ApiError);
148+
expect(error.correlationId).toBe(correlationId);
149+
});
97150
});

frontend/src/lib/api/client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { ApiError, normalizeApiError, type NormalizeApiErrorOptions } from "./error";
22
import { emitApiTelemetry } from "./telemetry";
3+
import {
4+
createCorrelationRequestInterceptor,
5+
createCorrelationResponseInterceptor,
6+
generateCorrelationId,
7+
} from "./correlationInterceptors";
38

49
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
510

@@ -33,6 +38,7 @@ export interface ApiResponseContext<T> {
3338
interface ApiClientConfig {
3439
baseUrl?: string;
3540
headers?: HeadersInit;
41+
getCorrelationId?: () => string;
3642
}
3743

3844
type RequestInterceptor = (
@@ -164,6 +170,7 @@ async function buildApiError(
164170
...options,
165171
method: request.method,
166172
url: request.url,
173+
correlationId: (request.headers as Headers).get("X-Correlation-ID") ?? undefined,
167174
});
168175
}
169176

@@ -175,6 +182,9 @@ export class ApiClient {
175182

176183
constructor(config: ApiClientConfig = {}) {
177184
this.baseConfig = config;
185+
const getCorrelationId = config.getCorrelationId ?? generateCorrelationId;
186+
this.useRequest(createCorrelationRequestInterceptor(getCorrelationId));
187+
this.useResponse(createCorrelationResponseInterceptor());
178188
}
179189

180190
useRequest(interceptor: RequestInterceptor) {
@@ -219,6 +229,7 @@ export class ApiClient {
219229
method: request.method,
220230
url: request.url,
221231
attempt,
232+
correlationId: (request.headers as Headers).get("X-Correlation-ID") ?? undefined,
222233
});
223234

224235
try {
@@ -252,6 +263,7 @@ export class ApiClient {
252263
attempt,
253264
durationMs,
254265
status: response.status,
266+
correlationId: (request.headers as Headers).get("X-Correlation-ID") ?? undefined,
255267
});
256268

257269
return context.data;
@@ -273,6 +285,7 @@ export class ApiClient {
273285
attempt,
274286
delayMs,
275287
reason: apiError.code,
288+
correlationId: (request.headers as Headers).get("X-Correlation-ID") ?? undefined,
276289
});
277290
await sleep(delayMs);
278291
continue;
@@ -285,6 +298,7 @@ export class ApiClient {
285298
attempt,
286299
durationMs,
287300
error: apiError,
301+
correlationId: (request.headers as Headers).get("X-Correlation-ID") ?? undefined,
288302
});
289303

290304
throw apiError;

0 commit comments

Comments
 (0)