Skip to content

Commit c86af98

Browse files
authored
feat: add debug mode to capture request IDs for support debugging (#731)
1 parent dc785c4 commit c86af98

3 files changed

Lines changed: 218 additions & 1 deletion

File tree

lib/api_client/execute_request.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ function execute_request(method, params, auth, api_url, callback, options = {})
7676
request_options.headers['Content-Length'] = Buffer.byteLength(query_params);
7777
}
7878
handle_response = function (res) {
79-
const { hide_sensitive = false } = config();
79+
const { hide_sensitive = false, debug = false } = config();
8080
const sanitizedOptions = { ...request_options };
8181

82+
// Capture X-Request-Id from response headers for debugging
83+
const requestId = res.headers['x-request-id'];
84+
8285
if (hide_sensitive === true) {
8386
if ("auth" in sanitizedOptions) { delete sanitizedOptions.auth; }
8487
if ("Authorization" in sanitizedOptions.headers) { delete sanitizedOptions.headers.Authorization; }
@@ -108,6 +111,10 @@ function execute_request(method, params, auth, api_url, callback, options = {})
108111

109112
if (result.error) {
110113
result.error.http_code = res.statusCode;
114+
// Include request_id in errors when debug mode is enabled
115+
if (debug && requestId) {
116+
result.error.request_id = requestId;
117+
}
111118
} else {
112119
if (res.headers["x-featureratelimit-limit"]) {
113120
result.rate_limit_allowed = parseInt(res.headers["x-featureratelimit-limit"]);
@@ -118,6 +125,10 @@ function execute_request(method, params, auth, api_url, callback, options = {})
118125
if (res.headers["x-featureratelimit-remaining"]) {
119126
result.rate_limit_remaining = parseInt(res.headers["x-featureratelimit-remaining"]);
120127
}
128+
// Include request_id in success responses when debug mode is enabled
129+
if (debug && requestId) {
130+
result.request_id = requestId;
131+
}
121132
}
122133

123134
if (result.error) {
@@ -142,6 +153,10 @@ function execute_request(method, params, auth, api_url, callback, options = {})
142153
query_params
143154
}
144155
};
156+
// Include request_id in network errors when debug mode is enabled
157+
if (debug && requestId) {
158+
err_obj.error.request_id = requestId;
159+
}
145160
deferred.reject(err_obj.error);
146161
if (typeof callback === "function") {
147162
callback(err_obj);
@@ -156,6 +171,10 @@ function execute_request(method, params, auth, api_url, callback, options = {})
156171
query_params
157172
}
158173
};
174+
// Include request_id in unexpected status code errors when debug mode is enabled
175+
if (debug && requestId) {
176+
err_obj.error.request_id = requestId;
177+
}
159178
deferred.reject(err_obj.error);
160179
if (typeof callback === "function") {
161180
callback(err_obj);

test/unit/debug_mode_spec.js

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
const expect = require('expect.js');
2+
const sinon = require('sinon');
3+
const https = require('https');
4+
const { EventEmitter } = require('events');
5+
const cloudinary = require('../../lib/cloudinary');
6+
const createTestConfig = require('../testUtils/createTestConfig');
7+
8+
describe('debug mode', function () {
9+
let requestStub;
10+
let mockResponse;
11+
12+
beforeEach(function () {
13+
cloudinary.config(createTestConfig());
14+
15+
// Create a mock response that extends EventEmitter
16+
mockResponse = new EventEmitter();
17+
mockResponse.statusCode = 200;
18+
mockResponse.headers = {
19+
'x-request-id': 'test-request-id-12345678',
20+
'x-featureratelimit-limit': '500',
21+
'x-featureratelimit-reset': new Date().toUTCString(),
22+
'x-featureratelimit-remaining': '499'
23+
};
24+
25+
// Stub the https.request method
26+
requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) {
27+
// Call the callback with our mock response
28+
setTimeout(() => callback(mockResponse), 0);
29+
30+
// Return a mock request object
31+
const mockRequest = new EventEmitter();
32+
mockRequest.write = sinon.stub();
33+
mockRequest.end = function () {
34+
// Simulate response data after end() is called
35+
setTimeout(() => {
36+
mockResponse.emit('data', JSON.stringify({ status: 'ok' }));
37+
mockResponse.emit('end');
38+
}, 10);
39+
};
40+
mockRequest.setTimeout = sinon.stub();
41+
42+
return mockRequest;
43+
});
44+
});
45+
46+
afterEach(function () {
47+
requestStub.restore();
48+
});
49+
50+
describe('when debug mode is enabled', function () {
51+
beforeEach(function () {
52+
cloudinary.config({ debug: true });
53+
});
54+
55+
it('should include request_id in successful responses', function (done) {
56+
cloudinary.v2.api.ping()
57+
.then((result) => {
58+
expect(result.request_id).to.be('test-request-id-12345678');
59+
expect(result.status).to.be('ok');
60+
done();
61+
})
62+
.catch(done);
63+
});
64+
65+
it('should include request_id in error responses', function (done) {
66+
// Override the mock response to simulate an error
67+
mockResponse.statusCode = 404;
68+
requestStub.restore();
69+
70+
requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) {
71+
setTimeout(() => callback(mockResponse), 0);
72+
73+
const mockRequest = new EventEmitter();
74+
mockRequest.write = sinon.stub();
75+
mockRequest.end = function () {
76+
setTimeout(() => {
77+
mockResponse.emit('data', JSON.stringify({
78+
error: {
79+
message: 'Resource not found'
80+
}
81+
}));
82+
mockResponse.emit('end');
83+
}, 10);
84+
};
85+
mockRequest.setTimeout = sinon.stub();
86+
87+
return mockRequest;
88+
});
89+
90+
cloudinary.v2.api.resource('nonexistent').catch((error) => {
91+
expect(error.error.request_id).to.be('test-request-id-12345678');
92+
expect(error.error.message).to.be('Resource not found');
93+
expect(error.error.http_code).to.be(404);
94+
done();
95+
});
96+
});
97+
98+
it('should include request_id even when X-Request-Id header has different casing', function (done) {
99+
// Test case insensitivity (Node.js lowercases headers)
100+
mockResponse.headers = {
101+
'x-request-id': 'case-insensitive-id'
102+
};
103+
104+
cloudinary.v2.api.ping()
105+
.then((result) => {
106+
expect(result.request_id).to.be('case-insensitive-id');
107+
done();
108+
})
109+
.catch(done);
110+
});
111+
});
112+
113+
describe('when debug mode is disabled', function () {
114+
beforeEach(function () {
115+
cloudinary.config({ debug: false });
116+
});
117+
118+
it('should NOT include request_id in successful responses', function (done) {
119+
cloudinary.v2.api.ping()
120+
.then((result) => {
121+
expect(result.request_id).to.be(undefined);
122+
expect(result.status).to.be('ok');
123+
done();
124+
})
125+
.catch(done);
126+
});
127+
128+
it('should NOT include request_id in error responses', function (done) {
129+
mockResponse.statusCode = 404;
130+
requestStub.restore();
131+
132+
requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) {
133+
setTimeout(() => callback(mockResponse), 0);
134+
135+
const mockRequest = new EventEmitter();
136+
mockRequest.write = sinon.stub();
137+
mockRequest.end = function () {
138+
setTimeout(() => {
139+
mockResponse.emit('data', JSON.stringify({
140+
error: {
141+
message: 'Resource not found'
142+
}
143+
}));
144+
mockResponse.emit('end');
145+
}, 10);
146+
};
147+
mockRequest.setTimeout = sinon.stub();
148+
149+
return mockRequest;
150+
});
151+
152+
cloudinary.v2.api.resource('nonexistent').catch((error) => {
153+
expect(error.error.request_id).to.be(undefined);
154+
expect(error.error.message).to.be('Resource not found');
155+
done();
156+
});
157+
});
158+
});
159+
160+
describe('when X-Request-Id header is missing', function () {
161+
beforeEach(function () {
162+
cloudinary.config({ debug: true });
163+
// Remove the x-request-id header
164+
delete mockResponse.headers['x-request-id'];
165+
});
166+
167+
it('should not break when request_id is missing from headers', function (done) {
168+
cloudinary.v2.api.ping()
169+
.then((result) => {
170+
expect(result.request_id).to.be(undefined);
171+
expect(result.status).to.be('ok');
172+
done();
173+
})
174+
.catch(done);
175+
});
176+
});
177+
178+
describe('config option', function () {
179+
it('should accept debug option in config', function () {
180+
cloudinary.config({ debug: true });
181+
expect(cloudinary.config('debug')).to.be(true);
182+
183+
cloudinary.config({ debug: false });
184+
expect(cloudinary.config('debug')).to.be(false);
185+
});
186+
187+
it('should be undefined when not explicitly set', function () {
188+
const testConfig = createTestConfig();
189+
delete testConfig.debug; // Ensure debug is not in the config
190+
cloudinary.config(testConfig);
191+
// When not set, it should be undefined (falsy)
192+
expect(cloudinary.config('debug')).to.not.be.ok();
193+
});
194+
});
195+
});

types/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ declare module 'cloudinary' {
360360
provisioning_api_key?: string;
361361
provisioning_api_secret?: string;
362362
oauth_token?: string;
363+
debug?: boolean;
363364

364365
[futureKey: string]: any;
365366
}
@@ -633,6 +634,7 @@ declare module 'cloudinary' {
633634
rate_limit_allowed?: number;
634635
rate_limit_reset_at?: string;
635636
rate_limit_remaining?: number;
637+
request_id?: string;
636638
}
637639

638640
export interface UploadApiResponse {
@@ -667,6 +669,7 @@ declare module 'cloudinary' {
667669
message: string;
668670
name: string;
669671
http_code: number;
672+
request_id?: string;
670673

671674
[futureKey: string]: any;
672675
}

0 commit comments

Comments
 (0)