@outputai/http 0.1.4 → 0.1.5

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.
Files changed (43) hide show
  1. package/dist/config.d.ts +10 -0
  2. package/dist/config.js +10 -0
  3. package/dist/hooks/assign_request_id.d.ts +9 -0
  4. package/dist/hooks/assign_request_id.js +15 -0
  5. package/dist/hooks/index.d.ts +4 -0
  6. package/dist/hooks/index.js +4 -0
  7. package/dist/hooks/trace_error.d.ts +14 -0
  8. package/dist/hooks/trace_error.js +61 -0
  9. package/dist/hooks/trace_error.spec.d.ts +1 -0
  10. package/dist/hooks/trace_error.spec.js +35 -0
  11. package/dist/hooks/trace_request.d.ts +6 -0
  12. package/dist/hooks/trace_request.js +25 -0
  13. package/dist/hooks/trace_request.spec.d.ts +1 -0
  14. package/dist/hooks/trace_request.spec.js +60 -0
  15. package/dist/hooks/trace_response.d.ts +6 -0
  16. package/dist/hooks/trace_response.js +26 -0
  17. package/dist/hooks/trace_response.spec.d.ts +1 -0
  18. package/dist/hooks/trace_response.spec.js +68 -0
  19. package/dist/index.d.ts +26 -0
  20. package/dist/index.integration.test.d.ts +1 -0
  21. package/dist/index.integration.test.js +389 -0
  22. package/dist/index.js +51 -0
  23. package/dist/index.spec.d.ts +1 -0
  24. package/dist/index.spec.js +391 -0
  25. package/dist/utils/create_trace_id.d.ts +13 -0
  26. package/dist/utils/create_trace_id.js +20 -0
  27. package/dist/utils/create_trace_id.spec.d.ts +1 -0
  28. package/dist/utils/create_trace_id.spec.js +20 -0
  29. package/dist/utils/index.d.ts +4 -0
  30. package/dist/utils/index.js +4 -0
  31. package/dist/utils/parse_request_body.d.ts +7 -0
  32. package/dist/utils/parse_request_body.js +19 -0
  33. package/dist/utils/parse_request_body.spec.d.ts +1 -0
  34. package/dist/utils/parse_request_body.spec.js +19 -0
  35. package/dist/utils/parse_response_body.d.ts +10 -0
  36. package/dist/utils/parse_response_body.js +14 -0
  37. package/dist/utils/parse_response_body.spec.d.ts +1 -0
  38. package/dist/utils/parse_response_body.spec.js +19 -0
  39. package/dist/utils/redact_headers.d.ts +6 -0
  40. package/dist/utils/redact_headers.js +27 -0
  41. package/dist/utils/redact_headers.spec.d.ts +1 -0
  42. package/dist/utils/redact_headers.spec.js +245 -0
  43. package/package.json +2 -2
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Configuration for HTTP client behavior
3
+ */
4
+ export declare const config: {
5
+ /**
6
+ * Whether to log verbose HTTP information (headers and bodies)
7
+ * Controlled by OUTPUT_TRACE_HTTP_VERBOSE environment variable
8
+ */
9
+ logVerbose: boolean;
10
+ };
package/dist/config.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Configuration for HTTP client behavior
3
+ */
4
+ export const config = {
5
+ /**
6
+ * Whether to log verbose HTTP information (headers and bodies)
7
+ * Controlled by OUTPUT_TRACE_HTTP_VERBOSE environment variable
8
+ */
9
+ logVerbose: ['1', 'true'].includes(process.env.OUTPUT_TRACE_HTTP_VERBOSE)
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
+ };
@@ -0,0 +1,4 @@
1
+ export { assignRequestId } from './assign_request_id.js';
2
+ export { traceRequest } from './trace_request.js';
3
+ export { traceResponse } from './trace_response.js';
4
+ export { traceError } from './trace_error.js';
@@ -0,0 +1,4 @@
1
+ export { assignRequestId } from './assign_request_id.js';
2
+ export { traceRequest } from './trace_request.js';
3
+ export { traceResponse } from './trace_response.js';
4
+ export { traceError } from './trace_error.js';
@@ -0,0 +1,14 @@
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>;
11
+ /**
12
+ * Traces HTTP errors for observability using Output.ai tracing
13
+ */
14
+ export declare const traceError: BeforeErrorHook;
@@ -0,0 +1,61 @@
1
+ import { HTTPError } from 'ky';
2
+ import { createTraceId, redactHeaders } from '#utils/index.js';
3
+ import { Tracing } from '@outputai/core/sdk_activity_integration';
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
+ }
41
+ /**
42
+ * Traces HTTP errors for observability using Output.ai tracing
43
+ */
44
+ export const traceError = (error, _state) => {
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
+ }
60
+ return error;
61
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { HTTPError } from 'ky';
3
+ import { traceError } from './trace_error.js';
4
+ import { Tracing } from '@outputai/core/sdk_activity_integration';
5
+ vi.mock('../utils/index.js', () => ({
6
+ redactHeaders: vi.fn((h) => h),
7
+ createTraceId: vi.fn(() => 'trace-id')
8
+ }));
9
+ vi.mock('@outputai/core/sdk_activity_integration', () => ({
10
+ Tracing: {
11
+ addEventError: vi.fn()
12
+ }
13
+ }));
14
+ const mockedTracing = vi.mocked(Tracing, true);
15
+ describe('http/hooks/trace_error', () => {
16
+ beforeEach(() => {
17
+ mockedTracing.addEventError.mockClear();
18
+ });
19
+ it('traces error with response details when response exists', async () => {
20
+ const request = new Request('https://api.example.com/users/1', { method: 'GET' });
21
+ const response = new Response('Unauthorized', {
22
+ status: 401,
23
+ statusText: 'Unauthorized',
24
+ headers: { authorization: 'secret', 'x-custom': 'v' }
25
+ });
26
+ const error = new HTTPError(response, request, {});
27
+ const returned = await traceError(error, { retryCount: 0 });
28
+ expect(returned).toBe(error);
29
+ expect(mockedTracing.addEventError).toHaveBeenCalledTimes(1);
30
+ const arg = mockedTracing.addEventError.mock.calls[0][0];
31
+ expect(arg.details.status).toBe(401);
32
+ expect(arg.details.statusText).toBe('Unauthorized');
33
+ expect(arg.details.headers).toMatchObject({ authorization: 'secret', 'x-custom': 'v' });
34
+ });
35
+ });
@@ -0,0 +1,6 @@
1
+ import type { BeforeRequestHook } from 'ky';
2
+ /**
3
+ * Traces HTTP request for observability using Output.ai tracing
4
+ * Respects OUTPUT_TRACE_HTTP_VERBOSE environment variable for detailed logging
5
+ */
6
+ export declare const traceRequest: BeforeRequestHook;
@@ -0,0 +1,25 @@
1
+ import { Tracing } from '@outputai/core/sdk_activity_integration';
2
+ import { redactHeaders, createTraceId, parseRequestBody } from '#utils/index.js';
3
+ import { config } from '#config.js';
4
+ /**
5
+ * Traces HTTP request for observability using Output.ai tracing
6
+ * Respects OUTPUT_TRACE_HTTP_VERBOSE environment variable for detailed logging
7
+ */
8
+ export const traceRequest = async (request, _options, _state) => {
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
+ }
15
+ const details = {
16
+ method: request.method,
17
+ url: request.url
18
+ };
19
+ if (config.logVerbose) {
20
+ const headers = Object.fromEntries(request.headers.entries());
21
+ details.headers = redactHeaders(headers);
22
+ details.body = await parseRequestBody(request);
23
+ }
24
+ Tracing.addEventStart({ id: traceId, kind: 'http', name: 'request', details });
25
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { traceRequest } from './trace_request.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
+ parseRequestBody: vi.fn(async () => ({ mocked: true })),
8
+ createTraceId: vi.fn(() => 'trace-id')
9
+ }));
10
+ vi.mock('@outputai/core/sdk_activity_integration', () => ({
11
+ Tracing: {
12
+ addEventStart: 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_request', () => {
21
+ beforeEach(() => {
22
+ mockedTracing.addEventStart.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 options = {};
29
+ const state = { retryCount: 0 };
30
+ await traceRequest(request, options, state);
31
+ expect(mockedTracing.addEventStart).toHaveBeenCalledTimes(1);
32
+ const arg = mockedTracing.addEventStart.mock.calls[0][0];
33
+ expect(arg).toHaveProperty('kind', 'http');
34
+ expect(arg).toHaveProperty('name', 'request');
35
+ expect(arg.details).toEqual({
36
+ method: 'GET',
37
+ url: 'https://api.example.com/users/1'
38
+ });
39
+ });
40
+ it('traces headers and parsed body when verbose logging is enabled', async () => {
41
+ mockedConfig.logVerbose = true;
42
+ const request = new Request('https://api.example.com/users', {
43
+ method: 'POST',
44
+ headers: {
45
+ authorization: 'secret',
46
+ 'x-custom': 'value'
47
+ },
48
+ body: JSON.stringify({ name: 'test' })
49
+ });
50
+ const options = {};
51
+ const state = { retryCount: 0 };
52
+ await traceRequest(request, options, state);
53
+ expect(mockedTracing.addEventStart).toHaveBeenCalledTimes(1);
54
+ const arg = mockedTracing.addEventStart.mock.calls[0][0];
55
+ expect(arg.details.method).toBe('POST');
56
+ expect(arg.details.url).toBe('https://api.example.com/users');
57
+ expect(arg.details.headers).toMatchObject({ authorization: 'secret', 'x-custom': 'value' });
58
+ expect(arg.details.body).toEqual({ mocked: true });
59
+ });
60
+ });
@@ -0,0 +1,6 @@
1
+ import type { AfterResponseHook } from 'ky';
2
+ /**
3
+ * Traces HTTP response for observability using Output.ai tracing
4
+ * Respects OUTPUT_TRACE_HTTP_VERBOSE environment variable for detailed logging
5
+ */
6
+ export declare const traceResponse: AfterResponseHook;
@@ -0,0 +1,26 @@
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
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
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
+ });
@@ -0,0 +1,26 @@
1
+ import type { Options } from 'ky';
2
+ /**
3
+ * Creates a ky client.
4
+ *
5
+ * This client is customized with hooks to integrate with Output.ai tracing.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { httpClient } from '@outputai/http';
10
+ *
11
+ * const client = httpClient({
12
+ * prefixUrl: 'https://api.example.com',
13
+ * timeout: 30000,
14
+ * retry: { limit: 3 }
15
+ * });
16
+ *
17
+ * const response = await client.get('users/1');
18
+ * const data = await response.json();
19
+ * ```
20
+ *
21
+ * @param options - The ky options to extend the base client.
22
+ * @returns A ky instance extended with Output.ai tracing hooks.
23
+ */
24
+ export declare function httpClient(options?: Options): import("ky").KyInstance;
25
+ export { HTTPError, TimeoutError } from 'ky';
26
+ export type { Options as HttpClientOptions } from 'ky';
@@ -0,0 +1 @@
1
+ export {};