Skip to content

Commit 6026d6d

Browse files
arleytmschwma
andauthored
feat(logging): selective error message logging by error type (#131)
* feat(logging): enhanced logging capabilities Previously, all errors were logged as generic errors. This enhancement introduces selective logging of warnings and errors based on their error type. Errors representing client errors (4xx range) are now logged as warnings instead of errors. Additionally, message interpolation has been introduced to resolve error message placeholders into human-readable text. Co-authored-by: Marcel Schwarz <[email protected]> --------- Co-authored-by: Marcel Schwarz <[email protected]>
1 parent 959beb6 commit 6026d6d

File tree

4 files changed

+119
-5
lines changed

4 files changed

+119
-5
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Message interpolation of logged errors, transforming error message placeholders into human-readable text (default locale)
13+
1214
### Changed
1315

1416
- Moved registration of `cds.compile.to.gql` and `cds.compile.to.graphql` targets from `@sap/cds` to `@cap-js/graphql`
1517
- Improve merging of custom `graphql` protocol configuration with plugin default configuration
18+
- Errors representing client errors (`4xx` range) are now logged as warnings instead of errors
19+
- Exclude the stack trace of the outer logged error message in multiple error scenarios, as the inner stack trace already
20+
contained the precise initial segment of the outer stack trace.
1621

1722
### Fixed
1823

lib/resolvers/error.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
const cds = require('@sap/cds')
2+
const { i18n } = cds.env
23
const LOG_CDS = cds.log()
34
const LOG_GRAPHQL = cds.log('graphql')
4-
const { normalizeError } = require('@sap/cds/libx/_runtime/common/error/frontend')
55
const { GraphQLError } = require('graphql')
66
const { IS_PRODUCTION } = require('../utils')
77

8+
// FIXME: importing internal modules from @sap/cds is discouraged and not recommended for external usage
9+
const { normalizeError, isClientError } = require('@sap/cds/libx/_runtime/common/error/frontend')
10+
811
const _reorderProperties = error => {
912
// 'stack' and 'stacktrace' to cover both common cases that a custom error formatter might return
1013
let { code, message, details, stack, stacktrace } = error
@@ -31,14 +34,45 @@ const _cdsToGraphQLError = (context, err) => {
3134
}
3235

3336
const _ensureError = error => (error instanceof Error ? error : new Error(error))
37+
const _clone = obj => Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))
3438

3539
const handleCDSError = (context, error) => {
3640
error = _ensureError(error)
37-
// TODO: choose log level depending on type of error analogous to OData adapter
38-
if (LOG_CDS._error) LOG_CDS.error(error)
41+
_log(error)
3942
return _cdsToGraphQLError(context, error)
4043
}
4144

45+
// TODO: Revise this logging functionality, as it's not specific to protocol adapters.
46+
// This function should be relocated and/or cleaned up when the new abstract/generic
47+
// protocol adapter is designed and implemented.
48+
const _log = error => {
49+
// log errors and warnings only
50+
if (LOG_CDS.level <= cds.log.levels.WARN) return
51+
52+
// Clone of the original error object to prevent mutation and unintended side-effects.
53+
// Notice that the cloned error is logged to standard output in its default language,
54+
// whereas the original error message is locale-dependent as it is usually sent in the
55+
// HTTP response to HTTP Clients to be displayed in the user interface.
56+
let error2log = _clone(error)
57+
if (error.details) {
58+
error2log.details = error.details.map(error => _clone(error))
59+
60+
// Excluding the stack trace for the outer error as the inner stack trace already
61+
// contains the initial segment of the outer stack trace.
62+
delete error2log.stack
63+
}
64+
65+
error2log = normalizeError(error2log, { locale: i18n.default_language }, error => error).error
66+
67+
// determine if the status code represents a client error (4xx range)
68+
if (isClientError(error2log)) {
69+
if (LOG_CDS._warn) LOG_CDS.warn(error2log)
70+
} else {
71+
// server error
72+
if (LOG_CDS._error) LOG_CDS.error(error2log)
73+
}
74+
}
75+
4276
const formatError = error => {
4377
// CDS errors have already been logged and already have a stacktrace in extensions
4478
if (error.originalError?._cdsError) return error

lib/resolvers/response.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ const { handleCDSError } = require('./error')
33
const setResponse = async (context, response, key, value) => {
44
try {
55
response[key] = await value
6-
} catch (e) {
7-
response[key] = handleCDSError(context, e)
6+
} catch (error) {
7+
response[key] = handleCDSError(context, error)
88
}
99
}
1010

test/tests/error-handling-prod.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ describe('graphql - error handling in production', () => {
88
// Prevent axios from throwing errors for non 2xx status codes
99
axios.defaults.validateStatus = false
1010

11+
beforeEach(() => {
12+
jest.spyOn(console, 'warn')
13+
jest.spyOn(console, 'error')
14+
})
15+
16+
afterEach(() => {
17+
jest.clearAllMocks()
18+
})
19+
1120
describe('Errors thrown by CDS', () => {
1221
test('Single @mandatory validation error', async () => {
1322
const query = gql`
@@ -33,6 +42,17 @@ describe('graphql - error handling in production', () => {
3342
const response = await POST('/graphql', { query })
3443
expect(response.data).toMatchObject({ errors })
3544
expect(response.data.errors[0].extensions).not.toHaveProperty('stacktrace') // No stacktrace in production
45+
expect(console.warn.mock.calls[0][1]).toMatchObject({
46+
code: '400',
47+
element: 'notEmptyI',
48+
entity: 'ValidationErrorsService.A',
49+
message: 'Value is required',
50+
numericSeverity: 4,
51+
target: 'notEmptyI',
52+
type: 'cds.Integer',
53+
value: undefined,
54+
stack: expect.any(String)
55+
})
3656
})
3757

3858
test('Multiple @mandatory validation errors', async () => {
@@ -137,6 +157,38 @@ describe('graphql - error handling in production', () => {
137157
expect(response.data.errors[0].extensions).not.toHaveProperty('stacktrace') // No stacktrace outside of error details
138158
expect(response.data.errors[0].extensions.details[0]).not.toHaveProperty('stacktrace') // No stacktrace in production
139159
expect(response.data.errors[0].extensions.details[1]).not.toHaveProperty('stacktrace') // No stacktrace in production
160+
expect(console.warn.mock.calls[0][1]).toMatchObject({
161+
code: '400',
162+
message: 'Multiple errors occurred. Please see the details for more information.',
163+
details: [
164+
{
165+
args: ['inRange'],
166+
code: '400',
167+
element: 'inRange',
168+
entity: 'ValidationErrorsService.C',
169+
message: 'Value is required',
170+
numericSeverity: 4,
171+
target: 'inRange',
172+
type: 'cds.Integer',
173+
value: undefined,
174+
stack: expect.any(String)
175+
},
176+
{
177+
args: ['"foo"', '"high", "medium", "low"'],
178+
code: '400',
179+
element: 'oneOfEnumValues',
180+
entity: 'ValidationErrorsService.C',
181+
enum: ['@assert.range', 'type', 'enum'],
182+
message: 'Value "foo" is invalid according to enum declaration {"high", "medium", "low"}',
183+
numericSeverity: 4,
184+
target: 'oneOfEnumValues',
185+
type: 'cds.String',
186+
value: 'foo',
187+
stack: expect.any(String)
188+
}
189+
]
190+
})
191+
expect(console.warn.mock.calls[0][1]).not.toHaveProperty('stacktrace') // No stacktrace outside of error details
140192
})
141193
})
142194

@@ -310,6 +362,29 @@ describe('graphql - error handling in production', () => {
310362
expect(response.data.errors[0].extensions).not.toHaveProperty('stacktrace') // No stacktrace outside of error details
311363
expect(response.data.errors[0].extensions.details[0]).not.toHaveProperty('stacktrace') // No stacktrace in production
312364
expect(response.data.errors[0].extensions.details[1]).not.toHaveProperty('stacktrace') // No stacktrace in production
365+
expect(console.error.mock.calls[0][1]).toMatchObject({
366+
code: '500',
367+
message: 'Multiple errors occurred. Please see the details for more information.',
368+
details: [
369+
{
370+
code: 'Some-Custom-Code1',
371+
message: 'Some Custom Error Message 1',
372+
numericSeverity: 4,
373+
status: 418,
374+
target: 'some_field',
375+
stack: expect.any(String)
376+
},
377+
{
378+
code: 'Some-Custom-Code2',
379+
message: 'Some Custom Error Message 2',
380+
numericSeverity: 4,
381+
status: 500,
382+
target: 'some_field',
383+
stack: expect.any(String)
384+
}
385+
]
386+
})
387+
expect(console.error.mock.calls[0][1]).not.toHaveProperty('stacktrace') // No stacktrace outside of error details
313388
})
314389

315390
test('Thrown error is modified in srv.on(error) handler', async () => {

0 commit comments

Comments
 (0)