From 3995d8f9764d88b74ccb5a4a828f7b34161127cf Mon Sep 17 00:00:00 2001 From: Ross Stenersen Date: Wed, 2 Sep 2026 14:01:44 -0500 Subject: [PATCH] fix: make trace-level logging safe --- .changeset/modern-hairs-think.md | 5 +++++ src/endpoint-client.ts | 29 +++++++++++++++++++++++------ test/unit/endpoint-client.test.ts | 19 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 .changeset/modern-hairs-think.md diff --git a/.changeset/modern-hairs-think.md b/.changeset/modern-hairs-think.md new file mode 100644 index 0000000..cbad4bc --- /dev/null +++ b/.changeset/modern-hairs-think.md @@ -0,0 +1,5 @@ +--- +"@smartthings/core-sdk": patch +--- + +make trace-level logging safe even if responses have circular references diff --git a/src/endpoint-client.ts b/src/endpoint-client.ts index 22a90bb..f27a819 100644 --- a/src/endpoint-client.ts +++ b/src/endpoint-client.ts @@ -71,14 +71,31 @@ export interface EndpointClientRequestOptions { dryRunReturnValue?: T } +/** + * Create a function that can be passed to `JSON.stringify` making it work safely on objects with + * circular references. + */ +const createCircularReplacerFn = (): (key: string, value: unknown) => unknown => { + const seen = new WeakSet() + return (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + } + return value + } +} + /** * Convert to string and scrub sensitive values like auth tokens * Meant to be used before logging the request */ -function scrubConfig(config: AxiosRequestConfig): string { +const scrubConfig = (config: AxiosRequestConfig): string => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { paramsSerializer: _unused, ...cleanerConfig } = config - const message = JSON.stringify(cleanerConfig) + const message = JSON.stringify(cleanerConfig, createCircularReplacerFn()) const bearerRegex = /"(Bearer [0-9a-f]{8})[0-9a-f-]{28}"/i if (bearerRegex.test(message)) { @@ -228,7 +245,7 @@ export class EndpointClient { try { const response = await axios.request(axiosConfig) if (this.logger.isTraceEnabled()) { - this.logger.trace(`axios response ${response.status}: data=${JSON.stringify(response.data)}`) + this.logger.trace(`axios response ${response.status}: data=${JSON.stringify(response.data, createCircularReplacerFn())}`) } if (response.headers?.warning && this.config.warningLogger) { // warningLogger allows for return of a promise or just void for flexibility @@ -242,10 +259,10 @@ export class EndpointClient { // https://www.npmjs.com/package/axios#handling-errors if (error.response) { // server responded with non-200 response code - this.logger.trace(`axios response ${error.response.status}: data=${JSON.stringify(error.response.data)}`) + this.logger.trace(`axios response ${error.response.status}: data=${JSON.stringify(error.response.data, createCircularReplacerFn())}`) } else if (error.request) { // server never responded - this.logger.trace(`no response from server for request ${JSON.stringify(error.request)}`) + this.logger.trace(`no response from server for request ${JSON.stringify(error.request, createCircularReplacerFn())}`) } else { this.logger.trace(`error making request: ${error.message}`) } @@ -270,7 +287,7 @@ export class EndpointClient { } // Annotate message with SmartThings API error data if (error.response && error.response.data) { - error.message = error.message + ': ' + JSON.stringify(error.response.data) + error.message = error.message + ': ' + JSON.stringify(error.response.data, createCircularReplacerFn()) } throw error } diff --git a/test/unit/endpoint-client.test.ts b/test/unit/endpoint-client.test.ts index af1adc6..80734c2 100644 --- a/test/unit/endpoint-client.test.ts +++ b/test/unit/endpoint-client.test.ts @@ -394,6 +394,25 @@ describe('EndpointClient', () => { expect(traceMock).toHaveBeenCalledWith('error making request: things done broke!') }) + it('gracefully handles circular references in axios response', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = { + status: 'ok', + objectField: { + key: 'value', + }, + } + data.objectField.theFieldItself = data.objectField + mockRequest.mockResolvedValue({ status: 200, data } as AxiosResponse) + await client.request('GET', 'my/path') + + expect(isTraceEnabledMock).toHaveBeenCalledTimes(1) + expect(traceMock).toHaveBeenCalledTimes(1) + expect(traceMock).toHaveBeenCalledWith( + 'axios response 200: data={"status":"ok","objectField":{"key":"value","theFieldItself":"[Circular]"}}', + ) + }) + it('logs failed axios response with response info', async () => { const error = Error('error message') as AxiosError error.response = {