@outputai/http 0.2.1-next.af8a069.0 → 0.2.1-next.b87b58f.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/consts.d.ts +4 -0
- package/dist/consts.js +4 -0
- package/dist/cost.d.ts +16 -0
- package/dist/cost.js +18 -0
- package/dist/cost.spec.js +88 -0
- package/dist/fetch/index.d.ts +22 -0
- package/dist/fetch/index.js +48 -0
- package/dist/fetch/index.spec.js +218 -0
- package/dist/fetch/logger.d.ts +45 -0
- package/dist/fetch/logger.js +57 -0
- package/dist/fetch/logger.spec.js +194 -0
- package/dist/fetch/utils.d.ts +45 -0
- package/dist/fetch/utils.js +101 -0
- package/dist/fetch/utils.spec.js +274 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -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.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 → cost.spec.d.ts} +0 -0
- /package/dist/{hooks/trace_request.spec.d.ts → fetch/index.spec.d.ts} +0 -0
- /package/dist/{hooks/trace_response.spec.d.ts → fetch/logger.spec.d.ts} +0 -0
- /package/dist/{index.integration.test.d.ts → fetch/utils.spec.d.ts} +0 -0
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { Tracing } from '@outputai/core/sdk_activity_integration';
|
|
2
|
-
import { redactHeaders, createTraceId, parseResponseBody } from '#utils/index.js';
|
|
3
|
-
import { config } from '#config.js';
|
|
4
|
-
/**
|
|
5
|
-
* Traces HTTP response for observability using Output.ai tracing
|
|
6
|
-
* Respects OUTPUT_TRACE_HTTP_VERBOSE environment variable for detailed logging
|
|
7
|
-
*/
|
|
8
|
-
export const traceResponse = async (request, _options, response, _state) => {
|
|
9
|
-
const traceId = createTraceId(request);
|
|
10
|
-
// Skip tracing if no X-Request-ID header is present
|
|
11
|
-
if (!traceId) {
|
|
12
|
-
console.warn('traceResponse: Skipping response tracing - no X-Request-ID header');
|
|
13
|
-
return response;
|
|
14
|
-
}
|
|
15
|
-
const details = {
|
|
16
|
-
status: response.status,
|
|
17
|
-
statusText: response.statusText
|
|
18
|
-
};
|
|
19
|
-
if (config.logVerbose) {
|
|
20
|
-
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
21
|
-
details.headers = redactHeaders(responseHeaders);
|
|
22
|
-
details.body = await parseResponseBody(response);
|
|
23
|
-
}
|
|
24
|
-
Tracing.addEventEnd({ id: traceId, details });
|
|
25
|
-
return response;
|
|
26
|
-
};
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import { traceResponse } from './trace_response.js';
|
|
3
|
-
import { Tracing } from '@outputai/core/sdk_activity_integration';
|
|
4
|
-
import { config } from '../config.js';
|
|
5
|
-
vi.mock('../utils/index.js', () => ({
|
|
6
|
-
redactHeaders: vi.fn((h) => h),
|
|
7
|
-
parseResponseBody: vi.fn(async () => ({ mocked: true })),
|
|
8
|
-
createTraceId: vi.fn(() => 'trace-id')
|
|
9
|
-
}));
|
|
10
|
-
vi.mock('@outputai/core/sdk_activity_integration', () => ({
|
|
11
|
-
Tracing: {
|
|
12
|
-
addEventEnd: vi.fn()
|
|
13
|
-
}
|
|
14
|
-
}));
|
|
15
|
-
vi.mock('../config.js', () => ({
|
|
16
|
-
config: { logVerbose: false }
|
|
17
|
-
}));
|
|
18
|
-
const mockedTracing = vi.mocked(Tracing, true);
|
|
19
|
-
const mockedConfig = vi.mocked(config);
|
|
20
|
-
describe('http/hooks/trace_response', () => {
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
mockedTracing.addEventEnd.mockClear();
|
|
23
|
-
mockedConfig.logVerbose = false;
|
|
24
|
-
});
|
|
25
|
-
it('traces minimal details when verbose logging is disabled', async () => {
|
|
26
|
-
mockedConfig.logVerbose = false;
|
|
27
|
-
const request = new Request('https://api.example.com/users/1', { method: 'GET' });
|
|
28
|
-
const response = new Response('ok', { status: 200, statusText: 'OK' });
|
|
29
|
-
const options = {};
|
|
30
|
-
const state = { retryCount: 0 };
|
|
31
|
-
const result = await traceResponse(request, options, response, state);
|
|
32
|
-
expect(result).toBe(response);
|
|
33
|
-
expect(mockedTracing.addEventEnd).toHaveBeenCalledTimes(1);
|
|
34
|
-
const arg = mockedTracing.addEventEnd.mock.calls[0][0];
|
|
35
|
-
expect(arg.details).toEqual({ status: 200, statusText: 'OK' });
|
|
36
|
-
});
|
|
37
|
-
it('traces headers and parsed JSON body when verbose logging is enabled', async () => {
|
|
38
|
-
mockedConfig.logVerbose = true;
|
|
39
|
-
const request = new Request('https://api.example.com/users', { method: 'POST' });
|
|
40
|
-
const response = new Response(JSON.stringify({ success: true }), {
|
|
41
|
-
status: 201,
|
|
42
|
-
statusText: 'Created',
|
|
43
|
-
headers: { 'content-type': 'application/json', authorization: 'secret', 'x-custom': 'v' }
|
|
44
|
-
});
|
|
45
|
-
const options = {};
|
|
46
|
-
const state = { retryCount: 0 };
|
|
47
|
-
await traceResponse(request, options, response, state);
|
|
48
|
-
const arg = mockedTracing.addEventEnd.mock.calls[0][0];
|
|
49
|
-
expect(arg.details.status).toBe(201);
|
|
50
|
-
expect(arg.details.statusText).toBe('Created');
|
|
51
|
-
expect(arg.details.headers).toEqual({ 'content-type': 'application/json', authorization: 'secret', 'x-custom': 'v' });
|
|
52
|
-
expect(arg.details.body).toEqual({ mocked: true });
|
|
53
|
-
});
|
|
54
|
-
it('traces text body for non-JSON content types', async () => {
|
|
55
|
-
mockedConfig.logVerbose = true;
|
|
56
|
-
const request = new Request('https://api.example.com/ping', { method: 'GET' });
|
|
57
|
-
const response = new Response('pong', {
|
|
58
|
-
status: 200,
|
|
59
|
-
statusText: 'OK',
|
|
60
|
-
headers: { 'content-type': 'text/plain' }
|
|
61
|
-
});
|
|
62
|
-
const options = {};
|
|
63
|
-
const state = { retryCount: 0 };
|
|
64
|
-
await traceResponse(request, options, response, state);
|
|
65
|
-
const arg = mockedTracing.addEventEnd.mock.calls[0][0];
|
|
66
|
-
expect(arg.details.body).toEqual({ mocked: true });
|
|
67
|
-
});
|
|
68
|
-
});
|
|
@@ -1,389 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import ky from 'ky';
|
|
3
|
-
import { httpClient } from './index.js';
|
|
4
|
-
import { Tracing } from '@outputai/core/sdk_activity_integration';
|
|
5
|
-
import createTraceId from './utils/create_trace_id.js';
|
|
6
|
-
import { traceRequest, traceResponse, traceError } from './hooks/index.js';
|
|
7
|
-
import { applyFetchErrorTracing } from './hooks/trace_error.js';
|
|
8
|
-
// Helper function for trace ID format validation
|
|
9
|
-
const isUuidFormat = (traceId) => {
|
|
10
|
-
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(traceId);
|
|
11
|
-
};
|
|
12
|
-
vi.mock('@outputai/core/sdk_activity_integration', () => ({
|
|
13
|
-
Tracing: {
|
|
14
|
-
addEventStart: vi.fn(),
|
|
15
|
-
addEventEnd: vi.fn(),
|
|
16
|
-
addEventError: vi.fn()
|
|
17
|
-
}
|
|
18
|
-
}));
|
|
19
|
-
const mockedTracing = vi.mocked(Tracing, true);
|
|
20
|
-
// Helper to get header value (httpbingo returns arrays)
|
|
21
|
-
const getHeader = (headers, key) => {
|
|
22
|
-
const value = headers[key];
|
|
23
|
-
return Array.isArray(value) ? value[0] : value;
|
|
24
|
-
};
|
|
25
|
-
describe('HTTP Client Authentication Integration', () => {
|
|
26
|
-
const httpBinClient = httpClient({
|
|
27
|
-
prefixUrl: 'https://httpbingo.org',
|
|
28
|
-
timeout: 5000
|
|
29
|
-
});
|
|
30
|
-
const clientsClient = httpBinClient.extend({
|
|
31
|
-
headers: {
|
|
32
|
-
'X-API-Key': 'demo-api-key-12345'
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
const contractsClient = httpBinClient.extend({
|
|
36
|
-
headers: {
|
|
37
|
-
Authorization: `Basic ${Buffer.from('demo-user:demo-pass').toString('base64')}`
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
describe('Authentication Headers', () => {
|
|
41
|
-
it('should include API key for clients endpoints', async () => {
|
|
42
|
-
const response = await clientsClient.get('anything/clients');
|
|
43
|
-
const data = await response.json();
|
|
44
|
-
expect(getHeader(data.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
45
|
-
expect(data.url).toContain('/anything/clients');
|
|
46
|
-
expect(data.method).toBe('GET');
|
|
47
|
-
}, 10000);
|
|
48
|
-
it('should include Basic auth for contracts endpoints', async () => {
|
|
49
|
-
const response = await contractsClient.get('anything/contracts');
|
|
50
|
-
const data = await response.json();
|
|
51
|
-
const authHeader = getHeader(data.headers, 'Authorization');
|
|
52
|
-
expect(authHeader).toMatch(/^Basic /);
|
|
53
|
-
expect(authHeader).toBe(`Basic ${Buffer.from('demo-user:demo-pass').toString('base64')}`);
|
|
54
|
-
expect(data.url).toContain('/anything/contracts');
|
|
55
|
-
}, 10000);
|
|
56
|
-
it('should remove auth headers when overridden with undefined', async () => {
|
|
57
|
-
const response = await clientsClient.get('anything/clients/export', {
|
|
58
|
-
headers: { 'X-API-Key': undefined }
|
|
59
|
-
});
|
|
60
|
-
const data = await response.json();
|
|
61
|
-
expect(getHeader(data.headers, 'X-Api-Key')).toBeUndefined();
|
|
62
|
-
expect(data.url).toContain('/anything/clients/export');
|
|
63
|
-
}, 10000);
|
|
64
|
-
it('should properly handle POST with JSON data and authentication', async () => {
|
|
65
|
-
const testData = { name: 'Test Client', email: 'test@example.com' };
|
|
66
|
-
const response = await clientsClient.post('anything/clients', { json: testData });
|
|
67
|
-
const data = await response.json();
|
|
68
|
-
expect(getHeader(data.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
69
|
-
expect(data.json).toEqual(testData);
|
|
70
|
-
expect(data.method).toBe('POST');
|
|
71
|
-
}, 10000);
|
|
72
|
-
});
|
|
73
|
-
describe('URL Path Construction', () => {
|
|
74
|
-
it('should correctly build URLs with chained prefixUrl', async () => {
|
|
75
|
-
const response = await clientsClient.get('anything/clients/details');
|
|
76
|
-
const data = await response.json();
|
|
77
|
-
expect(data.url).toContain('/anything/clients/details');
|
|
78
|
-
}, 10000);
|
|
79
|
-
it('should handle root path correctly', async () => {
|
|
80
|
-
const response = await contractsClient.get('anything/contracts');
|
|
81
|
-
const data = await response.json();
|
|
82
|
-
expect(data.url).toMatch(/\/anything\/contracts\/?$/);
|
|
83
|
-
}, 10000);
|
|
84
|
-
it('should handle POST to specific endpoints', async () => {
|
|
85
|
-
const testContract = { clientId: '123', title: 'Test Contract', value: 5000 };
|
|
86
|
-
const response = await contractsClient.post('anything/contracts/create', { json: testContract });
|
|
87
|
-
const data = await response.json();
|
|
88
|
-
expect(data.url).toContain('/anything/contracts/create');
|
|
89
|
-
expect(data.method).toBe('POST');
|
|
90
|
-
expect(data.json).toEqual(testContract);
|
|
91
|
-
}, 10000);
|
|
92
|
-
});
|
|
93
|
-
describe('Authentication Override Patterns', () => {
|
|
94
|
-
it('should allow per-request header overrides', async () => {
|
|
95
|
-
const response = await clientsClient.get('anything/clients/public', {
|
|
96
|
-
headers: {
|
|
97
|
-
'X-API-Key': 'different-key-456'
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
const data = await response.json();
|
|
101
|
-
expect(getHeader(data.headers, 'X-Api-Key')).toBe('different-key-456');
|
|
102
|
-
}, 10000);
|
|
103
|
-
it('should support multiple authentication methods', async () => {
|
|
104
|
-
const response = await contractsClient.get('anything/contracts/special', {
|
|
105
|
-
headers: {
|
|
106
|
-
'X-Special-Token': 'special-value',
|
|
107
|
-
'X-Client-ID': 'client-123'
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
const data = await response.json();
|
|
111
|
-
expect(getHeader(data.headers, 'Authorization')).toMatch(/^Basic /);
|
|
112
|
-
expect(getHeader(data.headers, 'X-Special-Token')).toBe('special-value');
|
|
113
|
-
expect(getHeader(data.headers, 'X-Client-Id')).toBe('client-123');
|
|
114
|
-
}, 10000);
|
|
115
|
-
});
|
|
116
|
-
describe('Real API Client Pattern', () => {
|
|
117
|
-
it('should demonstrate the complete authentication pattern', async () => {
|
|
118
|
-
const clientsResponse = await clientsClient.get('anything/clients');
|
|
119
|
-
const clientsData = await clientsResponse.json();
|
|
120
|
-
expect(getHeader(clientsData.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
121
|
-
const createResponse = await clientsClient.post('anything/clients', {
|
|
122
|
-
json: { name: 'Test Client', email: 'test@example.com' }
|
|
123
|
-
});
|
|
124
|
-
const createData = await createResponse.json();
|
|
125
|
-
expect(getHeader(createData.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
126
|
-
const exportResponse = await clientsClient.get('anything/clients/export', { headers: { 'X-API-Key': undefined } });
|
|
127
|
-
const exportData = await exportResponse.json();
|
|
128
|
-
expect(getHeader(exportData.headers, 'X-Api-Key')).toBeUndefined();
|
|
129
|
-
const contractsResponse = await contractsClient.get('anything/contracts');
|
|
130
|
-
const contractsResponseData = await contractsResponse.json();
|
|
131
|
-
expect(getHeader(contractsResponseData.headers, 'Authorization')).toMatch(/^Basic /);
|
|
132
|
-
const contractCreateResponse = await contractsClient.post('anything/contracts', {
|
|
133
|
-
json: { clientId: '123', title: 'Service Agreement', value: 10000 }
|
|
134
|
-
});
|
|
135
|
-
const contractCreateData = await contractCreateResponse.json();
|
|
136
|
-
expect(getHeader(contractCreateData.headers, 'Authorization')).toMatch(/^Basic /);
|
|
137
|
-
}, 15000);
|
|
138
|
-
});
|
|
139
|
-
describe('Error Tracing', () => {
|
|
140
|
-
beforeEach(() => {
|
|
141
|
-
mockedTracing.addEventStart.mockClear();
|
|
142
|
-
mockedTracing.addEventEnd.mockClear();
|
|
143
|
-
mockedTracing.addEventError.mockClear();
|
|
144
|
-
});
|
|
145
|
-
it('should trace timeout errors exactly once (no double-tracing)', async () => {
|
|
146
|
-
const timeoutClient = httpClient({
|
|
147
|
-
prefixUrl: 'https://httpbingo.org',
|
|
148
|
-
timeout: 1 // 1ms timeout will definitely fail on /delay/5
|
|
149
|
-
});
|
|
150
|
-
// Timeout will throw either TimeoutError or DOMException (AbortError)
|
|
151
|
-
await expect(timeoutClient.get('delay/5')).rejects.toThrow();
|
|
152
|
-
// Timeout errors should be traced by the wrapped fetch exactly once
|
|
153
|
-
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
154
|
-
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
155
|
-
expect(errorCall).toHaveProperty('id');
|
|
156
|
-
expect(errorCall).toHaveProperty('details');
|
|
157
|
-
// In real execution, ky's timeout throws a DOMException (AbortError)
|
|
158
|
-
expect(errorCall.details).toHaveProperty('message');
|
|
159
|
-
expect(errorCall.details.message).toMatch(/Fetch aborted|Unknown error/);
|
|
160
|
-
}, 10000);
|
|
161
|
-
});
|
|
162
|
-
describe('Trace ID Lifecycle', () => {
|
|
163
|
-
beforeEach(() => {
|
|
164
|
-
mockedTracing.addEventStart.mockClear();
|
|
165
|
-
mockedTracing.addEventEnd.mockClear();
|
|
166
|
-
mockedTracing.addEventError.mockClear();
|
|
167
|
-
});
|
|
168
|
-
it('should use same UUID trace ID for addEventStart and addEventEnd on successful requests', async () => {
|
|
169
|
-
const response = await httpBinClient.get('anything/success-trace');
|
|
170
|
-
expect(response.status).toBe(200);
|
|
171
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalledTimes(1);
|
|
172
|
-
expect(mockedTracing.addEventEnd).toHaveBeenCalledTimes(1);
|
|
173
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
174
|
-
const endCall = mockedTracing.addEventEnd.mock.calls[0][0];
|
|
175
|
-
expect(isUuidFormat(startCall.id)).toBe(true);
|
|
176
|
-
expect(isUuidFormat(endCall.id)).toBe(true);
|
|
177
|
-
expect(startCall.id).toBe(endCall.id);
|
|
178
|
-
}, 10000);
|
|
179
|
-
it('should use same UUID trace ID for addEventStart and addEventError on HTTP errors', async () => {
|
|
180
|
-
const noRetryClient = httpBinClient.extend({
|
|
181
|
-
retry: { limit: 0 }
|
|
182
|
-
});
|
|
183
|
-
await expect(noRetryClient.get('status/500')).rejects.toThrow();
|
|
184
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalledTimes(1);
|
|
185
|
-
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
186
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
187
|
-
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
188
|
-
expect(isUuidFormat(startCall.id)).toBe(true);
|
|
189
|
-
expect(isUuidFormat(errorCall.id)).toBe(true);
|
|
190
|
-
expect(startCall.id).toBe(errorCall.id);
|
|
191
|
-
}, 10000);
|
|
192
|
-
it('should use same UUID trace ID for addEventStart and addEventError on timeout errors', async () => {
|
|
193
|
-
const timeoutClient = httpClient({
|
|
194
|
-
prefixUrl: 'https://httpbingo.org',
|
|
195
|
-
timeout: 50
|
|
196
|
-
});
|
|
197
|
-
await expect(timeoutClient.get('delay/2')).rejects.toThrow();
|
|
198
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalledTimes(1);
|
|
199
|
-
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
200
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
201
|
-
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
202
|
-
expect(isUuidFormat(startCall.id)).toBe(true);
|
|
203
|
-
expect(isUuidFormat(errorCall.id)).toBe(true);
|
|
204
|
-
expect(startCall.id).toBe(errorCall.id);
|
|
205
|
-
}, 10000);
|
|
206
|
-
it('should maintain trace ID consistency between ky hooks and wrapped fetch', async () => {
|
|
207
|
-
const errorClient = httpClient({
|
|
208
|
-
prefixUrl: 'https://httpbingo.org',
|
|
209
|
-
timeout: 1,
|
|
210
|
-
retry: { limit: 0 }
|
|
211
|
-
});
|
|
212
|
-
try {
|
|
213
|
-
await errorClient.get('delay/5');
|
|
214
|
-
expect.fail('Should have thrown timeout error');
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
// Expected timeout error
|
|
218
|
-
}
|
|
219
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalledTimes(1);
|
|
220
|
-
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
221
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
222
|
-
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
223
|
-
expect(isUuidFormat(startCall.id)).toBe(true);
|
|
224
|
-
expect(isUuidFormat(errorCall.id)).toBe(true);
|
|
225
|
-
expect(startCall.id).toBe(errorCall.id);
|
|
226
|
-
}, 10000);
|
|
227
|
-
it('should always use UUID format when assignRequestId hook is present', async () => {
|
|
228
|
-
const scenarios = [
|
|
229
|
-
{ method: 'GET', url: 'anything/test-1' },
|
|
230
|
-
{ method: 'POST', url: 'anything/test-2', body: { data: 'test' } },
|
|
231
|
-
{ method: 'PUT', url: 'anything/test-3', body: { update: true } },
|
|
232
|
-
{ method: 'DELETE', url: 'anything/test-4' }
|
|
233
|
-
];
|
|
234
|
-
for (const scenario of scenarios) {
|
|
235
|
-
mockedTracing.addEventStart.mockClear();
|
|
236
|
-
mockedTracing.addEventEnd.mockClear();
|
|
237
|
-
if (scenario.method === 'GET') {
|
|
238
|
-
await httpBinClient.get(scenario.url);
|
|
239
|
-
}
|
|
240
|
-
else if (scenario.method === 'POST') {
|
|
241
|
-
await httpBinClient.post(scenario.url, { json: scenario.body });
|
|
242
|
-
}
|
|
243
|
-
else if (scenario.method === 'PUT') {
|
|
244
|
-
await httpBinClient.put(scenario.url, { json: scenario.body });
|
|
245
|
-
}
|
|
246
|
-
else if (scenario.method === 'DELETE') {
|
|
247
|
-
await httpBinClient.delete(scenario.url);
|
|
248
|
-
}
|
|
249
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
250
|
-
const endCall = mockedTracing.addEventEnd.mock.calls[0][0];
|
|
251
|
-
expect(isUuidFormat(startCall.id)).toBe(true);
|
|
252
|
-
expect(isUuidFormat(endCall.id)).toBe(true);
|
|
253
|
-
expect(startCall.id).toBe(endCall.id);
|
|
254
|
-
}
|
|
255
|
-
}, 15000);
|
|
256
|
-
});
|
|
257
|
-
describe('No Tracing Without X-Request-ID', () => {
|
|
258
|
-
beforeEach(() => {
|
|
259
|
-
mockedTracing.addEventStart.mockClear();
|
|
260
|
-
mockedTracing.addEventEnd.mockClear();
|
|
261
|
-
mockedTracing.addEventError.mockClear();
|
|
262
|
-
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
263
|
-
});
|
|
264
|
-
afterEach(() => {
|
|
265
|
-
vi.restoreAllMocks();
|
|
266
|
-
});
|
|
267
|
-
it('should skip tracing when assignRequestId hook is not present', async () => {
|
|
268
|
-
const noUuidClient = ky.create({
|
|
269
|
-
prefixUrl: 'https://httpbingo.org',
|
|
270
|
-
hooks: {
|
|
271
|
-
beforeRequest: [
|
|
272
|
-
traceRequest
|
|
273
|
-
],
|
|
274
|
-
afterResponse: [
|
|
275
|
-
traceResponse
|
|
276
|
-
],
|
|
277
|
-
beforeError: [
|
|
278
|
-
traceError
|
|
279
|
-
]
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
const wrappedNoUuidClient = noUuidClient.extend({
|
|
283
|
-
fetch: applyFetchErrorTracing(globalThis.fetch)
|
|
284
|
-
});
|
|
285
|
-
const response = await wrappedNoUuidClient.get('anything/no-trace-test');
|
|
286
|
-
expect(response.status).toBe(200);
|
|
287
|
-
// No tracing should occur without X-Request-ID
|
|
288
|
-
expect(mockedTracing.addEventStart).not.toHaveBeenCalled();
|
|
289
|
-
expect(mockedTracing.addEventEnd).not.toHaveBeenCalled();
|
|
290
|
-
// Warning should be logged
|
|
291
|
-
expect(console.warn).toHaveBeenCalledWith('createTraceId: X-Request-ID header not found. Tracing will be skipped for this request.');
|
|
292
|
-
}, 10000);
|
|
293
|
-
it('should skip tracing for errors when X-Request-ID is missing', async () => {
|
|
294
|
-
const noUuidClient = ky.create({
|
|
295
|
-
prefixUrl: 'https://httpbingo.org',
|
|
296
|
-
hooks: {
|
|
297
|
-
beforeRequest: [traceRequest],
|
|
298
|
-
afterResponse: [traceResponse],
|
|
299
|
-
beforeError: [traceError]
|
|
300
|
-
},
|
|
301
|
-
retry: { limit: 0 }
|
|
302
|
-
}).extend({
|
|
303
|
-
fetch: applyFetchErrorTracing(globalThis.fetch)
|
|
304
|
-
});
|
|
305
|
-
await expect(noUuidClient.get('status/500')).rejects.toThrow();
|
|
306
|
-
// No error tracing should occur without X-Request-ID
|
|
307
|
-
expect(mockedTracing.addEventStart).not.toHaveBeenCalled();
|
|
308
|
-
expect(mockedTracing.addEventError).not.toHaveBeenCalled();
|
|
309
|
-
// Warning should be logged
|
|
310
|
-
expect(console.warn).toHaveBeenCalled();
|
|
311
|
-
}, 10000);
|
|
312
|
-
});
|
|
313
|
-
describe('Trace ID Generation - Request Object Validation', () => {
|
|
314
|
-
beforeEach(() => {
|
|
315
|
-
mockedTracing.addEventStart.mockClear();
|
|
316
|
-
mockedTracing.addEventEnd.mockClear();
|
|
317
|
-
mockedTracing.addEventError.mockClear();
|
|
318
|
-
});
|
|
319
|
-
it('should pass a valid Request object to createTraceId', async () => {
|
|
320
|
-
const response = await httpBinClient.get('anything/trace-validation');
|
|
321
|
-
expect(response.status).toBe(200);
|
|
322
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalled();
|
|
323
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
324
|
-
const traceId = startCall.id;
|
|
325
|
-
// Trace ID should be a UUID from X-Request-ID header
|
|
326
|
-
expect(isUuidFormat(traceId)).toBe(true);
|
|
327
|
-
// An empty object would return null (no X-Request-ID header)
|
|
328
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
329
|
-
const emptyObjectResult = createTraceId({});
|
|
330
|
-
expect(emptyObjectResult).toBeNull();
|
|
331
|
-
warnSpy.mockRestore();
|
|
332
|
-
}, 10000);
|
|
333
|
-
it('should produce different trace IDs for GET vs POST to same endpoint', async () => {
|
|
334
|
-
mockedTracing.addEventStart.mockClear();
|
|
335
|
-
// Make GET request to specific endpoint
|
|
336
|
-
const getResponse = await httpBinClient.get('anything/trace-same-endpoint');
|
|
337
|
-
expect(getResponse.status).toBe(200);
|
|
338
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalled();
|
|
339
|
-
const getTraceId = mockedTracing.addEventStart.mock.calls[0][0].id;
|
|
340
|
-
mockedTracing.addEventStart.mockClear();
|
|
341
|
-
// Make POST request to SAME endpoint
|
|
342
|
-
const postResponse = await httpBinClient.post('anything/trace-same-endpoint', { json: { data: 'test' } });
|
|
343
|
-
expect(postResponse.status).toBe(200);
|
|
344
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalled();
|
|
345
|
-
const postTraceId = mockedTracing.addEventStart.mock.calls[0][0].id;
|
|
346
|
-
expect(getTraceId).not.toBe(postTraceId);
|
|
347
|
-
expect(isUuidFormat(getTraceId)).toBe(true);
|
|
348
|
-
expect(isUuidFormat(postTraceId)).toBe(true);
|
|
349
|
-
}, 10000);
|
|
350
|
-
it('should generate unique trace IDs for identical requests', async () => {
|
|
351
|
-
mockedTracing.addEventStart.mockClear();
|
|
352
|
-
const response1 = await httpBinClient.get('anything/unique-requests-1');
|
|
353
|
-
expect(response1.status).toBe(200);
|
|
354
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalled();
|
|
355
|
-
const traceId1 = mockedTracing.addEventStart.mock.calls[0][0].id;
|
|
356
|
-
mockedTracing.addEventStart.mockClear();
|
|
357
|
-
const response2 = await httpBinClient.get('anything/unique-requests-1');
|
|
358
|
-
expect(response2.status).toBe(200);
|
|
359
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalled();
|
|
360
|
-
const traceId2 = mockedTracing.addEventStart.mock.calls[0][0].id;
|
|
361
|
-
expect(isUuidFormat(traceId1)).toBe(true);
|
|
362
|
-
expect(isUuidFormat(traceId2)).toBe(true);
|
|
363
|
-
expect(traceId1).not.toBe(traceId2);
|
|
364
|
-
}, 10000);
|
|
365
|
-
it('should maintain trace ID consistency during timeout errors', async () => {
|
|
366
|
-
const timeoutClient = httpClient({
|
|
367
|
-
prefixUrl: 'https://httpbingo.org',
|
|
368
|
-
timeout: 300
|
|
369
|
-
});
|
|
370
|
-
mockedTracing.addEventStart.mockClear();
|
|
371
|
-
mockedTracing.addEventError.mockClear();
|
|
372
|
-
try {
|
|
373
|
-
await timeoutClient.get('delay/3');
|
|
374
|
-
expect.fail('Should have thrown timeout error');
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
expect(mockedTracing.addEventStart).toHaveBeenCalled();
|
|
378
|
-
const startCall = mockedTracing.addEventStart.mock.calls[0][0];
|
|
379
|
-
const requestTraceId = startCall.id;
|
|
380
|
-
expect(mockedTracing.addEventError).toHaveBeenCalled();
|
|
381
|
-
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
382
|
-
const errorTraceId = errorCall.id;
|
|
383
|
-
expect(isUuidFormat(requestTraceId)).toBe(true);
|
|
384
|
-
expect(isUuidFormat(errorTraceId)).toBe(true);
|
|
385
|
-
expect(requestTraceId).toBe(errorTraceId);
|
|
386
|
-
}
|
|
387
|
-
}, 10000);
|
|
388
|
-
});
|
|
389
|
-
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create a trace ID from the X-Request-ID header
|
|
3
|
-
*
|
|
4
|
-
* Returns the X-Request-ID header value if present, otherwise returns null.
|
|
5
|
-
* When null is returned, tracing should be skipped entirely.
|
|
6
|
-
*
|
|
7
|
-
* The X-Request-ID header is assigned by the assignRequestId hook,
|
|
8
|
-
* ensuring each request invocation has a unique identifier for tracing.
|
|
9
|
-
*
|
|
10
|
-
* @param {Request} request - The fetch API request object
|
|
11
|
-
* @returns {string | null} The X-Request-ID value or null if not present
|
|
12
|
-
*/
|
|
13
|
-
export default function createTraceId(request: Request): string | null;
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create a trace ID from the X-Request-ID header
|
|
3
|
-
*
|
|
4
|
-
* Returns the X-Request-ID header value if present, otherwise returns null.
|
|
5
|
-
* When null is returned, tracing should be skipped entirely.
|
|
6
|
-
*
|
|
7
|
-
* The X-Request-ID header is assigned by the assignRequestId hook,
|
|
8
|
-
* ensuring each request invocation has a unique identifier for tracing.
|
|
9
|
-
*
|
|
10
|
-
* @param {Request} request - The fetch API request object
|
|
11
|
-
* @returns {string | null} The X-Request-ID value or null if not present
|
|
12
|
-
*/
|
|
13
|
-
export default function createTraceId(request) {
|
|
14
|
-
const requestId = request.headers?.get('X-Request-ID');
|
|
15
|
-
if (!requestId) {
|
|
16
|
-
console.warn('createTraceId: X-Request-ID header not found. Tracing will be skipped for this request.');
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
return requestId;
|
|
20
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import createTraceId from './create_trace_id.js';
|
|
3
|
-
describe('utils/create_trace_id', () => {
|
|
4
|
-
it('returns the X-Request-ID header when present', () => {
|
|
5
|
-
const req = new Request('https://ex.com/users/1', {
|
|
6
|
-
method: 'GET',
|
|
7
|
-
headers: { 'X-Request-ID': 'test-uuid-123' }
|
|
8
|
-
});
|
|
9
|
-
const id = createTraceId(req);
|
|
10
|
-
expect(id).toBe('test-uuid-123');
|
|
11
|
-
});
|
|
12
|
-
it('returns null and logs warning when X-Request-ID header is missing', () => {
|
|
13
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
14
|
-
const req = new Request('https://ex.com/users/1', { method: 'GET' });
|
|
15
|
-
const id = createTraceId(req);
|
|
16
|
-
expect(id).toBeNull();
|
|
17
|
-
expect(warnSpy).toHaveBeenCalledWith('createTraceId: X-Request-ID header not found. Tracing will be skipped for this request.');
|
|
18
|
-
warnSpy.mockRestore();
|
|
19
|
-
});
|
|
20
|
-
});
|
package/dist/utils/index.d.ts
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
export { default as parseResponseBody } from './parse_response_body.js';
|
|
2
|
-
export { default as parseRequestBody } from './parse_request_body.js';
|
|
3
|
-
export { default as redactHeaders } from './redact_headers.js';
|
|
4
|
-
export { default as createTraceId } from './create_trace_id.js';
|
package/dist/utils/index.js
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
export { default as parseResponseBody } from './parse_response_body.js';
|
|
2
|
-
export { default as parseRequestBody } from './parse_request_body.js';
|
|
3
|
-
export { default as redactHeaders } from './redact_headers.js';
|
|
4
|
-
export { default as createTraceId } from './create_trace_id.js';
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Safely parses request body as JSON, falling back to string if parsing fails
|
|
3
|
-
*
|
|
4
|
-
* @param {Request} request - The fetch API request object
|
|
5
|
-
* @returns {object|string|null} The parsed response
|
|
6
|
-
*/
|
|
7
|
-
export default function parseRequestBody(request: Request): Promise<object | string | null>;
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Safely parses request body as JSON, falling back to string if parsing fails
|
|
3
|
-
*
|
|
4
|
-
* @param {Request} request - The fetch API request object
|
|
5
|
-
* @returns {object|string|null} The parsed response
|
|
6
|
-
*/
|
|
7
|
-
export default async function parseRequestBody(request) {
|
|
8
|
-
if (!request.body) {
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
11
|
-
const cloned = request.clone();
|
|
12
|
-
const body = await cloned.text();
|
|
13
|
-
try {
|
|
14
|
-
return JSON.parse(body);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return body;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import parseRequestBody from './parse_request_body.js';
|
|
3
|
-
describe('utils/parse_request_body', () => {
|
|
4
|
-
it('returns null when no body is present', async () => {
|
|
5
|
-
const req = new Request('https://ex.com', { method: 'GET' });
|
|
6
|
-
const result = await parseRequestBody(req);
|
|
7
|
-
expect(result).toBeNull();
|
|
8
|
-
});
|
|
9
|
-
it('parses JSON body when present', async () => {
|
|
10
|
-
const req = new Request('https://ex.com', { method: 'POST', body: JSON.stringify({ a: 1 }) });
|
|
11
|
-
const result = await parseRequestBody(req);
|
|
12
|
-
expect(result).toEqual({ a: 1 });
|
|
13
|
-
});
|
|
14
|
-
it('returns raw text when not valid JSON', async () => {
|
|
15
|
-
const req = new Request('https://ex.com', { method: 'POST', body: 'not-json' });
|
|
16
|
-
const result = await parseRequestBody(req);
|
|
17
|
-
expect(result).toBe('not-json');
|
|
18
|
-
});
|
|
19
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { KyResponse } from 'ky';
|
|
2
|
-
/**
|
|
3
|
-
* Parses response body based on content type:
|
|
4
|
-
* - application/json = object
|
|
5
|
-
* - text/plain = string
|
|
6
|
-
*
|
|
7
|
-
* @param {KyResponse} response
|
|
8
|
-
* @returns {object|string|null} The parsed response
|
|
9
|
-
*/
|
|
10
|
-
export default function parseResponseBody(response: KyResponse): Promise<object | string | null>;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parses response body based on content type:
|
|
3
|
-
* - application/json = object
|
|
4
|
-
* - text/plain = string
|
|
5
|
-
*
|
|
6
|
-
* @param {KyResponse} response
|
|
7
|
-
* @returns {object|string|null} The parsed response
|
|
8
|
-
*/
|
|
9
|
-
export default async function parseResponseBody(response) {
|
|
10
|
-
const cloned = response.clone();
|
|
11
|
-
const contentType = response.headers.get('content-type') || '';
|
|
12
|
-
const body = await cloned[contentType.includes('application/json') ? 'json' : 'text']();
|
|
13
|
-
return body || null;
|
|
14
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|