Skip to content

Commit 9700a93

Browse files
committed
feat(horizon): add structured logging for outbound API calls
1 parent 94cbd4b commit 9700a93

3 files changed

Lines changed: 247 additions & 0 deletions

File tree

src/clients/horizon.client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
horizonRequest,
3+
type HorizonRequestInit,
4+
} from '../utils/horizon-api.utils';
5+
6+
/**
7+
* Horizon HTTP client. All outbound Horizon traffic must go through these helpers
8+
* so structured request logging is applied consistently.
9+
*/
10+
export async function horizonGet(
11+
endpoint: string,
12+
init: Omit<HorizonRequestInit, 'method'> = {}
13+
): Promise<Response> {
14+
return horizonRequest(endpoint, { ...init, method: 'GET' });
15+
}
16+
17+
export async function horizonPost(
18+
endpoint: string,
19+
init: Omit<HorizonRequestInit, 'method'> = {}
20+
): Promise<Response> {
21+
return horizonRequest(endpoint, { ...init, method: 'POST' });
22+
}
23+
24+
export { horizonRequest };
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Unit tests for #681 — structured logs for outbound Horizon API calls.
2+
3+
import {
4+
horizonRequest,
5+
normalizeHorizonEndpoint,
6+
} from './horizon-api.utils';
7+
import { logger } from './logger.utils';
8+
import { RpcTimeoutError, withRpcTimeout } from './rpc-timeout.utils';
9+
10+
jest.mock('./logger.utils', () => ({
11+
logger: {
12+
info: jest.fn(),
13+
warn: jest.fn(),
14+
error: jest.fn(),
15+
debug: jest.fn(),
16+
},
17+
}));
18+
19+
jest.mock('./rpc-timeout.utils', () => {
20+
const actual = jest.requireActual('./rpc-timeout.utils');
21+
return {
22+
...actual,
23+
withRpcTimeout: jest.fn(
24+
(_operation: string, fn: () => Promise<unknown>) => fn()
25+
),
26+
};
27+
});
28+
29+
const mockLogger = logger as unknown as {
30+
info: jest.Mock;
31+
warn: jest.Mock;
32+
};
33+
34+
const mockWithRpcTimeout = withRpcTimeout as jest.MockedFunction<
35+
typeof withRpcTimeout
36+
>;
37+
38+
describe('#681 Horizon API structured logging', () => {
39+
beforeEach(() => {
40+
jest.clearAllMocks();
41+
mockWithRpcTimeout.mockImplementation((_op, fn) => fn());
42+
});
43+
44+
describe('normalizeHorizonEndpoint', () => {
45+
it('keeps path-only endpoints', () => {
46+
expect(normalizeHorizonEndpoint('/ledgers?order=desc')).toBe(
47+
'/ledgers?order=desc'
48+
);
49+
});
50+
51+
it('strips the origin from absolute URLs', () => {
52+
expect(
53+
normalizeHorizonEndpoint(
54+
'https://horizon-testnet.stellar.org/accounts/GABC'
55+
)
56+
).toBe('/accounts/GABC');
57+
});
58+
});
59+
60+
it('emits info log with all five fields after a completed Horizon call', async () => {
61+
const mockResponse = { status: 200 } as Response;
62+
global.fetch = jest.fn().mockResolvedValue(mockResponse);
63+
64+
await horizonRequest('/ledgers?order=desc&limit=1', {
65+
method: 'GET',
66+
headers: { Authorization: 'Bearer secret-token' },
67+
});
68+
69+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
70+
const [fields] = mockLogger.info.mock.calls[0];
71+
expect(fields).toMatchObject({
72+
horizon_endpoint: '/ledgers?order=desc&limit=1',
73+
method: 'GET',
74+
status_code: 200,
75+
});
76+
expect(fields.response_time_ms).toEqual(expect.any(Number));
77+
expect(fields.response_time_ms).toBeGreaterThanOrEqual(0);
78+
expect(fields.called_at).toEqual(expect.any(String));
79+
expect(() => new Date(fields.called_at).toISOString()).not.toThrow();
80+
expect(JSON.stringify(fields)).not.toContain('secret-token');
81+
expect(JSON.stringify(fields)).not.toContain('Authorization');
82+
});
83+
84+
it('does not include request body in log fields', async () => {
85+
global.fetch = jest.fn().mockResolvedValue({ status: 201 } as Response);
86+
87+
await horizonRequest('/transactions', {
88+
method: 'POST',
89+
body: '{"sensitive":"payload"}',
90+
});
91+
92+
const [fields] = mockLogger.info.mock.calls[0];
93+
expect(JSON.stringify(fields)).not.toContain('sensitive');
94+
expect(JSON.stringify(fields)).not.toContain('payload');
95+
});
96+
97+
it('emits warn log with timed_out and no status_code on timeout', async () => {
98+
mockWithRpcTimeout.mockImplementation(() =>
99+
Promise.reject(new RpcTimeoutError('horizon:GET:/ledgers', 50))
100+
);
101+
102+
await expect(horizonRequest('/ledgers')).rejects.toBeInstanceOf(
103+
RpcTimeoutError
104+
);
105+
106+
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
107+
expect(mockLogger.info).not.toHaveBeenCalled();
108+
const [fields] = mockLogger.warn.mock.calls[0];
109+
expect(fields).toMatchObject({
110+
horizon_endpoint: '/ledgers',
111+
method: 'GET',
112+
timed_out: true,
113+
});
114+
expect(fields).not.toHaveProperty('status_code');
115+
expect(fields.response_time_ms).toBeGreaterThanOrEqual(0);
116+
expect(fields.called_at).toEqual(expect.any(String));
117+
});
118+
119+
it('does not emit timeout warn log for non-timeout failures', async () => {
120+
mockWithRpcTimeout.mockImplementation(() =>
121+
Promise.reject(new Error('network down'))
122+
);
123+
124+
await expect(horizonRequest('/accounts/GABC')).rejects.toThrow(
125+
'network down'
126+
);
127+
128+
expect(mockLogger.warn).not.toHaveBeenCalled();
129+
expect(mockLogger.info).not.toHaveBeenCalled();
130+
});
131+
});

src/utils/horizon-api.utils.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { envConfig } from '../config';
2+
import { formatIsoTimestamp } from './iso-timestamp.utils';
3+
import { logger } from './logger.utils';
4+
import { elapsedMs, startTimer } from './monotonic-clock.utils';
5+
import { RpcTimeoutError, withRpcTimeout } from './rpc-timeout.utils';
6+
7+
export type HorizonHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD';
8+
9+
export interface HorizonRequestInit {
10+
method?: HorizonHttpMethod;
11+
headers?: Record<string, string>;
12+
body?: string;
13+
timeoutMs?: number;
14+
}
15+
16+
/**
17+
* Normalises a Horizon path for logging (pathname + query, no origin).
18+
*/
19+
export function normalizeHorizonEndpoint(endpoint: string): string {
20+
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
21+
const url = new URL(endpoint);
22+
return `${url.pathname}${url.search}`;
23+
}
24+
return endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
25+
}
26+
27+
function buildHorizonUrl(endpoint: string): string {
28+
const path = normalizeHorizonEndpoint(endpoint);
29+
const base = envConfig.STELLAR_HORIZON_URL.replace(/\/$/, '');
30+
return `${base}${path}`;
31+
}
32+
33+
function nonNegativeResponseTimeMs(timer: ReturnType<typeof startTimer>): number {
34+
return Math.max(0, Math.round(elapsedMs(timer)));
35+
}
36+
37+
/**
38+
* Performs an outbound Horizon API request and emits structured logs after the
39+
* response is received. Request bodies and authorization headers are never logged.
40+
*/
41+
export async function horizonRequest(
42+
endpoint: string,
43+
init: HorizonRequestInit = {}
44+
): Promise<Response> {
45+
const method = init.method ?? 'GET';
46+
const horizonEndpoint = normalizeHorizonEndpoint(endpoint);
47+
const calledAt = formatIsoTimestamp(new Date());
48+
const timer = startTimer();
49+
const url = buildHorizonUrl(endpoint);
50+
51+
const doFetch = () =>
52+
fetch(url, {
53+
method,
54+
headers: init.headers,
55+
body: init.body,
56+
});
57+
58+
try {
59+
const response = await withRpcTimeout(
60+
`horizon:${method}:${horizonEndpoint}`,
61+
doFetch,
62+
init.timeoutMs
63+
);
64+
65+
logger.info(
66+
{
67+
horizon_endpoint: horizonEndpoint,
68+
method,
69+
status_code: response.status,
70+
response_time_ms: nonNegativeResponseTimeMs(timer),
71+
called_at: calledAt,
72+
},
73+
'Horizon API call completed'
74+
);
75+
76+
return response;
77+
} catch (err) {
78+
if (err instanceof RpcTimeoutError) {
79+
logger.warn(
80+
{
81+
horizon_endpoint: horizonEndpoint,
82+
method,
83+
response_time_ms: nonNegativeResponseTimeMs(timer),
84+
called_at: calledAt,
85+
timed_out: true,
86+
},
87+
'Horizon API call timed out'
88+
);
89+
}
90+
throw err;
91+
}
92+
}

0 commit comments

Comments
 (0)