@outputai/http 0.2.0 → 0.2.1-next.fd72d95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fetch/index.d.ts +22 -0
- package/dist/fetch/index.js +45 -0
- package/dist/fetch/index.spec.js +206 -0
- package/dist/fetch/logger.d.ts +45 -0
- package/dist/fetch/logger.js +54 -0
- package/dist/fetch/logger.spec.js +182 -0
- package/dist/fetch/utils.d.ts +36 -0
- package/dist/fetch/utils.js +91 -0
- package/dist/fetch/utils.spec.js +253 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -26
- package/dist/index.spec.js +11 -389
- package/package.json +3 -2
- package/dist/hooks/assign_request_id.d.ts +0 -9
- package/dist/hooks/assign_request_id.js +0 -15
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -4
- package/dist/hooks/trace_error.d.ts +0 -14
- package/dist/hooks/trace_error.js +0 -61
- package/dist/hooks/trace_error.spec.js +0 -35
- package/dist/hooks/trace_request.d.ts +0 -6
- package/dist/hooks/trace_request.js +0 -25
- package/dist/hooks/trace_request.spec.js +0 -60
- package/dist/hooks/trace_response.d.ts +0 -6
- package/dist/hooks/trace_response.js +0 -26
- package/dist/hooks/trace_response.spec.js +0 -68
- package/dist/index.integration.test.d.ts +0 -1
- package/dist/index.integration.test.js +0 -389
- package/dist/utils/create_trace_id.d.ts +0 -13
- package/dist/utils/create_trace_id.js +0 -20
- package/dist/utils/create_trace_id.spec.d.ts +0 -1
- package/dist/utils/create_trace_id.spec.js +0 -20
- package/dist/utils/index.d.ts +0 -4
- package/dist/utils/index.js +0 -4
- package/dist/utils/parse_request_body.d.ts +0 -7
- package/dist/utils/parse_request_body.js +0 -19
- package/dist/utils/parse_request_body.spec.d.ts +0 -1
- package/dist/utils/parse_request_body.spec.js +0 -19
- package/dist/utils/parse_response_body.d.ts +0 -10
- package/dist/utils/parse_response_body.js +0 -14
- package/dist/utils/parse_response_body.spec.d.ts +0 -1
- package/dist/utils/parse_response_body.spec.js +0 -19
- package/dist/utils/redact_headers.d.ts +0 -6
- package/dist/utils/redact_headers.js +0 -27
- package/dist/utils/redact_headers.spec.d.ts +0 -1
- package/dist/utils/redact_headers.spec.js +0 -245
- /package/dist/{hooks/trace_error.spec.d.ts → fetch/index.spec.d.ts} +0 -0
- /package/dist/{hooks/trace_request.spec.d.ts → fetch/logger.spec.d.ts} +0 -0
- /package/dist/{hooks/trace_response.spec.d.ts → fetch/utils.spec.d.ts} +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RequestInfo, RequestInit } from 'undici';
|
|
2
|
+
/** Re-export undici library for convenience. */
|
|
3
|
+
export * as undici from 'undici';
|
|
4
|
+
/** Export fetch input types. Also available under in undici.* export. */
|
|
5
|
+
export type {
|
|
6
|
+
/** Undici's fetch first argument: Either a URL string, a URL object or a undici.Request object. */
|
|
7
|
+
RequestInfo,
|
|
8
|
+
/** Undici's fetch second argument: A plain object containing HTTP options. */
|
|
9
|
+
RequestInit };
|
|
10
|
+
/**
|
|
11
|
+
* A fetch compliant function, that wraps undici's fetch.
|
|
12
|
+
*
|
|
13
|
+
* Behaves the same as any fetch function except:
|
|
14
|
+
* - Sets a request header called `x-request--trace-id` with a random UUID;
|
|
15
|
+
* - Sends the request, response, error and/or failure to the Trace system;
|
|
16
|
+
*
|
|
17
|
+
* @see {@link https://fetch.spec.whatwg.org/}
|
|
18
|
+
* @param input - URL string, URL object or Request object (undici's or Node's)
|
|
19
|
+
* @param init - Request options
|
|
20
|
+
* @returns The HTTP response
|
|
21
|
+
*/
|
|
22
|
+
export declare const fetch: (input: RequestInfo | Request, init?: RequestInit) => Promise<Response>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as undici from 'undici';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { logRequest, logResponse, logError, logFailure } from './logger.js';
|
|
4
|
+
/*
|
|
5
|
+
* Unifies undici and nodes realms
|
|
6
|
+
* https://github.com/nodejs/undici#keep-fetch-and-formdata-together
|
|
7
|
+
*/
|
|
8
|
+
undici.install();
|
|
9
|
+
/** Re-export undici library for convenience. */
|
|
10
|
+
export * as undici from 'undici';
|
|
11
|
+
/**
|
|
12
|
+
* A fetch compliant function, that wraps undici's fetch.
|
|
13
|
+
*
|
|
14
|
+
* Behaves the same as any fetch function except:
|
|
15
|
+
* - Sets a request header called `x-request--trace-id` with a random UUID;
|
|
16
|
+
* - Sends the request, response, error and/or failure to the Trace system;
|
|
17
|
+
*
|
|
18
|
+
* @see {@link https://fetch.spec.whatwg.org/}
|
|
19
|
+
* @param input - URL string, URL object or Request object (undici's or Node's)
|
|
20
|
+
* @param init - Request options
|
|
21
|
+
* @returns The HTTP response
|
|
22
|
+
*/
|
|
23
|
+
export const fetch = async (input, init) => {
|
|
24
|
+
// Creates a Request object with the many shapes RequestInfo can have
|
|
25
|
+
const base = new undici.Request(input, init);
|
|
26
|
+
// Creates a headers object with the many shapes Request.Headers can have (object, array, Headers)
|
|
27
|
+
const headers = new undici.Headers(base.headers);
|
|
28
|
+
const requestId = randomUUID();
|
|
29
|
+
headers.set('x-request-trace-id', requestId);
|
|
30
|
+
const request = new undici.Request(base, { headers });
|
|
31
|
+
await logRequest({ requestId, request });
|
|
32
|
+
try {
|
|
33
|
+
const response = await undici.fetch(request);
|
|
34
|
+
if (response.status > 399) {
|
|
35
|
+
await logError({ requestId, response });
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
await logResponse({ requestId, response });
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logFailure({ requestId, error: error });
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { getGlobalDispatcher, Headers, MockAgent, Request, setGlobalDispatcher } from 'undici';
|
|
3
|
+
const FIXED_REQUEST_ID = 'aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee';
|
|
4
|
+
const randomUUIDMock = vi.hoisted(() => vi.fn(() => FIXED_REQUEST_ID));
|
|
5
|
+
const loggerMock = vi.hoisted(() => ({
|
|
6
|
+
logRequest: vi.fn(async (_args) => { }),
|
|
7
|
+
logResponse: vi.fn(async (_args) => { }),
|
|
8
|
+
logError: vi.fn((_args) => { }),
|
|
9
|
+
logFailure: vi.fn((_args) => { })
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('node:crypto', () => ({
|
|
12
|
+
randomUUID: () => randomUUIDMock()
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('./logger.js', () => ({
|
|
15
|
+
logRequest: loggerMock.logRequest,
|
|
16
|
+
logResponse: loggerMock.logResponse,
|
|
17
|
+
logError: loggerMock.logError,
|
|
18
|
+
logFailure: loggerMock.logFailure
|
|
19
|
+
}));
|
|
20
|
+
import { fetch } from './index.js';
|
|
21
|
+
const MOCK_ORIGIN = 'https://fetch-index.undici.test';
|
|
22
|
+
describe('fetch/index', () => {
|
|
23
|
+
const undiciCtx = {
|
|
24
|
+
mockAgent: undefined,
|
|
25
|
+
previousDispatcher: undefined
|
|
26
|
+
};
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
undiciCtx.mockAgent = new MockAgent();
|
|
29
|
+
undiciCtx.mockAgent.disableNetConnect();
|
|
30
|
+
undiciCtx.previousDispatcher = getGlobalDispatcher();
|
|
31
|
+
setGlobalDispatcher(undiciCtx.mockAgent);
|
|
32
|
+
loggerMock.logRequest.mockClear();
|
|
33
|
+
loggerMock.logResponse.mockClear();
|
|
34
|
+
loggerMock.logError.mockClear();
|
|
35
|
+
loggerMock.logFailure.mockClear();
|
|
36
|
+
randomUUIDMock.mockClear();
|
|
37
|
+
randomUUIDMock.mockImplementation(() => FIXED_REQUEST_ID);
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
if (undiciCtx.previousDispatcher !== undefined) {
|
|
41
|
+
setGlobalDispatcher(undiciCtx.previousDispatcher);
|
|
42
|
+
}
|
|
43
|
+
if (undiciCtx.mockAgent !== undefined) {
|
|
44
|
+
await undiciCtx.mockAgent.close();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
describe('fetch with MockAgent', () => {
|
|
48
|
+
it('returns 200, traces request start and response end', async () => {
|
|
49
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/ok', method: 'GET' }).reply(200, 'hello', { headers: { 'content-type': 'text/plain' } });
|
|
50
|
+
const response = await fetch(`${MOCK_ORIGIN}/ok`);
|
|
51
|
+
expect(response.status).toBe(200);
|
|
52
|
+
expect(await response.text()).toBe('hello');
|
|
53
|
+
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(loggerMock.logRequest.mock.calls[0][0].requestId).toBe(FIXED_REQUEST_ID);
|
|
55
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
|
|
56
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.url).toBe(`${MOCK_ORIGIN}/ok`);
|
|
57
|
+
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(loggerMock.logResponse.mock.calls[0][0].requestId).toBe(FIXED_REQUEST_ID);
|
|
59
|
+
expect(loggerMock.logResponse.mock.calls[0][0].response).toBe(response);
|
|
60
|
+
expect(loggerMock.logError).not.toHaveBeenCalled();
|
|
61
|
+
expect(loggerMock.logFailure).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
it('uses logError for status > 399 without logging response end', async () => {
|
|
64
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/missing', method: 'GET' }).reply(404, 'nope', { headers: { 'content-type': 'text/plain' } });
|
|
65
|
+
const response = await fetch(`${MOCK_ORIGIN}/missing`);
|
|
66
|
+
expect(response.status).toBe(404);
|
|
67
|
+
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(loggerMock.logResponse).not.toHaveBeenCalled();
|
|
69
|
+
expect(loggerMock.logError).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(loggerMock.logError.mock.calls[0][0].requestId).toBe(FIXED_REQUEST_ID);
|
|
71
|
+
expect(loggerMock.logError.mock.calls[0][0].response).toBe(response);
|
|
72
|
+
});
|
|
73
|
+
it('treats status 399 as success (logs response end, not HTTP error)', async () => {
|
|
74
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/edge', method: 'GET' }).reply(399, '', { headers: { 'content-type': 'text/plain' } });
|
|
75
|
+
const response = await fetch(`${MOCK_ORIGIN}/edge`);
|
|
76
|
+
expect(response.status).toBe(399);
|
|
77
|
+
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(loggerMock.logError).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
it('sends x-request-trace-id and custom headers when init.headers is a plain object', async () => {
|
|
81
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({
|
|
82
|
+
path: '/with-plain-headers',
|
|
83
|
+
method: 'GET',
|
|
84
|
+
headers: {
|
|
85
|
+
'x-request-trace-id': FIXED_REQUEST_ID,
|
|
86
|
+
'x-custom': 'plain-value'
|
|
87
|
+
}
|
|
88
|
+
}).reply(200, 'ok');
|
|
89
|
+
const response = await fetch(`${MOCK_ORIGIN}/with-plain-headers`, {
|
|
90
|
+
headers: { 'X-Custom': 'plain-value' }
|
|
91
|
+
});
|
|
92
|
+
expect(response.status).toBe(200);
|
|
93
|
+
const { request } = loggerMock.logRequest.mock.calls[0][0];
|
|
94
|
+
expect(request.headers.get('x-request-trace-id')).toBe(FIXED_REQUEST_ID);
|
|
95
|
+
expect(request.headers.get('x-custom')).toBe('plain-value');
|
|
96
|
+
});
|
|
97
|
+
it('sends x-request-trace-id and custom headers when init.headers is a Headers instance', async () => {
|
|
98
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({
|
|
99
|
+
path: '/with-headers-instance',
|
|
100
|
+
method: 'GET',
|
|
101
|
+
headers: {
|
|
102
|
+
'x-request-trace-id': FIXED_REQUEST_ID,
|
|
103
|
+
'x-from-headers': 'yes'
|
|
104
|
+
}
|
|
105
|
+
}).reply(200, 'ok');
|
|
106
|
+
const userHeaders = new Headers();
|
|
107
|
+
userHeaders.set('X-From-Headers', 'yes');
|
|
108
|
+
const response = await fetch(`${MOCK_ORIGIN}/with-headers-instance`, {
|
|
109
|
+
headers: userHeaders
|
|
110
|
+
});
|
|
111
|
+
expect(response.status).toBe(200);
|
|
112
|
+
const { request } = loggerMock.logRequest.mock.calls[0][0];
|
|
113
|
+
expect(request.headers.get('x-request-trace-id')).toBe(FIXED_REQUEST_ID);
|
|
114
|
+
expect(request.headers.get('x-from-headers')).toBe('yes');
|
|
115
|
+
});
|
|
116
|
+
it('calls logFailure when the mock responds with replyWithError', async () => {
|
|
117
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/boom', method: 'GET' }).replyWithError(new Error('simulated network failure'));
|
|
118
|
+
await expect(fetch(`${MOCK_ORIGIN}/boom`)).rejects.toThrow('fetch failed');
|
|
119
|
+
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(loggerMock.logResponse).not.toHaveBeenCalled();
|
|
121
|
+
expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(loggerMock.logFailure.mock.calls[0][0].requestId).toBe(FIXED_REQUEST_ID);
|
|
123
|
+
const failure = loggerMock.logFailure.mock.calls[0][0].error;
|
|
124
|
+
expect(failure).toBeInstanceOf(TypeError);
|
|
125
|
+
expect(failure.message).toBe('fetch failed');
|
|
126
|
+
expect(failure.cause).toBeInstanceOf(Error);
|
|
127
|
+
expect(failure.cause.message).toBe('simulated network failure');
|
|
128
|
+
});
|
|
129
|
+
it('fails when no mock matches (disabled net)', async () => {
|
|
130
|
+
await expect(fetch(`${MOCK_ORIGIN}/unmocked`)).rejects.toThrow();
|
|
131
|
+
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(loggerMock.logResponse).not.toHaveBeenCalled();
|
|
133
|
+
expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
it('passes method and body through to undici for POST JSON', async () => {
|
|
136
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/create', method: 'POST' }).reply(201, JSON.stringify({ id: 1 }), { headers: { 'content-type': 'application/json' } });
|
|
137
|
+
const response = await fetch(`${MOCK_ORIGIN}/create`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ name: 'a' })
|
|
141
|
+
});
|
|
142
|
+
expect(response.status).toBe(201);
|
|
143
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
|
|
144
|
+
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
it('works when the second argument is omitted', async () => {
|
|
147
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/bare', method: 'GET' }).reply(204);
|
|
148
|
+
const response = await fetch(`${MOCK_ORIGIN}/bare`);
|
|
149
|
+
expect(response.status).toBe(204);
|
|
150
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('fetch RequestInfo / RequestInit shapes', () => {
|
|
154
|
+
it('accepts a URL object as the first argument', async () => {
|
|
155
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/from-url', method: 'GET' }).reply(200, 'url-ok', { headers: { 'content-type': 'text/plain' } });
|
|
156
|
+
const href = new URL('/from-url', `${MOCK_ORIGIN}/`);
|
|
157
|
+
const response = await fetch(href);
|
|
158
|
+
expect(response.status).toBe(200);
|
|
159
|
+
expect(await response.text()).toBe('url-ok');
|
|
160
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.url).toBe(href.href);
|
|
161
|
+
});
|
|
162
|
+
it('accepts a Request as the first argument (no init)', async () => {
|
|
163
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/req-only', method: 'GET' }).reply(200, 'r1');
|
|
164
|
+
const input = new Request(`${MOCK_ORIGIN}/req-only`, { method: 'GET' });
|
|
165
|
+
const response = await fetch(input);
|
|
166
|
+
expect(response.status).toBe(200);
|
|
167
|
+
expect(await response.text()).toBe('r1');
|
|
168
|
+
const { request } = loggerMock.logRequest.mock.calls[0][0];
|
|
169
|
+
expect(request.method).toBe('GET');
|
|
170
|
+
expect(request.url).toBe(`${MOCK_ORIGIN}/req-only`);
|
|
171
|
+
});
|
|
172
|
+
it('accepts Request plus init that overrides method and body', async () => {
|
|
173
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/req-plus-init', method: 'POST' }).reply(201, JSON.stringify({ saved: true }), { headers: { 'content-type': 'application/json' } });
|
|
174
|
+
const input = new Request(`${MOCK_ORIGIN}/req-plus-init`, { method: 'GET' });
|
|
175
|
+
const response = await fetch(input, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ name: 'override' })
|
|
179
|
+
});
|
|
180
|
+
expect(response.status).toBe(201);
|
|
181
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
|
|
182
|
+
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
it('accepts string URL with explicit undefined init', async () => {
|
|
185
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/explicit-undefined', method: 'GET' }).reply(200, 'ok');
|
|
186
|
+
const response = await fetch(`${MOCK_ORIGIN}/explicit-undefined`, undefined);
|
|
187
|
+
expect(response.status).toBe(200);
|
|
188
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
|
|
189
|
+
});
|
|
190
|
+
it('accepts URL plus init with method and headers', async () => {
|
|
191
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({
|
|
192
|
+
path: '/url-post',
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'content-type': 'application/json', 'x-request-trace-id': FIXED_REQUEST_ID }
|
|
195
|
+
}).reply(200, '{}');
|
|
196
|
+
const href = new URL('/url-post', `${MOCK_ORIGIN}/`);
|
|
197
|
+
const response = await fetch(href, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json' },
|
|
200
|
+
body: '{}'
|
|
201
|
+
});
|
|
202
|
+
expect(response.status).toBe(200);
|
|
203
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Request, Response } from 'undici';
|
|
2
|
+
/**
|
|
3
|
+
* Sends the trace start event for an http request
|
|
4
|
+
*
|
|
5
|
+
* @param options
|
|
6
|
+
* @param options.requestId - id of the request
|
|
7
|
+
* @param options.request - The HTTP Request object
|
|
8
|
+
*/
|
|
9
|
+
export declare const logRequest: ({ requestId, request }: {
|
|
10
|
+
requestId: string;
|
|
11
|
+
request: Request;
|
|
12
|
+
}) => Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Sends the trace error event for an http response with error status
|
|
15
|
+
*
|
|
16
|
+
* @param options
|
|
17
|
+
* @param options.requestId - id of the request
|
|
18
|
+
* @param options.response - The HTTP Response object
|
|
19
|
+
*/
|
|
20
|
+
export declare const logError: ({ requestId, response }: {
|
|
21
|
+
requestId: string;
|
|
22
|
+
response: Response;
|
|
23
|
+
}) => Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Sends the trace end event for an http response
|
|
26
|
+
*
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param options.requestId - id of the request
|
|
29
|
+
* @param {Response} options.response - The HTTP Response object
|
|
30
|
+
*/
|
|
31
|
+
export declare const logResponse: ({ requestId, response }: {
|
|
32
|
+
requestId: string;
|
|
33
|
+
response: Response;
|
|
34
|
+
}) => Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Creates the trace error event for a network/connection failure
|
|
37
|
+
*
|
|
38
|
+
* @param options
|
|
39
|
+
* @param options.requestId - id of the request
|
|
40
|
+
* @param options.error - The error thrown
|
|
41
|
+
*/
|
|
42
|
+
export declare const logFailure: ({ requestId, error }: {
|
|
43
|
+
requestId: string;
|
|
44
|
+
error: Error;
|
|
45
|
+
}) => void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Tracing } from '@outputai/core/sdk_activity_integration';
|
|
2
|
+
import { config } from '../config.js';
|
|
3
|
+
import { parseBody, redactHeaders, serializeError } from './utils.js';
|
|
4
|
+
/**
|
|
5
|
+
* Sends the trace start event for an http request
|
|
6
|
+
*
|
|
7
|
+
* @param options
|
|
8
|
+
* @param options.requestId - id of the request
|
|
9
|
+
* @param options.request - The HTTP Request object
|
|
10
|
+
*/
|
|
11
|
+
export const logRequest = async ({ requestId, request }) => Tracing.addEventStart({
|
|
12
|
+
id: requestId, kind: 'http', name: 'request', details: {
|
|
13
|
+
method: request.method,
|
|
14
|
+
url: request.url,
|
|
15
|
+
...(config.logVerbose && { headers: redactHeaders(request.headers), body: await parseBody(request) })
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Sends the trace error event for an http response with error status
|
|
20
|
+
*
|
|
21
|
+
* @param options
|
|
22
|
+
* @param options.requestId - id of the request
|
|
23
|
+
* @param options.response - The HTTP Response object
|
|
24
|
+
*/
|
|
25
|
+
export const logError = async ({ requestId, response }) => Tracing.addEventError({
|
|
26
|
+
id: requestId, details: {
|
|
27
|
+
status: response.status,
|
|
28
|
+
statusText: response.statusText,
|
|
29
|
+
headers: redactHeaders(response.headers),
|
|
30
|
+
body: await parseBody(response)
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Sends the trace end event for an http response
|
|
35
|
+
*
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param options.requestId - id of the request
|
|
38
|
+
* @param {Response} options.response - The HTTP Response object
|
|
39
|
+
*/
|
|
40
|
+
export const logResponse = async ({ requestId, response }) => Tracing.addEventEnd({
|
|
41
|
+
id: requestId, details: {
|
|
42
|
+
status: response.status,
|
|
43
|
+
statusText: response.statusText,
|
|
44
|
+
...(config.logVerbose && { headers: redactHeaders(response.headers), body: await parseBody(response) })
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* Creates the trace error event for a network/connection failure
|
|
49
|
+
*
|
|
50
|
+
* @param options
|
|
51
|
+
* @param options.requestId - id of the request
|
|
52
|
+
* @param options.error - The error thrown
|
|
53
|
+
*/
|
|
54
|
+
export const logFailure = ({ requestId, error }) => Tracing.addEventError({ id: requestId, details: serializeError(error) });
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Response, Request } from 'undici';
|
|
3
|
+
vi.mock('@outputai/core/sdk_activity_integration', () => ({
|
|
4
|
+
Tracing: {
|
|
5
|
+
addEventStart: vi.fn(),
|
|
6
|
+
addEventEnd: vi.fn(),
|
|
7
|
+
addEventError: vi.fn()
|
|
8
|
+
}
|
|
9
|
+
}));
|
|
10
|
+
import { Tracing } from '@outputai/core/sdk_activity_integration';
|
|
11
|
+
const tracing = vi.mocked(Tracing, true);
|
|
12
|
+
/** Loads logger with optional verbose tracing env so `config.js` is evaluated fresh. */
|
|
13
|
+
async function logLogger(verbose) {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
if (verbose) {
|
|
16
|
+
process.env.OUTPUT_TRACE_HTTP_VERBOSE = 'true';
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
delete process.env.OUTPUT_TRACE_HTTP_VERBOSE;
|
|
20
|
+
}
|
|
21
|
+
return import('./logger.js');
|
|
22
|
+
}
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tracing.addEventStart.mockClear();
|
|
25
|
+
tracing.addEventEnd.mockClear();
|
|
26
|
+
tracing.addEventError.mockClear();
|
|
27
|
+
});
|
|
28
|
+
describe('fetch/logger', () => {
|
|
29
|
+
describe('logRequest', () => {
|
|
30
|
+
it('records minimal details when verbose is off', async () => {
|
|
31
|
+
const { logRequest } = await logLogger(false);
|
|
32
|
+
const request = new Request('https://api.example.com/r', { method: 'GET' });
|
|
33
|
+
await logRequest({ requestId: 'req-1', request });
|
|
34
|
+
expect(tracing.addEventStart).toHaveBeenCalledWith({
|
|
35
|
+
id: 'req-1',
|
|
36
|
+
kind: 'http',
|
|
37
|
+
name: 'request',
|
|
38
|
+
details: {
|
|
39
|
+
method: 'GET',
|
|
40
|
+
url: 'https://api.example.com/r'
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('defaults method to GET', async () => {
|
|
45
|
+
const { logRequest } = await logLogger(false);
|
|
46
|
+
const request = new Request('https://x.test');
|
|
47
|
+
await logRequest({ requestId: 'r2', request });
|
|
48
|
+
expect(tracing.addEventStart.mock.calls[0][0].details.method).toBe('GET');
|
|
49
|
+
});
|
|
50
|
+
it('includes redacted headers and parsed body when verbose is on', async () => {
|
|
51
|
+
const { logRequest } = await logLogger(true);
|
|
52
|
+
const request = new Request('https://api.example.com/p', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: 'tok',
|
|
56
|
+
'X-Custom': 'ok',
|
|
57
|
+
'Content-Type': 'application/json'
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({ x: 1 })
|
|
60
|
+
});
|
|
61
|
+
await logRequest({ requestId: 'req-v', request });
|
|
62
|
+
expect(tracing.addEventStart).toHaveBeenCalledWith({
|
|
63
|
+
id: 'req-v',
|
|
64
|
+
kind: 'http',
|
|
65
|
+
name: 'request',
|
|
66
|
+
details: {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
url: 'https://api.example.com/p',
|
|
69
|
+
headers: { authorization: '[REDACTED]', 'x-custom': 'ok', 'content-type': 'application/json' },
|
|
70
|
+
body: { x: 1 }
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('logError', () => {
|
|
76
|
+
it('records status, statusText, redacted headers, and parsed JSON body', async () => {
|
|
77
|
+
const { logError } = await logLogger(false);
|
|
78
|
+
const body = { message: 'Upstream unavailable', code: 'E_UPSTREAM' };
|
|
79
|
+
const response = new Response(JSON.stringify(body), {
|
|
80
|
+
status: 502,
|
|
81
|
+
statusText: 'Bad Gateway',
|
|
82
|
+
headers: {
|
|
83
|
+
'X-API-Key': 'k',
|
|
84
|
+
Accept: 'text/plain',
|
|
85
|
+
'content-type': 'application/json'
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
await logError({ requestId: 'e1', response });
|
|
89
|
+
expect(tracing.addEventError).toHaveBeenCalledWith({
|
|
90
|
+
id: 'e1',
|
|
91
|
+
details: {
|
|
92
|
+
status: 502,
|
|
93
|
+
statusText: 'Bad Gateway',
|
|
94
|
+
headers: {
|
|
95
|
+
'x-api-key': '[REDACTED]',
|
|
96
|
+
accept: 'text/plain',
|
|
97
|
+
'content-type': 'application/json'
|
|
98
|
+
},
|
|
99
|
+
body
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
it('records error body as raw text when content-type is not application/json', async () => {
|
|
104
|
+
const { logError } = await logLogger(false);
|
|
105
|
+
const text = 'Bad Gateway: no healthy upstream';
|
|
106
|
+
const response = new Response(text, {
|
|
107
|
+
status: 502,
|
|
108
|
+
statusText: 'Bad Gateway',
|
|
109
|
+
headers: { 'content-type': 'text/plain' }
|
|
110
|
+
});
|
|
111
|
+
await logError({ requestId: 'e2', response });
|
|
112
|
+
expect(tracing.addEventError).toHaveBeenCalledWith({
|
|
113
|
+
id: 'e2',
|
|
114
|
+
details: {
|
|
115
|
+
status: 502,
|
|
116
|
+
statusText: 'Bad Gateway',
|
|
117
|
+
headers: { 'content-type': 'text/plain' },
|
|
118
|
+
body: text
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('logResponse', () => {
|
|
124
|
+
it('records status and statusText without headers/body when verbose is off', async () => {
|
|
125
|
+
const { logResponse } = await logLogger(false);
|
|
126
|
+
const response = new Response(JSON.stringify({ a: 1 }), {
|
|
127
|
+
status: 200,
|
|
128
|
+
statusText: 'OK',
|
|
129
|
+
headers: { 'content-type': 'application/json', Authorization: 'x' }
|
|
130
|
+
});
|
|
131
|
+
await logResponse({ requestId: 'lr1', response });
|
|
132
|
+
expect(tracing.addEventEnd).toHaveBeenCalledWith({
|
|
133
|
+
id: 'lr1',
|
|
134
|
+
details: {
|
|
135
|
+
status: 200,
|
|
136
|
+
statusText: 'OK'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
it('includes redacted headers and parsed body when verbose is on', async () => {
|
|
141
|
+
const { logResponse } = await logLogger(true);
|
|
142
|
+
const response = new Response(JSON.stringify({ ok: true }), {
|
|
143
|
+
status: 201,
|
|
144
|
+
statusText: 'Created',
|
|
145
|
+
headers: {
|
|
146
|
+
'content-type': 'application/json',
|
|
147
|
+
'Set-Cookie': 'a=b'
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
await logResponse({ requestId: 'lr-v', response });
|
|
151
|
+
expect(tracing.addEventEnd).toHaveBeenCalledWith({
|
|
152
|
+
id: 'lr-v',
|
|
153
|
+
details: {
|
|
154
|
+
status: 201,
|
|
155
|
+
statusText: 'Created',
|
|
156
|
+
headers: {
|
|
157
|
+
'content-type': 'application/json',
|
|
158
|
+
'set-cookie': '[REDACTED]'
|
|
159
|
+
},
|
|
160
|
+
body: { ok: true }
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('logFailure', () => {
|
|
166
|
+
it('forwards serialized error details (including stack) to Tracing.addEventError', async () => {
|
|
167
|
+
const { logFailure } = await logLogger(false);
|
|
168
|
+
const err = new TypeError('network');
|
|
169
|
+
logFailure({ requestId: 'f1', error: err });
|
|
170
|
+
expect(tracing.addEventError).toHaveBeenCalledWith({
|
|
171
|
+
id: 'f1',
|
|
172
|
+
details: {
|
|
173
|
+
name: 'TypeError',
|
|
174
|
+
message: 'network',
|
|
175
|
+
cause: undefined,
|
|
176
|
+
code: undefined,
|
|
177
|
+
stack: expect.stringMatching(/TypeError:\s*network[\s\S]+at\s+/)
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Request, Response, Headers } from 'undici';
|
|
2
|
+
/**
|
|
3
|
+
* Serialize a given error to a plain object keeping main properties:
|
|
4
|
+
* - name (from constructor.name)
|
|
5
|
+
* - message
|
|
6
|
+
* - stack
|
|
7
|
+
* - code (optional, but present on Node errors)
|
|
8
|
+
* - cause (error chain)
|
|
9
|
+
*
|
|
10
|
+
* @param error Error to serialize
|
|
11
|
+
* @param depth Current recursion depth for the error.cause chain
|
|
12
|
+
* @returns Object
|
|
13
|
+
*/
|
|
14
|
+
export declare const serializeError: (error: Error, depth?: number) => {
|
|
15
|
+
name: string;
|
|
16
|
+
message: string;
|
|
17
|
+
stack: string | undefined;
|
|
18
|
+
code: string | undefined;
|
|
19
|
+
cause: string | object | undefined;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Redacts sensitive headers for safe logging
|
|
23
|
+
*
|
|
24
|
+
* @param headers
|
|
25
|
+
* @returns Plain object with sensitive headers redacted
|
|
26
|
+
*/
|
|
27
|
+
export declare const redactHeaders: (headers: Headers) => Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Clones a Request or Response object and reads the body as text, then:
|
|
30
|
+
* - non-JSON content-type, or empty body: returns the text as-is
|
|
31
|
+
* - application/json with a non-empty body: returns JSON.parse result, or the raw text if parsing fails
|
|
32
|
+
*
|
|
33
|
+
* @param r
|
|
34
|
+
* @returns Parsed JSON value or raw body string
|
|
35
|
+
*/
|
|
36
|
+
export declare const parseBody: (r: Request | Response) => Promise<string | object>;
|