@outputai/http 0.10.1-next.f6a7c1a.0 → 0.11.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.
@@ -1,11 +1,9 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { Response, Request } from 'undici';
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Request, Response } from 'undici';
3
3
  vi.mock('@outputai/core/sdk/runtime', () => {
4
4
  class HTTPRequestCount {
5
5
  static TYPE = 'http:request:count';
6
6
  type = HTTPRequestCount.TYPE;
7
- url;
8
- requestId;
9
7
  constructor(url, requestId) {
10
8
  this.url = url;
11
9
  this.requestId = requestId;
@@ -20,179 +18,142 @@ vi.mock('@outputai/core/sdk/runtime', () => {
20
18
  Attribute: {
21
19
  HTTPRequestCount
22
20
  }
23
- },
24
- Event: {
25
- emit: vi.fn()
26
21
  }
27
22
  };
28
23
  });
29
- import { Tracing, Event } from '@outputai/core/sdk/runtime';
24
+ import { Tracing } from '@outputai/core/sdk/runtime';
25
+ import { config } from '../config.js';
26
+ import { logError, logFailure, logRequest, logResponse } from './logger.js';
30
27
  const tracing = vi.mocked(Tracing, true);
31
- const event = vi.mocked(Event, true);
32
- /** Loads logger with optional verbose tracing env so `config.js` is evaluated fresh. */
33
- async function logLogger(verbose) {
34
- vi.resetModules();
35
- if (verbose) {
36
- process.env.OUTPUT_TRACE_HTTP_VERBOSE = 'true';
37
- }
38
- else {
39
- delete process.env.OUTPUT_TRACE_HTTP_VERBOSE;
40
- }
41
- return import('./logger.js');
42
- }
43
28
  beforeEach(() => {
29
+ config.logVerbose = false;
44
30
  tracing.addEventStart.mockClear();
45
31
  tracing.addEventEnd.mockClear();
46
32
  tracing.addEventError.mockClear();
47
33
  tracing.addEventAttribute.mockClear();
48
- event.emit.mockClear();
49
34
  });
50
- describe('fetch/logger', () => {
35
+ describe('instrumented_fetch/logger', () => {
51
36
  describe('logRequest', () => {
52
- const expectRequestCountAttribute = (requestId, url) => {
53
- expect(tracing.addEventAttribute).toHaveBeenCalledWith({
54
- eventId: requestId,
55
- attribute: expect.objectContaining({
56
- type: Tracing.Attribute.HTTPRequestCount.TYPE,
57
- url,
58
- requestId
59
- })
60
- });
61
- };
62
- it('records minimal details when verbose is off', async () => {
63
- const { logRequest } = await logLogger(false);
64
- const request = new Request('https://api.example.com/r', { method: 'GET' });
65
- await logRequest({ requestId: 'req-1', request });
37
+ it('records request details and the request count attribute', async () => {
38
+ const request = new Request('https://example.com/users', { method: 'GET' });
39
+ await logRequest({ requestId: 'request-1', request });
66
40
  expect(tracing.addEventStart).toHaveBeenCalledWith({
67
- id: 'req-1',
41
+ id: 'request-1',
68
42
  kind: 'http',
69
43
  name: 'request',
70
44
  details: {
71
45
  method: 'GET',
72
- url: 'https://api.example.com/r'
46
+ url: 'https://example.com/users'
73
47
  }
74
48
  });
75
- expectRequestCountAttribute('req-1', 'https://api.example.com/r');
76
- });
77
- it('defaults method to GET', async () => {
78
- const { logRequest } = await logLogger(false);
79
- const request = new Request('https://x.test');
80
- await logRequest({ requestId: 'r2', request });
81
- expect(tracing.addEventStart.mock.calls[0][0].details.method).toBe('GET');
82
- expectRequestCountAttribute('r2', 'https://x.test/');
49
+ expect(tracing.addEventAttribute).toHaveBeenCalledWith({
50
+ eventId: 'request-1',
51
+ attribute: expect.objectContaining({
52
+ type: 'http:request:count',
53
+ url: 'https://example.com/users',
54
+ requestId: 'request-1'
55
+ })
56
+ });
83
57
  });
84
- it('includes redacted headers and parsed body when verbose is on', async () => {
85
- const { logRequest } = await logLogger(true);
86
- const request = new Request('https://api.example.com/p', {
58
+ it('includes redacted headers and parsed body in verbose mode', async () => {
59
+ config.logVerbose = true;
60
+ const request = new Request('https://example.com/users', {
87
61
  method: 'POST',
88
62
  headers: {
89
- authorization: 'tok',
90
- 'X-Custom': 'ok',
91
- 'Content-Type': 'application/json'
63
+ authorization: 'secret',
64
+ 'content-type': 'application/json',
65
+ 'x-visible': 'visible'
92
66
  },
93
- body: JSON.stringify({ x: 1 })
67
+ body: JSON.stringify({ name: 'Ada' })
94
68
  });
95
- await logRequest({ requestId: 'req-v', request });
69
+ await logRequest({ requestId: 'request-verbose', request });
96
70
  expect(tracing.addEventStart).toHaveBeenCalledWith({
97
- id: 'req-v',
71
+ id: 'request-verbose',
98
72
  kind: 'http',
99
73
  name: 'request',
100
74
  details: {
101
75
  method: 'POST',
102
- url: 'https://api.example.com/p',
103
- headers: { authorization: '[REDACTED]', 'x-custom': 'ok', 'content-type': 'application/json' },
104
- body: { x: 1 }
76
+ url: 'https://example.com/users',
77
+ headers: {
78
+ authorization: '[REDACTED]',
79
+ 'content-type': 'application/json',
80
+ 'x-visible': 'visible'
81
+ },
82
+ body: { name: 'Ada' }
105
83
  }
106
84
  });
107
- expectRequestCountAttribute('req-v', 'https://api.example.com/p');
108
85
  });
109
86
  });
110
87
  describe('logError', () => {
111
- it('records status, statusText, redacted headers, and parsed JSON body', async () => {
112
- const { logError } = await logLogger(false);
113
- const body = { message: 'Upstream unavailable', code: 'E_UPSTREAM' };
114
- const response = new Response(JSON.stringify(body), {
115
- status: 502,
116
- statusText: 'Bad Gateway',
117
- headers: {
118
- 'X-API-Key': 'k',
119
- Accept: 'text/plain',
120
- 'content-type': 'application/json'
121
- }
122
- });
123
- await logError({
124
- requestId: 'e1', response, method: 'GET', url: 'https://upstream.test/x', durationMs: 1
88
+ it('omits headers and body outside verbose mode', async () => {
89
+ const response = new Response('unavailable', {
90
+ status: 503,
91
+ statusText: 'Service Unavailable',
92
+ headers: { 'x-api-key': 'secret' }
125
93
  });
94
+ await logError({ requestId: 'request-error', response });
126
95
  expect(tracing.addEventError).toHaveBeenCalledWith({
127
- id: 'e1',
96
+ id: 'request-error',
128
97
  details: {
129
- status: 502,
130
- statusText: 'Bad Gateway',
131
- headers: {
132
- 'x-api-key': '[REDACTED]',
133
- accept: 'text/plain',
134
- 'content-type': 'application/json'
135
- },
136
- body
98
+ status: 503,
99
+ statusText: 'Service Unavailable'
137
100
  }
138
101
  });
139
102
  });
140
- it('records error body as raw text when content-type is not application/json', async () => {
141
- const { logError } = await logLogger(false);
142
- const text = 'Bad Gateway: no healthy upstream';
143
- const response = new Response(text, {
144
- status: 502,
145
- statusText: 'Bad Gateway',
146
- headers: { 'content-type': 'text/plain' }
147
- });
148
- await logError({
149
- requestId: 'e2', response, method: 'GET', url: 'https://upstream.test/y', durationMs: 1
103
+ it('includes redacted headers and parsed body in verbose mode', async () => {
104
+ config.logVerbose = true;
105
+ const response = new Response(JSON.stringify({ error: 'unavailable' }), {
106
+ status: 503,
107
+ statusText: 'Service Unavailable',
108
+ headers: {
109
+ 'content-type': 'application/json',
110
+ 'x-api-key': 'secret'
111
+ }
150
112
  });
113
+ await logError({ requestId: 'request-error', response });
151
114
  expect(tracing.addEventError).toHaveBeenCalledWith({
152
- id: 'e2',
115
+ id: 'request-error',
153
116
  details: {
154
- status: 502,
155
- statusText: 'Bad Gateway',
156
- headers: { 'content-type': 'text/plain' },
157
- body: text
117
+ status: 503,
118
+ statusText: 'Service Unavailable',
119
+ headers: {
120
+ 'content-type': 'application/json',
121
+ 'x-api-key': '[REDACTED]'
122
+ },
123
+ body: { error: 'unavailable' }
158
124
  }
159
125
  });
160
126
  });
161
127
  });
162
128
  describe('logResponse', () => {
163
- it('records status and statusText without headers/body when verbose is off', async () => {
164
- const { logResponse } = await logLogger(false);
165
- const response = new Response(JSON.stringify({ a: 1 }), {
129
+ it('omits headers and body outside verbose mode', async () => {
130
+ const response = new Response('ok', {
166
131
  status: 200,
167
132
  statusText: 'OK',
168
- headers: { 'content-type': 'application/json', Authorization: 'x' }
169
- });
170
- await logResponse({
171
- requestId: 'lr1', response, method: 'GET', url: 'https://x.test/a', durationMs: 1
133
+ headers: { authorization: 'secret' }
172
134
  });
135
+ await logResponse({ requestId: 'request-response', response });
173
136
  expect(tracing.addEventEnd).toHaveBeenCalledWith({
174
- id: 'lr1',
137
+ id: 'request-response',
175
138
  details: {
176
139
  status: 200,
177
140
  statusText: 'OK'
178
141
  }
179
142
  });
180
143
  });
181
- it('includes redacted headers and parsed body when verbose is on', async () => {
182
- const { logResponse } = await logLogger(true);
144
+ it('includes redacted headers and parsed body in verbose mode', async () => {
145
+ config.logVerbose = true;
183
146
  const response = new Response(JSON.stringify({ ok: true }), {
184
147
  status: 201,
185
148
  statusText: 'Created',
186
149
  headers: {
187
150
  'content-type': 'application/json',
188
- 'Set-Cookie': 'a=b'
151
+ 'set-cookie': 'session=secret'
189
152
  }
190
153
  });
191
- await logResponse({
192
- requestId: 'lr-v', response, method: 'POST', url: 'https://x.test/b', durationMs: 1
193
- });
154
+ await logResponse({ requestId: 'request-response-verbose', response });
194
155
  expect(tracing.addEventEnd).toHaveBeenCalledWith({
195
- id: 'lr-v',
156
+ id: 'request-response-verbose',
196
157
  details: {
197
158
  status: 201,
198
159
  statusText: 'Created',
@@ -206,79 +167,10 @@ describe('fetch/logger', () => {
206
167
  });
207
168
  });
208
169
  describe('logFailure', () => {
209
- it('forwards serialized error details (including stack) to Tracing.addEventError', async () => {
210
- const { logFailure } = await logLogger(false);
211
- const err = new TypeError('network');
212
- logFailure({ requestId: 'f1', error: err, method: 'GET', url: 'https://example.test/x', durationMs: 12 });
213
- expect(tracing.addEventError).toHaveBeenCalledWith({
214
- id: 'f1',
215
- details: {
216
- name: 'TypeError',
217
- message: 'network',
218
- cause: undefined,
219
- code: undefined,
220
- stack: expect.stringMatching(/TypeError:\s*network[\s\S]+at\s+/)
221
- }
222
- });
223
- });
224
- });
225
- describe('http:request event emission', () => {
226
- it('emits http:request with outcome=success on logResponse', async () => {
227
- const { logResponse } = await logLogger(false);
228
- const response = new Response('', { status: 200 });
229
- await logResponse({
230
- requestId: 'r-ok',
231
- response,
232
- method: 'GET',
233
- url: 'https://api.example.com/ok',
234
- durationMs: 42
235
- });
236
- expect(event.emit).toHaveBeenCalledWith('http:request', {
237
- requestId: 'r-ok',
238
- method: 'GET',
239
- url: 'https://api.example.com/ok',
240
- status: 200,
241
- durationMs: 42,
242
- outcome: 'success'
243
- });
244
- });
245
- it('emits http:request with outcome=error on logError', async () => {
246
- const { logError } = await logLogger(false);
247
- const response = new Response('boom', { status: 500 });
248
- await logError({
249
- requestId: 'r-err',
250
- response,
251
- method: 'POST',
252
- url: 'https://api.example.com/err',
253
- durationMs: 15
254
- });
255
- expect(event.emit).toHaveBeenCalledWith('http:request', {
256
- requestId: 'r-err',
257
- method: 'POST',
258
- url: 'https://api.example.com/err',
259
- status: 500,
260
- durationMs: 15,
261
- outcome: 'error'
262
- });
263
- });
264
- it('emits http:request with outcome=failure on logFailure (status undefined)', async () => {
265
- const { logFailure } = await logLogger(false);
266
- const err = new TypeError('network');
267
- logFailure({
268
- requestId: 'r-net',
269
- error: err,
270
- method: 'GET',
271
- url: 'https://api.example.com/net',
272
- durationMs: 9
273
- });
274
- expect(event.emit).toHaveBeenCalledWith('http:request', {
275
- requestId: 'r-net',
276
- method: 'GET',
277
- url: 'https://api.example.com/net',
278
- status: undefined,
279
- durationMs: 9,
280
- outcome: 'failure'
281
- });
170
+ it('records the error', () => {
171
+ const error = new TypeError('network unavailable');
172
+ logFailure({ requestId: 'request-failure', error });
173
+ expect(tracing.addEventError).toHaveBeenCalledWith({ id: 'request-failure', details: error });
282
174
  });
283
175
  });
284
176
  });
@@ -1,45 +1,3 @@
1
- import type { Request, Response, Headers } from 'undici';
2
- /**
3
- * Serialize a given error to a plain object keeping main properties:
4
- * - name (from constructor.name)
5
- * - message
6
- * - stack
7
- * - code (optional, but present on Node errors)
8
- * - cause (error chain)
9
- *
10
- * @param error Error to serialize
11
- * @param depth Current recursion depth for the error.cause chain
12
- * @returns Object
13
- */
14
- export declare const serializeError: (error: Error, depth?: number) => {
15
- name: string;
16
- message: string;
17
- stack: string | undefined;
18
- code: string | undefined;
19
- cause: string | object | undefined;
20
- };
21
- /**
22
- * Redacts sensitive headers for safe logging
23
- *
24
- * @param headers
25
- * @returns Plain object with sensitive headers redacted
26
- */
27
- export declare const redactHeaders: (headers: Headers) => Record<string, unknown>;
28
- /**
29
- * Clones a Request or Response object and reads the body as text, then:
30
- * - non-JSON content-type, or empty body: returns the text as-is
31
- * - application/json with a non-empty body: returns JSON.parse result, or the raw text if parsing fails
32
- *
33
- * @param r
34
- * @returns Parsed JSON value or raw body string
35
- */
36
- export declare const parseBody: (r: Request | Response) => Promise<string | object>;
37
- /**
38
- * Tag a response in place with its request id so downstream code (e.g.
39
- * `addRequestCost`) can correlate. Stores the id under a private symbol AND
40
- * patches `clone()` so the tag propagates to clones — ky clones the response
41
- * before invoking `afterResponse` hooks, and undici headers are immutable on
42
- * received responses, so a symbol re-attached inside `clone()` is the only
43
- * path that survives.
44
- */
45
- export declare const addRequestIdToResponse: (response: Response, requestId: string) => void;
1
+ export function redactHeaders(headers: Headers): object;
2
+ export function parseBody(r: Request | Response): object | string;
3
+ export function addRequestIdToResponse(response: Response, requestId: string): void;
@@ -6,10 +6,10 @@ const HEADER_REDACTION_EXEMPT = new Set([
6
6
  'x-csrf-token',
7
7
  'public-key-pins'
8
8
  ]);
9
- /** Matches red int "hot-red-pie", but not int "redact" */
10
- const wordMatcher = (term) => new RegExp(`(?<![a-z\\d])${term}(?![a-z\\d])`, 'i');
9
+ /** Matches red in "hot-red-pie", but not in "redact" */
10
+ const wordMatcher = term => new RegExp(`(?<![a-z\\d])${term}(?![a-z\\d])`, 'i');
11
11
  /** Matches red in "acquired", but not in "redact" */
12
- const wordEndMatcher = (term) => new RegExp(`${term}(?![a-z\\d])`, 'i');
12
+ const wordEndMatcher = term => new RegExp(`${term}(?![a-z\\d])`, 'i');
13
13
  /**
14
14
  * Sensitive header patterns for redaction (case-insensitive).
15
15
  * Uses alphanumeric boundaries so e.g. `token` does not match inside `tokens`.
@@ -25,40 +25,13 @@ const SENSITIVE_HEADER_PATTERNS = [
25
25
  // matches header that contain words ending with these sequences
26
26
  wordEndMatcher('key')
27
27
  ];
28
- /**
29
- * Serialize a given error to a plain object keeping main properties:
30
- * - name (from constructor.name)
31
- * - message
32
- * - stack
33
- * - code (optional, but present on Node errors)
34
- * - cause (error chain)
35
- *
36
- * @param error Error to serialize
37
- * @param depth Current recursion depth for the error.cause chain
38
- * @returns Object
39
- */
40
- export const serializeError = (error, depth = 1) => ({
41
- name: error.constructor.name,
42
- message: error.message,
43
- stack: error.stack,
44
- code: error.code ?? undefined,
45
- cause: (() => {
46
- if (depth > 5) {
47
- return '<Max recursion depth reached>';
48
- }
49
- if (error.cause instanceof Error) {
50
- return serializeError(error.cause, depth + 1);
51
- }
52
- return undefined; // eslint-disable-line consistent-return
53
- })()
54
- });
55
28
  /**
56
29
  * Redacts sensitive headers for safe logging
57
30
  *
58
- * @param headers
59
- * @returns Plain object with sensitive headers redacted
31
+ * @param {Headers} headers
32
+ * @returns {object} Plain object with sensitive headers redacted
60
33
  */
61
- export const redactHeaders = (headers) => {
34
+ export const redactHeaders = headers => {
62
35
  const result = {};
63
36
  for (const [key, value] of headers.entries()) {
64
37
  const lowerCaseKey = key.toLowerCase();
@@ -73,8 +46,8 @@ export const redactHeaders = (headers) => {
73
46
  * - non-JSON content-type, or empty body: returns the text as-is
74
47
  * - application/json with a non-empty body: returns JSON.parse result, or the raw text if parsing fails
75
48
  *
76
- * @param r
77
- * @returns Parsed JSON value or raw body string
49
+ * @param {Request|Response} r
50
+ * @returns {object|string} Parsed JSON value or raw body string
78
51
  */
79
52
  export const parseBody = async (r) => {
80
53
  const clone = r.clone();
@@ -97,6 +70,9 @@ export const parseBody = async (r) => {
97
70
  * before invoking `afterResponse` hooks, and undici headers are immutable on
98
71
  * received responses, so a symbol re-attached inside `clone()` is the only
99
72
  * path that survives.
73
+ *
74
+ * @param {Response} response
75
+ * @param {string} requestId
100
76
  */
101
77
  export const addRequestIdToResponse = (response, requestId) => {
102
78
  Object.defineProperty(response, requestIdSymbol, { value: requestId, enumerable: false, configurable: false, writable: false });