@outputai/http 0.2.1-next.f1502fb.0 → 0.3.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.
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Symbol used to store request id in the response object.
3
+ */
4
+ export declare const requestIdSymbol: unique symbol;
package/dist/consts.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Symbol used to store request id in the response object.
3
+ */
4
+ export const requestIdSymbol = Symbol('request_id');
package/dist/cost.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { KyResponse } from 'ky';
2
+ export type RequestCost = {
3
+ total: number;
4
+ components?: Array<{
5
+ name: string;
6
+ value: number;
7
+ }>;
8
+ };
9
+ /**
10
+ * Attach cost information to the trace of an HTTP Request using the response
11
+ *
12
+ * @param response - The response of the HTTP Request to attach the information
13
+ * @param cost - The cost information
14
+ * @returns
15
+ */
16
+ export declare const addRequestCost: (response: KyResponse | Response, cost: RequestCost) => void;
package/dist/cost.js ADDED
@@ -0,0 +1,18 @@
1
+ import { Tracing, emitEvent } from '@outputai/core/sdk_activity_integration';
2
+ import { requestIdSymbol } from './consts.js';
3
+ /**
4
+ * Attach cost information to the trace of an HTTP Request using the response
5
+ *
6
+ * @param response - The response of the HTTP Request to attach the information
7
+ * @param cost - The cost information
8
+ * @returns
9
+ */
10
+ export const addRequestCost = (response, cost) => {
11
+ const eventId = Reflect.get(response, requestIdSymbol);
12
+ if (!eventId) {
13
+ console.warn('addRequestCost(): The "response" argument did not originate from @outputai/http, no costs were added.');
14
+ return;
15
+ }
16
+ Tracing.addEventAttribute({ eventId, name: Tracing.Attribute.COST, value: cost });
17
+ emitEvent('cost:http:request', { requestId: eventId, url: response.url, cost });
18
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { requestIdSymbol } from './consts.js';
3
+ vi.mock('@outputai/core/sdk_activity_integration', () => ({
4
+ Tracing: {
5
+ addEventAttribute: vi.fn(),
6
+ Attribute: {
7
+ COST: 'cost'
8
+ }
9
+ },
10
+ emitEvent: vi.fn()
11
+ }));
12
+ import { Tracing, emitEvent } from '@outputai/core/sdk_activity_integration';
13
+ import { addRequestCost } from './cost.js';
14
+ const tracing = vi.mocked(Tracing, true);
15
+ const emit = vi.mocked(emitEvent, true);
16
+ describe('addRequestCost', () => {
17
+ beforeEach(() => {
18
+ tracing.addEventAttribute.mockClear();
19
+ emit.mockClear();
20
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
21
+ });
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+ it('shortcircuits when the response has no http request id', () => {
26
+ const response = new Response();
27
+ const cost = { total: 1 };
28
+ addRequestCost(response, cost);
29
+ expect(console.warn).toHaveBeenCalledWith('addRequestCost(): The "response" argument did not originate from @outputai/http, no costs were added.');
30
+ expect(tracing.addEventAttribute).not.toHaveBeenCalled();
31
+ expect(emit).not.toHaveBeenCalled();
32
+ });
33
+ it('records cost on the trace event when the response carries the request id', () => {
34
+ const response = new Response(undefined, { status: 200 });
35
+ Reflect.set(response, requestIdSymbol, 'evt-cost-1');
36
+ const cost = { total: 2.5 };
37
+ addRequestCost(response, cost);
38
+ expect(console.warn).not.toHaveBeenCalled();
39
+ expect(tracing.addEventAttribute).toHaveBeenCalledWith({
40
+ eventId: 'evt-cost-1',
41
+ name: Tracing.Attribute.COST,
42
+ value: cost
43
+ });
44
+ expect(emit).toHaveBeenCalledWith('cost:http:request', {
45
+ requestId: 'evt-cost-1',
46
+ url: response.url,
47
+ cost
48
+ });
49
+ });
50
+ it('forwards multiple components to tracing', () => {
51
+ const response = new Response();
52
+ Reflect.set(response, requestIdSymbol, 'evt-cost-2');
53
+ const cost = {
54
+ total: 10,
55
+ components: [
56
+ { name: 'input', value: 3 },
57
+ { name: 'output', value: 7 }
58
+ ]
59
+ };
60
+ addRequestCost(response, cost);
61
+ expect(tracing.addEventAttribute).toHaveBeenCalledWith({
62
+ eventId: 'evt-cost-2',
63
+ name: Tracing.Attribute.COST,
64
+ value: cost
65
+ });
66
+ expect(emit).toHaveBeenCalledWith('cost:http:request', {
67
+ requestId: 'evt-cost-2',
68
+ url: response.url,
69
+ cost
70
+ });
71
+ });
72
+ it('forwards an empty components array to tracing', () => {
73
+ const response = new Response();
74
+ Reflect.set(response, requestIdSymbol, 'evt-cost-3');
75
+ const cost = { total: 1, components: [] };
76
+ addRequestCost(response, cost);
77
+ expect(tracing.addEventAttribute).toHaveBeenCalledWith({
78
+ eventId: 'evt-cost-3',
79
+ name: Tracing.Attribute.COST,
80
+ value: cost
81
+ });
82
+ expect(emit).toHaveBeenCalledWith('cost:http:request', {
83
+ requestId: 'evt-cost-3',
84
+ url: response.url,
85
+ cost
86
+ });
87
+ });
88
+ });
@@ -1,6 +1,7 @@
1
1
  import * as undici from 'undici';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { logRequest, logResponse, logError, logFailure } from './logger.js';
4
+ import { addRequestIdToResponse } from './utils.js';
4
5
  /*
5
6
  * Unifies undici and nodes realms
6
7
  * https://github.com/nodejs/undici#keep-fetch-and-formdata-together
@@ -31,6 +32,8 @@ export const fetch = async (input, init) => {
31
32
  await logRequest({ requestId, request });
32
33
  try {
33
34
  const response = await undici.fetch(request);
35
+ // This enriches the response of the request id, so it is identifiable later.
36
+ addRequestIdToResponse(response, requestId);
34
37
  if (response.status > 399) {
35
38
  await logError({ requestId, response });
36
39
  return response;
@@ -8,6 +8,9 @@ const loggerMock = vi.hoisted(() => ({
8
8
  logError: vi.fn((_args) => { }),
9
9
  logFailure: vi.fn((_args) => { })
10
10
  }));
11
+ const utilsMock = vi.hoisted(() => ({
12
+ addRequestIdToResponse: vi.fn()
13
+ }));
11
14
  vi.mock('node:crypto', () => ({
12
15
  randomUUID: () => randomUUIDMock()
13
16
  }));
@@ -17,6 +20,9 @@ vi.mock('./logger.js', () => ({
17
20
  logError: loggerMock.logError,
18
21
  logFailure: loggerMock.logFailure
19
22
  }));
23
+ vi.mock('./utils.js', () => ({
24
+ addRequestIdToResponse: utilsMock.addRequestIdToResponse
25
+ }));
20
26
  import { fetch } from './index.js';
21
27
  const MOCK_ORIGIN = 'https://fetch-index.undici.test';
22
28
  describe('fetch/index', () => {
@@ -33,6 +39,7 @@ describe('fetch/index', () => {
33
39
  loggerMock.logResponse.mockClear();
34
40
  loggerMock.logError.mockClear();
35
41
  loggerMock.logFailure.mockClear();
42
+ utilsMock.addRequestIdToResponse.mockClear();
36
43
  randomUUIDMock.mockClear();
37
44
  randomUUIDMock.mockImplementation(() => FIXED_REQUEST_ID);
38
45
  });
@@ -57,6 +64,8 @@ describe('fetch/index', () => {
57
64
  expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
58
65
  expect(loggerMock.logResponse.mock.calls[0][0].requestId).toBe(FIXED_REQUEST_ID);
59
66
  expect(loggerMock.logResponse.mock.calls[0][0].response).toBe(response);
67
+ expect(utilsMock.addRequestIdToResponse).toHaveBeenCalledTimes(1);
68
+ expect(utilsMock.addRequestIdToResponse).toHaveBeenCalledWith(response, FIXED_REQUEST_ID);
60
69
  expect(loggerMock.logError).not.toHaveBeenCalled();
61
70
  expect(loggerMock.logFailure).not.toHaveBeenCalled();
62
71
  });
@@ -69,6 +78,8 @@ describe('fetch/index', () => {
69
78
  expect(loggerMock.logError).toHaveBeenCalledTimes(1);
70
79
  expect(loggerMock.logError.mock.calls[0][0].requestId).toBe(FIXED_REQUEST_ID);
71
80
  expect(loggerMock.logError.mock.calls[0][0].response).toBe(response);
81
+ expect(utilsMock.addRequestIdToResponse).toHaveBeenCalledTimes(1);
82
+ expect(utilsMock.addRequestIdToResponse).toHaveBeenCalledWith(response, FIXED_REQUEST_ID);
72
83
  });
73
84
  it('treats status 399 as success (logs response end, not HTTP error)', async () => {
74
85
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/edge', method: 'GET' }).reply(399, '', { headers: { 'content-type': 'text/plain' } });
@@ -131,6 +142,7 @@ describe('fetch/index', () => {
131
142
  expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
132
143
  expect(loggerMock.logResponse).not.toHaveBeenCalled();
133
144
  expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
145
+ expect(utilsMock.addRequestIdToResponse).not.toHaveBeenCalled();
134
146
  });
135
147
  it('passes method and body through to undici for POST JSON', async () => {
136
148
  undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/create', method: 'POST' }).reply(201, JSON.stringify({ id: 1 }), { headers: { 'content-type': 'application/json' } });
@@ -8,13 +8,16 @@ import { parseBody, redactHeaders, serializeError } from './utils.js';
8
8
  * @param options.requestId - id of the request
9
9
  * @param options.request - The HTTP Request object
10
10
  */
11
- export const logRequest = async ({ requestId, request }) => Tracing.addEventStart({
12
- id: requestId, kind: 'http', name: 'request', details: {
13
- method: request.method,
14
- url: request.url,
15
- ...(config.logVerbose && { headers: redactHeaders(request.headers), body: await parseBody(request) })
16
- }
17
- });
11
+ export const logRequest = async ({ requestId, request }) => {
12
+ Tracing.addEventStart({
13
+ id: requestId, kind: 'http', name: 'request', details: {
14
+ method: request.method,
15
+ url: request.url,
16
+ ...(config.logVerbose && { headers: redactHeaders(request.headers), body: await parseBody(request) })
17
+ }
18
+ });
19
+ Tracing.addEventAttribute({ eventId: requestId, name: 'requestId', value: requestId });
20
+ };
18
21
  /**
19
22
  * Sends the trace error event for an http response with error status
20
23
  *
@@ -4,7 +4,8 @@ vi.mock('@outputai/core/sdk_activity_integration', () => ({
4
4
  Tracing: {
5
5
  addEventStart: vi.fn(),
6
6
  addEventEnd: vi.fn(),
7
- addEventError: vi.fn()
7
+ addEventError: vi.fn(),
8
+ addEventAttribute: vi.fn()
8
9
  }
9
10
  }));
10
11
  import { Tracing } from '@outputai/core/sdk_activity_integration';
@@ -24,9 +25,17 @@ beforeEach(() => {
24
25
  tracing.addEventStart.mockClear();
25
26
  tracing.addEventEnd.mockClear();
26
27
  tracing.addEventError.mockClear();
28
+ tracing.addEventAttribute.mockClear();
27
29
  });
28
30
  describe('fetch/logger', () => {
29
31
  describe('logRequest', () => {
32
+ const expectRequestIdAttribute = (requestId) => {
33
+ expect(tracing.addEventAttribute).toHaveBeenCalledWith({
34
+ eventId: requestId,
35
+ name: 'requestId',
36
+ value: requestId
37
+ });
38
+ };
30
39
  it('records minimal details when verbose is off', async () => {
31
40
  const { logRequest } = await logLogger(false);
32
41
  const request = new Request('https://api.example.com/r', { method: 'GET' });
@@ -40,12 +49,14 @@ describe('fetch/logger', () => {
40
49
  url: 'https://api.example.com/r'
41
50
  }
42
51
  });
52
+ expectRequestIdAttribute('req-1');
43
53
  });
44
54
  it('defaults method to GET', async () => {
45
55
  const { logRequest } = await logLogger(false);
46
56
  const request = new Request('https://x.test');
47
57
  await logRequest({ requestId: 'r2', request });
48
58
  expect(tracing.addEventStart.mock.calls[0][0].details.method).toBe('GET');
59
+ expectRequestIdAttribute('r2');
49
60
  });
50
61
  it('includes redacted headers and parsed body when verbose is on', async () => {
51
62
  const { logRequest } = await logLogger(true);
@@ -70,6 +81,7 @@ describe('fetch/logger', () => {
70
81
  body: { x: 1 }
71
82
  }
72
83
  });
84
+ expectRequestIdAttribute('req-v');
73
85
  });
74
86
  });
75
87
  describe('logError', () => {
@@ -34,3 +34,12 @@ export declare const redactHeaders: (headers: Headers) => Record<string, unknown
34
34
  * @returns Parsed JSON value or raw body string
35
35
  */
36
36
  export declare const parseBody: (r: Request | Response) => Promise<string | object>;
37
+ /**
38
+ * Adds a non-enumerable, non-configurable and non-writable property to a response.
39
+ *
40
+ * This property is identified by a unique symbol and contains the id of the request.
41
+ *
42
+ * @param response
43
+ * @param requestId
44
+ */
45
+ export declare const addRequestIdToResponse: (response: Response, requestId: string) => Response;
@@ -1,3 +1,4 @@
1
+ import { requestIdSymbol } from '../consts.js';
1
2
  /**
2
3
  * Header names that look sensitive by substring rules but are not secret material.
3
4
  */
@@ -89,3 +90,12 @@ export const parseBody = async (r) => {
89
90
  return textContent;
90
91
  }
91
92
  };
93
+ /**
94
+ * Adds a non-enumerable, non-configurable and non-writable property to a response.
95
+ *
96
+ * This property is identified by a unique symbol and contains the id of the request.
97
+ *
98
+ * @param response
99
+ * @param requestId
100
+ */
101
+ export const addRequestIdToResponse = (response, requestId) => Object.defineProperty(response, requestIdSymbol, { value: requestId, enumerable: false, configurable: false, writable: false });
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { Response, Request, Headers } from 'undici';
3
- import { parseBody, redactHeaders, serializeError } from './utils.js';
3
+ import { requestIdSymbol } from '../consts.js';
4
+ import { addRequestIdToResponse, parseBody, redactHeaders, serializeError } from './utils.js';
4
5
  const createMultiLevelError = (levels, depth = 1) => depth === levels ?
5
6
  new Error(`level-${depth}`) :
6
7
  new Error(`level-${depth}`, { cause: createMultiLevelError(levels, depth + 1) });
@@ -250,4 +251,24 @@ describe('fetch/utils', () => {
250
251
  await expect(parseBody(request)).resolves.toBe(raw);
251
252
  });
252
253
  });
254
+ describe('addRequestIdToResponse', () => {
255
+ it('stores request id under requestIdSymbol and returns the same response', () => {
256
+ const response = new Response('ok');
257
+ const enriched = addRequestIdToResponse(response, 'req-123');
258
+ expect(enriched).toBe(response);
259
+ expect(response[requestIdSymbol]).toBe('req-123');
260
+ });
261
+ it('defines request id as non-enumerable, non-writable and non-configurable', () => {
262
+ const response = new Response('ok');
263
+ addRequestIdToResponse(response, 'req-456');
264
+ const descriptor = Object.getOwnPropertyDescriptor(response, requestIdSymbol);
265
+ expect(descriptor).toBeDefined();
266
+ expect(descriptor).toMatchObject({
267
+ enumerable: false,
268
+ configurable: false,
269
+ writable: false,
270
+ value: 'req-456'
271
+ });
272
+ });
273
+ });
253
274
  });
package/dist/index.d.ts CHANGED
@@ -25,3 +25,4 @@ export declare function httpClient(options?: Options): import("ky").KyInstance;
25
25
  export { HTTPError, TimeoutError } from 'ky';
26
26
  export type { Options as HttpClientOptions } from 'ky';
27
27
  export * from './fetch/index.js';
28
+ export { addRequestCost } from './cost.js';
package/dist/index.js CHANGED
@@ -27,3 +27,4 @@ export function httpClient(options = {}) {
27
27
  }
28
28
  export { HTTPError, TimeoutError } from 'ky';
29
29
  export * from './fetch/index.js';
30
+ export { addRequestCost } from './cost.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/http",
3
- "version": "0.2.1-next.f1502fb.0",
3
+ "version": "0.3.0",
4
4
  "description": "Framework abstraction to make HTTP calls with tracing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "ky": "1.14.3",
13
13
  "undici": "8.1.0",
14
- "@outputai/core": "0.2.1-next.f1502fb.0"
14
+ "@outputai/core": "0.3.0"
15
15
  },
16
16
  "license": "Apache-2.0",
17
17
  "publishConfig": {