@output.ai/http 0.0.2 → 0.1.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/config.js +1 -1
- package/dist/hooks/assign_request_id.d.ts +9 -0
- package/dist/hooks/assign_request_id.js +15 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/trace_error.d.ts +10 -1
- package/dist/hooks/trace_error.js +53 -8
- package/dist/hooks/trace_request.js +7 -1
- package/dist/hooks/trace_response.js +7 -2
- package/dist/index.integration.test.js +289 -17
- package/dist/index.js +12 -2
- package/dist/index.spec.js +182 -19
- package/dist/utils/create_trace_id.d.ts +10 -5
- package/dist/utils/create_trace_id.js +15 -5
- package/dist/utils/create_trace_id.spec.js +15 -7
- package/dist/utils/parse_request_body.d.ts +2 -3
- package/dist/utils/parse_request_body.js +1 -1
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -6,5 +6,5 @@ export const config = {
|
|
|
6
6
|
* Whether to log verbose HTTP information (headers and bodies)
|
|
7
7
|
* Controlled by LOG_HTTP_VERBOSE environment variable
|
|
8
8
|
*/
|
|
9
|
-
logVerbose: ['1', 'true'].includes(process.env.
|
|
9
|
+
logVerbose: ['1', 'true'].includes(process.env.LOG_HTTP_VERBOSE)
|
|
10
10
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BeforeRequestHook } from 'ky';
|
|
2
|
+
/**
|
|
3
|
+
* Assigns a unique request ID to each request via X-Request-ID header
|
|
4
|
+
* This ensures each request invocation has a unique identifier for tracing,
|
|
5
|
+
* even if the request shape (method/url/headers) is identical
|
|
6
|
+
*
|
|
7
|
+
* If X-Request-ID already exists (from upstream), it's preserved for propagation
|
|
8
|
+
*/
|
|
9
|
+
export declare const assignRequestId: BeforeRequestHook;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Assigns a unique request ID to each request via X-Request-ID header
|
|
4
|
+
* This ensures each request invocation has a unique identifier for tracing,
|
|
5
|
+
* even if the request shape (method/url/headers) is identical
|
|
6
|
+
*
|
|
7
|
+
* If X-Request-ID already exists (from upstream), it's preserved for propagation
|
|
8
|
+
*/
|
|
9
|
+
export const assignRequestId = (request) => {
|
|
10
|
+
const existingId = request.headers.get('X-Request-ID');
|
|
11
|
+
if (!existingId) {
|
|
12
|
+
const requestId = randomUUID();
|
|
13
|
+
request.headers.set('X-Request-ID', requestId);
|
|
14
|
+
}
|
|
15
|
+
};
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import type { BeforeErrorHook } from 'ky';
|
|
1
|
+
import type { BeforeErrorHook, Input } from 'ky';
|
|
2
|
+
/**
|
|
3
|
+
* Wraps a fetch-like function to log and rethrow errors.
|
|
4
|
+
*
|
|
5
|
+
* This is nessesary as ky's beforeError hook does not trace non-HTTP errors.
|
|
6
|
+
* See: https://github.com/sindresorhus/ky/issues/296
|
|
7
|
+
* @param fetchFn - A fetch-compatible function (input, init) => Promise<Response>
|
|
8
|
+
* @returns A new function with the same signature that logs and rethrows errors.
|
|
9
|
+
*/
|
|
10
|
+
export declare function applyFetchErrorTracing(fetchFn: (input: Input, init?: RequestInit) => Promise<Response>): (input: Input, init?: RequestInit) => Promise<Response>;
|
|
2
11
|
/**
|
|
3
12
|
* Traces HTTP errors for observability using Output SDK tracing
|
|
4
13
|
*/
|
|
@@ -1,16 +1,61 @@
|
|
|
1
|
+
import { HTTPError } from 'ky';
|
|
1
2
|
import { createTraceId, redactHeaders } from '#utils/index.js';
|
|
2
3
|
import { Tracing } from '@output.ai/core/tracing';
|
|
4
|
+
/**
|
|
5
|
+
* Wraps a fetch-like function to log and rethrow errors.
|
|
6
|
+
*
|
|
7
|
+
* This is nessesary as ky's beforeError hook does not trace non-HTTP errors.
|
|
8
|
+
* See: https://github.com/sindresorhus/ky/issues/296
|
|
9
|
+
* @param fetchFn - A fetch-compatible function (input, init) => Promise<Response>
|
|
10
|
+
* @returns A new function with the same signature that logs and rethrows errors.
|
|
11
|
+
*/
|
|
12
|
+
export function applyFetchErrorTracing(fetchFn) {
|
|
13
|
+
return async (input, init) => {
|
|
14
|
+
try {
|
|
15
|
+
return await fetchFn(input, init);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const isHTTPError = err instanceof HTTPError;
|
|
19
|
+
if (!isHTTPError) {
|
|
20
|
+
const traceId = createTraceId(input);
|
|
21
|
+
// Skip tracing if no X-Request-ID header is present
|
|
22
|
+
if (traceId) {
|
|
23
|
+
const isAbortError = err instanceof DOMException && err.name === 'AbortError';
|
|
24
|
+
const message = isAbortError ? 'Fetch aborted due to timeout or manual cancellation' : 'Unknown error occurred';
|
|
25
|
+
Tracing.addEventError({
|
|
26
|
+
id: traceId,
|
|
27
|
+
details: {
|
|
28
|
+
error: JSON.stringify(err, null, 2),
|
|
29
|
+
message
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.warn('applyFetchErrorTracing: Skipping fetch error tracing - no X-Request-ID header');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
3
41
|
/**
|
|
4
42
|
* Traces HTTP errors for observability using Output SDK tracing
|
|
5
43
|
*/
|
|
6
44
|
export const traceError = (error) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
45
|
+
const traceId = createTraceId(error.request);
|
|
46
|
+
// Skip tracing if no X-Request-ID header is present
|
|
47
|
+
if (traceId) {
|
|
48
|
+
Tracing.addEventError({
|
|
49
|
+
id: traceId,
|
|
50
|
+
details: {
|
|
51
|
+
status: error.response.status,
|
|
52
|
+
statusText: error.response.statusText,
|
|
53
|
+
headers: redactHeaders(Object.fromEntries(error.response.headers.entries()))
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.warn('traceError: Skipping HTTP error tracing - no X-Request-ID header');
|
|
59
|
+
}
|
|
15
60
|
return error;
|
|
16
61
|
};
|
|
@@ -6,6 +6,12 @@ import { config } from '#config.js';
|
|
|
6
6
|
* Respects LOG_HTTP_VERBOSE environment variable for detailed logging
|
|
7
7
|
*/
|
|
8
8
|
export const traceRequest = async (request, _options) => {
|
|
9
|
+
const traceId = createTraceId(request);
|
|
10
|
+
// Skip tracing if no X-Request-ID header is present
|
|
11
|
+
if (!traceId) {
|
|
12
|
+
console.warn('traceRequest: Skipping request tracing - no X-Request-ID header');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
9
15
|
const details = {
|
|
10
16
|
method: request.method,
|
|
11
17
|
url: request.url
|
|
@@ -15,5 +21,5 @@ export const traceRequest = async (request, _options) => {
|
|
|
15
21
|
details.headers = redactHeaders(headers);
|
|
16
22
|
details.body = await parseRequestBody(request);
|
|
17
23
|
}
|
|
18
|
-
Tracing.addEventStart({ id:
|
|
24
|
+
Tracing.addEventStart({ id: traceId, kind: 'http', name: 'request', details });
|
|
19
25
|
};
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { Tracing } from '@output.ai/core/tracing';
|
|
2
2
|
import { redactHeaders, createTraceId, parseResponseBody } from '#utils/index.js';
|
|
3
|
-
;
|
|
4
3
|
import { config } from '#config.js';
|
|
5
4
|
/**
|
|
6
5
|
* Traces HTTP response for observability using Output SDK tracing
|
|
7
6
|
* Respects LOG_HTTP_VERBOSE environment variable for detailed logging
|
|
8
7
|
*/
|
|
9
8
|
export const traceResponse = async (request, _options, response) => {
|
|
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
|
+
}
|
|
10
15
|
const details = {
|
|
11
16
|
status: response.status,
|
|
12
17
|
statusText: response.statusText
|
|
@@ -16,6 +21,6 @@ export const traceResponse = async (request, _options, response) => {
|
|
|
16
21
|
details.headers = redactHeaders(responseHeaders);
|
|
17
22
|
details.body = await parseResponseBody(response);
|
|
18
23
|
}
|
|
19
|
-
Tracing.addEventEnd({ id:
|
|
24
|
+
Tracing.addEventEnd({ id: traceId, details });
|
|
20
25
|
return response;
|
|
21
26
|
};
|
|
@@ -1,8 +1,30 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import ky from 'ky';
|
|
2
3
|
import { httpClient } from './index.js';
|
|
4
|
+
import { Tracing } from '@output.ai/core/tracing';
|
|
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('@output.ai/core/tracing', () => ({
|
|
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
|
+
};
|
|
3
25
|
describe('HTTP Client Authentication Integration', () => {
|
|
4
26
|
const httpBinClient = httpClient({
|
|
5
|
-
prefixUrl: 'https://
|
|
27
|
+
prefixUrl: 'https://httpbingo.org',
|
|
6
28
|
timeout: 5000
|
|
7
29
|
});
|
|
8
30
|
const clientsClient = httpBinClient.extend({
|
|
@@ -19,15 +41,16 @@ describe('HTTP Client Authentication Integration', () => {
|
|
|
19
41
|
it('should include API key for clients endpoints', async () => {
|
|
20
42
|
const response = await clientsClient.get('anything/clients');
|
|
21
43
|
const data = await response.json();
|
|
22
|
-
expect(data.headers
|
|
44
|
+
expect(getHeader(data.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
23
45
|
expect(data.url).toContain('/anything/clients');
|
|
24
46
|
expect(data.method).toBe('GET');
|
|
25
47
|
}, 10000);
|
|
26
48
|
it('should include Basic auth for contracts endpoints', async () => {
|
|
27
49
|
const response = await contractsClient.get('anything/contracts');
|
|
28
50
|
const data = await response.json();
|
|
29
|
-
|
|
30
|
-
expect(
|
|
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')}`);
|
|
31
54
|
expect(data.url).toContain('/anything/contracts');
|
|
32
55
|
}, 10000);
|
|
33
56
|
it('should remove auth headers when overridden with undefined', async () => {
|
|
@@ -35,14 +58,14 @@ describe('HTTP Client Authentication Integration', () => {
|
|
|
35
58
|
headers: { 'X-API-Key': undefined }
|
|
36
59
|
});
|
|
37
60
|
const data = await response.json();
|
|
38
|
-
expect(data.headers
|
|
61
|
+
expect(getHeader(data.headers, 'X-Api-Key')).toBeUndefined();
|
|
39
62
|
expect(data.url).toContain('/anything/clients/export');
|
|
40
63
|
}, 10000);
|
|
41
64
|
it('should properly handle POST with JSON data and authentication', async () => {
|
|
42
65
|
const testData = { name: 'Test Client', email: 'test@example.com' };
|
|
43
66
|
const response = await clientsClient.post('anything/clients', { json: testData });
|
|
44
67
|
const data = await response.json();
|
|
45
|
-
expect(data.headers
|
|
68
|
+
expect(getHeader(data.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
46
69
|
expect(data.json).toEqual(testData);
|
|
47
70
|
expect(data.method).toBe('POST');
|
|
48
71
|
}, 10000);
|
|
@@ -75,7 +98,7 @@ describe('HTTP Client Authentication Integration', () => {
|
|
|
75
98
|
}
|
|
76
99
|
});
|
|
77
100
|
const data = await response.json();
|
|
78
|
-
expect(data.headers
|
|
101
|
+
expect(getHeader(data.headers, 'X-Api-Key')).toBe('different-key-456');
|
|
79
102
|
}, 10000);
|
|
80
103
|
it('should support multiple authentication methods', async () => {
|
|
81
104
|
const response = await contractsClient.get('anything/contracts/special', {
|
|
@@ -85,33 +108,282 @@ describe('HTTP Client Authentication Integration', () => {
|
|
|
85
108
|
}
|
|
86
109
|
});
|
|
87
110
|
const data = await response.json();
|
|
88
|
-
expect(data.headers
|
|
89
|
-
expect(data.headers
|
|
90
|
-
expect(data.headers
|
|
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');
|
|
91
114
|
}, 10000);
|
|
92
115
|
});
|
|
93
116
|
describe('Real API Client Pattern', () => {
|
|
94
117
|
it('should demonstrate the complete authentication pattern', async () => {
|
|
95
118
|
const clientsResponse = await clientsClient.get('anything/clients');
|
|
96
119
|
const clientsData = await clientsResponse.json();
|
|
97
|
-
expect(clientsData.headers
|
|
120
|
+
expect(getHeader(clientsData.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
98
121
|
const createResponse = await clientsClient.post('anything/clients', {
|
|
99
122
|
json: { name: 'Test Client', email: 'test@example.com' }
|
|
100
123
|
});
|
|
101
124
|
const createData = await createResponse.json();
|
|
102
|
-
expect(createData.headers
|
|
125
|
+
expect(getHeader(createData.headers, 'X-Api-Key')).toBe('demo-api-key-12345');
|
|
103
126
|
const exportResponse = await clientsClient.get('anything/clients/export', { headers: { 'X-API-Key': undefined } });
|
|
104
127
|
const exportData = await exportResponse.json();
|
|
105
|
-
expect(exportData.headers
|
|
128
|
+
expect(getHeader(exportData.headers, 'X-Api-Key')).toBeUndefined();
|
|
106
129
|
const contractsResponse = await contractsClient.get('anything/contracts');
|
|
107
130
|
const contractsResponseData = await contractsResponse.json();
|
|
108
|
-
expect(contractsResponseData.headers
|
|
131
|
+
expect(getHeader(contractsResponseData.headers, 'Authorization')).toMatch(/^Basic /);
|
|
109
132
|
const contractCreateResponse = await contractsClient.post('anything/contracts', {
|
|
110
133
|
json: { clientId: '123', title: 'Service Agreement', value: 10000 }
|
|
111
134
|
});
|
|
112
135
|
const contractCreateData = await contractCreateResponse.json();
|
|
113
|
-
expect(contractCreateData.headers
|
|
114
|
-
console.log('✅ All authentication patterns working correctly');
|
|
136
|
+
expect(getHeader(contractCreateData.headers, 'Authorization')).toMatch(/^Basic /);
|
|
115
137
|
}, 15000);
|
|
116
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
|
+
});
|
|
117
389
|
});
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import ky from 'ky';
|
|
2
|
-
import { traceRequest, traceResponse, traceError } from './hooks/index.js';
|
|
2
|
+
import { assignRequestId, traceRequest, traceResponse, traceError } from './hooks/index.js';
|
|
3
|
+
import { applyFetchErrorTracing } from '#hooks/trace_error.js';
|
|
3
4
|
// Create base HTTP client with standard Output SDK tracing hooks
|
|
4
5
|
const baseHttpClient = ky.create({
|
|
5
6
|
hooks: {
|
|
6
7
|
beforeRequest: [
|
|
8
|
+
assignRequestId,
|
|
7
9
|
traceRequest
|
|
8
10
|
],
|
|
9
11
|
afterResponse: [
|
|
@@ -14,6 +16,14 @@ const baseHttpClient = ky.create({
|
|
|
14
16
|
]
|
|
15
17
|
}
|
|
16
18
|
});
|
|
19
|
+
const applyDefaultOptions = (userOptions) => (parentOptions) => {
|
|
20
|
+
const kyFetch = parentOptions.fetch || globalThis.fetch.bind(globalThis);
|
|
21
|
+
const patchedFetch = applyFetchErrorTracing(kyFetch);
|
|
22
|
+
return {
|
|
23
|
+
fetch: patchedFetch,
|
|
24
|
+
...userOptions
|
|
25
|
+
};
|
|
26
|
+
};
|
|
17
27
|
/**
|
|
18
28
|
* Creates an HTTP client with Output SDK tracing
|
|
19
29
|
*
|
|
@@ -35,6 +45,6 @@ const baseHttpClient = ky.create({
|
|
|
35
45
|
* ```
|
|
36
46
|
*/
|
|
37
47
|
export function httpClient(options = {}) {
|
|
38
|
-
return baseHttpClient.extend(options);
|
|
48
|
+
return baseHttpClient.extend(applyDefaultOptions(options));
|
|
39
49
|
}
|
|
40
50
|
export { HTTPError, TimeoutError } from 'ky';
|
package/dist/index.spec.js
CHANGED
|
@@ -20,12 +20,80 @@ vi.mock('ky', () => {
|
|
|
20
20
|
status: 200,
|
|
21
21
|
headers: { 'content-type': 'application/json' }
|
|
22
22
|
});
|
|
23
|
+
// Mock error types that match ky's actual error classes
|
|
24
|
+
// IMPORTANT: These must be the same instances exported by the mock
|
|
25
|
+
// so that instanceof checks work correctly
|
|
26
|
+
class MockHTTPError extends Error {
|
|
27
|
+
response;
|
|
28
|
+
request;
|
|
29
|
+
options;
|
|
30
|
+
constructor(response, request, options) {
|
|
31
|
+
super(`${response.status} ${response.statusText}`);
|
|
32
|
+
this.name = 'HTTPError';
|
|
33
|
+
this.response = response;
|
|
34
|
+
this.request = request;
|
|
35
|
+
this.options = options;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
class MockTimeoutError extends Error {
|
|
39
|
+
request;
|
|
40
|
+
constructor(request) {
|
|
41
|
+
super('Request timed out');
|
|
42
|
+
this.name = 'TimeoutError';
|
|
43
|
+
this.request = request;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Helper to extract URL string from various input types
|
|
47
|
+
const getUrlString = (input) => {
|
|
48
|
+
if (typeof input === 'string') {
|
|
49
|
+
return input;
|
|
50
|
+
}
|
|
51
|
+
if (input instanceof Request) {
|
|
52
|
+
return input.url;
|
|
53
|
+
}
|
|
54
|
+
return input.toString();
|
|
55
|
+
};
|
|
56
|
+
// Default mock fetch implementation
|
|
57
|
+
const defaultMockFetch = (input, init) => {
|
|
58
|
+
const urlStr = getUrlString(input);
|
|
59
|
+
const request = input instanceof Request ? input : new Request(urlStr, init);
|
|
60
|
+
// Simulate timeout error (bypass hooks, thrown at fetch level)
|
|
61
|
+
if (urlStr.includes('/timeout')) {
|
|
62
|
+
throw new MockTimeoutError(request);
|
|
63
|
+
}
|
|
64
|
+
// Simulate network error (bypass hooks, thrown at fetch level)
|
|
65
|
+
if (urlStr.includes('/network-error')) {
|
|
66
|
+
throw new TypeError('Failed to fetch');
|
|
67
|
+
}
|
|
68
|
+
// Simulate HTTP 500 error (goes through hooks)
|
|
69
|
+
if (urlStr.includes('/500')) {
|
|
70
|
+
return Promise.resolve(new Response('Internal Server Error', {
|
|
71
|
+
status: 500,
|
|
72
|
+
statusText: 'Internal Server Error'
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
// Simulate HTTP 404 error (goes through hooks)
|
|
76
|
+
if (urlStr.includes('/404')) {
|
|
77
|
+
return Promise.resolve(new Response('Not Found', {
|
|
78
|
+
status: 404,
|
|
79
|
+
statusText: 'Not Found'
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
return Promise.resolve(createMockResponse());
|
|
83
|
+
};
|
|
23
84
|
class MockKy {
|
|
24
85
|
hooks = {};
|
|
25
86
|
options = {};
|
|
87
|
+
customFetch;
|
|
26
88
|
constructor(options = {}) {
|
|
27
89
|
this.hooks = options.hooks || {};
|
|
28
|
-
|
|
90
|
+
// Use provided fetch or default mock fetch
|
|
91
|
+
this.customFetch = options.fetch || defaultMockFetch;
|
|
92
|
+
// Store options with fetch function included
|
|
93
|
+
this.options = {
|
|
94
|
+
...options,
|
|
95
|
+
fetch: this.customFetch
|
|
96
|
+
};
|
|
29
97
|
}
|
|
30
98
|
async runHooks(hookType, ...args) {
|
|
31
99
|
const hooks = this.hooks[hookType] || [];
|
|
@@ -54,16 +122,33 @@ vi.mock('ky', () => {
|
|
|
54
122
|
const request = new Request(fullUrl, { method });
|
|
55
123
|
// Run beforeRequest hooks
|
|
56
124
|
await this.runHooks('beforeRequest', request);
|
|
57
|
-
|
|
58
|
-
//
|
|
125
|
+
// Use the custom fetch (which may be wrapped by applyFetchErrorTracing)
|
|
126
|
+
// Fetch-level errors (timeout, network) bypass hooks entirely and will throw
|
|
127
|
+
const response = await this.customFetch(request, { method });
|
|
128
|
+
// Check for HTTP errors (non-2xx status codes)
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
const httpError = new MockHTTPError(response, request, options);
|
|
131
|
+
// Run beforeError hooks for HTTP errors
|
|
132
|
+
try {
|
|
133
|
+
await this.runHooks('beforeError', httpError);
|
|
134
|
+
}
|
|
135
|
+
catch (hookErr) {
|
|
136
|
+
// Hooks can transform the error
|
|
137
|
+
throw hookErr;
|
|
138
|
+
}
|
|
139
|
+
throw httpError;
|
|
140
|
+
}
|
|
141
|
+
// Run afterResponse hooks for successful responses
|
|
59
142
|
await this.runHooks('afterResponse', request, options, response);
|
|
60
143
|
return response;
|
|
61
144
|
}
|
|
62
145
|
extend(options = {}) {
|
|
63
|
-
|
|
146
|
+
// Handle function-based options (like applyDefaultOptions returns)
|
|
147
|
+
const resolvedOptions = typeof options === 'function' ? options(this.options) : options;
|
|
148
|
+
const mergedOptions = { ...this.options, ...resolvedOptions };
|
|
64
149
|
const mergedHooks = { ...this.hooks };
|
|
65
|
-
if (
|
|
66
|
-
Object.entries(
|
|
150
|
+
if (resolvedOptions.hooks) {
|
|
151
|
+
Object.entries(resolvedOptions.hooks).forEach(([hookType, hookArray]) => {
|
|
67
152
|
mergedHooks[hookType] = [
|
|
68
153
|
...(this.hooks[hookType] || []),
|
|
69
154
|
...(Array.isArray(hookArray) ? hookArray : [])
|
|
@@ -80,18 +165,8 @@ vi.mock('ky', () => {
|
|
|
80
165
|
return {
|
|
81
166
|
default: new MockKy(),
|
|
82
167
|
create: (options) => new MockKy(options),
|
|
83
|
-
HTTPError:
|
|
84
|
-
|
|
85
|
-
super(message);
|
|
86
|
-
this.name = 'HTTPError';
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
TimeoutError: class TimeoutError extends Error {
|
|
90
|
-
constructor(message) {
|
|
91
|
-
super(message);
|
|
92
|
-
this.name = 'TimeoutError';
|
|
93
|
-
}
|
|
94
|
-
}
|
|
168
|
+
HTTPError: MockHTTPError,
|
|
169
|
+
TimeoutError: MockTimeoutError
|
|
95
170
|
};
|
|
96
171
|
});
|
|
97
172
|
const mockedTracing = vi.mocked(Tracing, true);
|
|
@@ -182,7 +257,7 @@ describe('HTTP Client', () => {
|
|
|
182
257
|
}
|
|
183
258
|
],
|
|
184
259
|
afterResponse: [
|
|
185
|
-
async (
|
|
260
|
+
async (_request, _options, response) => {
|
|
186
261
|
customAfterResponseCalled();
|
|
187
262
|
return response;
|
|
188
263
|
}
|
|
@@ -225,4 +300,92 @@ describe('HTTP Client', () => {
|
|
|
225
300
|
expect(postData.success).toBe(true);
|
|
226
301
|
});
|
|
227
302
|
});
|
|
303
|
+
describe('Error Tracing', () => {
|
|
304
|
+
describe('Fetch-Level Errors (should be traced by wrapped fetch)', () => {
|
|
305
|
+
it('should trace timeout errors that bypass ky hooks', async () => {
|
|
306
|
+
const client = httpClient({
|
|
307
|
+
prefixUrl: 'https://api.example.com'
|
|
308
|
+
});
|
|
309
|
+
await expect(client.get('users/timeout')).rejects.toThrow(TimeoutError);
|
|
310
|
+
// Timeout errors should be traced by the wrapped fetch
|
|
311
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
312
|
+
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
313
|
+
expect(errorCall).toHaveProperty('id');
|
|
314
|
+
expect(errorCall).toHaveProperty('details');
|
|
315
|
+
expect(errorCall.details).toHaveProperty('message');
|
|
316
|
+
expect(errorCall.details).toHaveProperty('error');
|
|
317
|
+
});
|
|
318
|
+
it('should trace network errors that bypass ky hooks', async () => {
|
|
319
|
+
const client = httpClient({
|
|
320
|
+
prefixUrl: 'https://api.example.com'
|
|
321
|
+
});
|
|
322
|
+
await expect(client.get('users/network-error')).rejects.toThrow(TypeError);
|
|
323
|
+
// Network errors should be traced by the wrapped fetch
|
|
324
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
325
|
+
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
326
|
+
expect(errorCall).toHaveProperty('id');
|
|
327
|
+
expect(errorCall).toHaveProperty('details');
|
|
328
|
+
expect(errorCall.details).toHaveProperty('message');
|
|
329
|
+
expect(errorCall.details.message).toBe('Unknown error occurred');
|
|
330
|
+
expect(errorCall.details).toHaveProperty('error');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
describe('HTTP Errors (should be traced by beforeError hook only)', () => {
|
|
334
|
+
it('should trace HTTP 500 errors via beforeError hook, not fetch wrapper', async () => {
|
|
335
|
+
const client = httpClient({
|
|
336
|
+
prefixUrl: 'https://api.example.com'
|
|
337
|
+
});
|
|
338
|
+
await expect(client.get('users/500')).rejects.toThrow(HTTPError);
|
|
339
|
+
// HTTP errors should be traced by the beforeError hook (traceError)
|
|
340
|
+
// The wrapped fetch should NOT trace it (to prevent double-tracing)
|
|
341
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
342
|
+
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
343
|
+
expect(errorCall).toHaveProperty('id');
|
|
344
|
+
expect(errorCall).toHaveProperty('details');
|
|
345
|
+
expect(errorCall.details).toHaveProperty('status', 500);
|
|
346
|
+
expect(errorCall.details).toHaveProperty('statusText', 'Internal Server Error');
|
|
347
|
+
});
|
|
348
|
+
it('should trace HTTP 404 errors via beforeError hook, not fetch wrapper', async () => {
|
|
349
|
+
const client = httpClient({
|
|
350
|
+
prefixUrl: 'https://api.example.com'
|
|
351
|
+
});
|
|
352
|
+
await expect(client.get('users/404')).rejects.toThrow(HTTPError);
|
|
353
|
+
// HTTP errors should be traced by the beforeError hook (traceError)
|
|
354
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
355
|
+
const errorCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
356
|
+
expect(errorCall).toHaveProperty('id');
|
|
357
|
+
expect(errorCall).toHaveProperty('details');
|
|
358
|
+
expect(errorCall.details).toHaveProperty('status', 404);
|
|
359
|
+
expect(errorCall.details).toHaveProperty('statusText', 'Not Found');
|
|
360
|
+
});
|
|
361
|
+
it('should not double-trace HTTP errors', async () => {
|
|
362
|
+
const client = httpClient({
|
|
363
|
+
prefixUrl: 'https://api.example.com'
|
|
364
|
+
});
|
|
365
|
+
await expect(client.get('users/500')).rejects.toThrow(HTTPError);
|
|
366
|
+
// Should only be traced once (by beforeError hook)
|
|
367
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
describe('Error Type Differentiation', () => {
|
|
371
|
+
it('should handle timeout and HTTP errors differently in the same client', async () => {
|
|
372
|
+
const client = httpClient({
|
|
373
|
+
prefixUrl: 'https://api.example.com'
|
|
374
|
+
});
|
|
375
|
+
// Test timeout error
|
|
376
|
+
mockedTracing.addEventError.mockClear();
|
|
377
|
+
await expect(client.get('users/timeout')).rejects.toThrow(TimeoutError);
|
|
378
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
379
|
+
const timeoutCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
380
|
+
expect(timeoutCall.details).toHaveProperty('message');
|
|
381
|
+
expect(timeoutCall.details).toHaveProperty('error');
|
|
382
|
+
// Test HTTP error
|
|
383
|
+
mockedTracing.addEventError.mockClear();
|
|
384
|
+
await expect(client.get('users/500')).rejects.toThrow(HTTPError);
|
|
385
|
+
expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
|
|
386
|
+
const httpCall = mockedTracing.addEventError.mock.calls[0][0];
|
|
387
|
+
expect(httpCall.details).toHaveProperty('status', 500);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
228
391
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { KyRequest } from 'ky';
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
2
|
+
* Create a trace ID from the X-Request-ID header
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
7
12
|
*/
|
|
8
|
-
export default function createTraceId(request:
|
|
13
|
+
export default function createTraceId(request: Request): string | null;
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
2
|
+
* Create a trace ID from the X-Request-ID header
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
7
12
|
*/
|
|
8
13
|
export default function createTraceId(request) {
|
|
9
|
-
|
|
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;
|
|
10
20
|
}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import createTraceId from './create_trace_id.js';
|
|
3
3
|
describe('utils/create_trace_id', () => {
|
|
4
|
-
it('
|
|
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(() => { });
|
|
5
14
|
const req = new Request('https://ex.com/users/1', { method: 'GET' });
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
expect(
|
|
9
|
-
|
|
10
|
-
expect(id1).toBe(id2);
|
|
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();
|
|
11
19
|
});
|
|
12
20
|
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type { KyRequest } from 'ky';
|
|
2
1
|
/**
|
|
3
2
|
* Safely parses request body as JSON, falling back to string if parsing fails
|
|
4
3
|
*
|
|
5
|
-
* @param {
|
|
4
|
+
* @param {Request} request - The fetch API request object
|
|
6
5
|
* @returns {object|string|null} The parsed response
|
|
7
6
|
*/
|
|
8
|
-
export default function parseRequestBody(request:
|
|
7
|
+
export default function parseRequestBody(request: Request): Promise<object | string | null>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Safely parses request body as JSON, falling back to string if parsing fails
|
|
3
3
|
*
|
|
4
|
-
* @param {
|
|
4
|
+
* @param {Request} request - The fetch API request object
|
|
5
5
|
* @returns {object|string|null} The parsed response
|
|
6
6
|
*/
|
|
7
7
|
export default async function parseRequestBody(request) {
|