generated from SAP/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 10
feat: improved query logging #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
c91df0b
Don't log 'GET undefined' when missing query
schwma df41694
Merge branch 'main' into don't-log-GET-undefined
schwma def64fa
WIP improved request logger
schwma c45e345
Merge branch 'main' into don't-log-GET-undefined
schwma 51a1b81
Add operationName and variables to object
schwma 144131e
Extract request logger to own module
schwma 1a10437
Add changelog entry
schwma 1da7670
Reformat changelog entry
schwma efcb5d3
Remove outdated changelog entry
schwma 9dfcbf6
Remove padding after query
schwma e9f440d
Prettier format
schwma af5dc0e
Log `operationName` and `variables` from URL query parameter
schwma 283b4e1
Parse variables JSON string if taken from `req.query`
schwma 5e30635
Add typeof object short-circuiting before Object.keys
schwma 8df47d7
Sanitize arguments and their values in production
schwma b5e39af
Reorder changelog entries
schwma c7bfc36
Rename requestLogger to queryLogger
schwma 2341fe7
Add query logging tests
schwma a7d566d
Merge branch 'main' into don't-log-GET-undefined
schwma d1e414d
Rename `logger.test.js` to `logger-dev.test.js`
schwma bcb8899
Add test for log sanetization in production
schwma 9512f6c
Prettier format
schwma a0045bc
Merge branch 'main' into don't-log-GET-undefined
schwma dd1a772
Fix logger module path
schwma f62ffb8
Fix truncation of nested variables
schwma 7f58f6b
Fix typo
schwma e881919
Move object formatting to only affect query info object
schwma 37e2e58
Add and improve comments
schwma 0f795c7
Log variables object as '***' in production
schwma 61b7948
Fix typo
schwma 33284ce
Use mocked instead of dummy auth
schwma dbd68fc
Add dev logging tests to prod to ensure no unwanted values are sanitized
schwma 2187ac2
Add tests that ensures literal values are not sanitized in dev
schwma 9a08b36
Use `{color:false,depth:null}` options for formatting everywhere
schwma 13c98c4
Prettier format
schwma 52d24c9
Fix property name typo `color`->`colors` and helper function call
schwma 1d66c1d
Merge branch 'main' into don't-log-GET-undefined
johannes-vogel d1cd6dd
Move changelog entries from added to changed section
schwma File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| const cds = require('@sap/cds') | ||
| const { decodeURIComponent } = cds.utils | ||
| const LOG = cds.log('graphql') | ||
| const util = require('util') | ||
|
|
||
| const IS_PRODUCTION = process.env.NODE_ENV === 'production' | ||
|
|
||
| const queryLogger = (req, _, next) => { | ||
| let query = req.body?.query || (req.query.query && decodeURIComponent(req.query.query)) | ||
| // Only log requests that contain a query | ||
| if (!query) { | ||
| next() | ||
| return | ||
| } | ||
| query = query.trim() | ||
|
|
||
| const operationName = req.body?.operationName || req.query?.operationName | ||
|
|
||
| let variables = req.body?.variables || req.query?.variables | ||
| if (typeof variables === 'string') { | ||
| try { | ||
| // variables is a JSON string if taken from req.query.variables | ||
| variables = JSON.parse(variables) | ||
| } catch (e) { | ||
| // Ignore parsing errors, handled by GraphQL server | ||
| } | ||
| } | ||
| if (IS_PRODUCTION && variables) variables = '***' | ||
|
|
||
| // Only add properties to object that aren't undefined | ||
| const queryInfo = Object.fromEntries(Object.entries({ operationName, variables }).filter(([, v]) => v)) | ||
| // Only format queryInfo if it contains properties | ||
| const formattedQueryInfo = | ||
| Object.keys(queryInfo).length > 0 ? util.formatWithOptions({ colors: false, depth: null }, queryInfo) : undefined | ||
|
|
||
| // If query is multiline string, add newline padding to front | ||
| let formattedQuery = query.includes('\n') ? `\n${query}` : query | ||
| // Sanitize all values between parentheses | ||
| if (IS_PRODUCTION) formattedQuery = formattedQuery.replace(/\([\s\S]*?\)/g, '( *** )') | ||
|
|
||
| // Don't log undefined values | ||
| LOG.info(...[req.method, formattedQueryInfo, formattedQuery].filter(e => e)) | ||
|
|
||
| next() | ||
| } | ||
|
|
||
| module.exports = queryLogger |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "cds": { | ||
| "requires": { | ||
| "db": { | ||
| "kind": "sqlite", | ||
| "credentials": { | ||
| "database": ":memory:" | ||
| } | ||
| }, | ||
| "auth": { | ||
| "kind": "mocked" | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| describe('graphql - query logging in development', () => { | ||
| const cds = require('@sap/cds/lib') | ||
| const path = require('path') | ||
| const { gql } = require('../util') | ||
| const util = require('util') | ||
|
|
||
| const { axios, GET, POST, data } = cds.test(path.join(__dirname, '../resources/bookshop-graphql')) | ||
| // Prevent axios from throwing errors for non 2xx status codes | ||
| axios.defaults.validateStatus = false | ||
| data.autoReset(true) | ||
|
|
||
| const _format = e => util.formatWithOptions({ colors: false, depth: null }, ...(Array.isArray(e) ? e : [e])) | ||
|
|
||
| let _log | ||
|
|
||
| beforeEach(() => { | ||
| _log = jest.spyOn(console, 'info') | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| jest.clearAllMocks() | ||
| }) | ||
|
|
||
| describe('POST requests', () => { | ||
| test('Do not log requests with undefined queries', async () => { | ||
| await POST('/graphql') | ||
| expect(_log.mock.calls.length).toEqual(0) | ||
| }) | ||
|
|
||
| test('Log should contain HTTP method', async () => { | ||
| const query = gql` | ||
| { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await POST('/graphql', { query }) | ||
| expect(_format(_log.mock.calls[0])).toContain('POST') | ||
| }) | ||
|
|
||
| test('Log should not contain operationName if none was provided', async () => { | ||
| const query = gql` | ||
| { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await POST('/graphql', { query }) | ||
| expect(_format(_log.mock.calls[0])).not.toContain('operationName') | ||
| }) | ||
|
|
||
| test('Log should not contain variables if none were provided', async () => { | ||
| const query = gql` | ||
| { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await POST('/graphql', { query }) | ||
| expect(_format(_log.mock.calls[0])).not.toContain('variables') | ||
| }) | ||
|
|
||
| test('Log should contain operationName and its value', async () => { | ||
| const operationName = 'ListBooks' | ||
| const query = gql` | ||
| query ListAuthors { | ||
| AdminService { | ||
| Authors { | ||
| nodes { | ||
| name | ||
| } | ||
| } | ||
| } | ||
| } | ||
| query ${operationName} { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await POST('/graphql', { operationName, query }) | ||
| expect(_format(_log.mock.calls[0])).toContain(`operationName: '${operationName}'`) | ||
| }) | ||
|
|
||
| test('Log should contain literal values', async () => { | ||
| const secretTitle = 'secret' | ||
| const query = gql` | ||
| mutation { | ||
| AdminService { | ||
| Books { | ||
| create (input: { title: "${secretTitle}" }) { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await POST('/graphql', { query }) | ||
| expect(_format(_log.mock.calls[0])).toContain(secretTitle) | ||
| }) | ||
|
|
||
| test('Log should contain variables and their values', async () => { | ||
| const query = gql` | ||
| query ($filter: [AdminService_Books_filter]) { | ||
| AdminService { | ||
| Books(filter: $filter) { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| const variables = { filter: { ID: { ge: 250 } } } | ||
| await POST('/graphql', { query, variables }) | ||
| expect(_format(_log.mock.calls[0])).toContain(`variables: ${_format(variables)}`) | ||
| }) | ||
| }) | ||
|
|
||
| describe('GET requests', () => { | ||
| test('Do not log requests with undefined queries', async () => { | ||
| await GET('/graphql') | ||
| expect(_log.mock.calls.length).toEqual(0) | ||
| }) | ||
|
|
||
| test('Log should contain HTTP method', async () => { | ||
| const query = gql` | ||
| { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await GET(`/graphql?query=${query}`) | ||
| expect(_format(_log.mock.calls[0])).toContain('GET') | ||
| }) | ||
|
|
||
| test('Log should not contain operationName if none was provided', async () => { | ||
| const query = gql` | ||
| { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await GET(`/graphql?query=${query}`) | ||
| expect(_format(_log.mock.calls[0])).not.toContain('operationName') | ||
| }) | ||
|
|
||
| test('Log should not contain variables if none were provided', async () => { | ||
| const query = gql` | ||
| { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await GET(`/graphql?query=${query}`) | ||
| expect(_format(_log.mock.calls[0])).not.toContain('variables') | ||
| }) | ||
|
|
||
| test('Log should contain operationName and its value', async () => { | ||
| const operationName = 'ListBooks' | ||
| const query = gql` | ||
| query ListAuthors { | ||
| AdminService { | ||
| Authors { | ||
| nodes { | ||
| name | ||
| } | ||
| } | ||
| } | ||
| } | ||
| query ${operationName} { | ||
| AdminService { | ||
| Books { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await GET(`/graphql?operationName=${operationName}&query=${query}`) | ||
| expect(_format(_log.mock.calls[0])).toContain(`operationName: '${operationName}'`) | ||
| }) | ||
|
|
||
| test('Log should contain literal values', async () => { | ||
| const secretTitle = 'secret' | ||
| const query = gql` | ||
| query { | ||
| AdminService { | ||
| Books(filter: { title: { ne: "${secretTitle}"}}) { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| await GET(`/graphql?query=${query}`) | ||
| expect(_format(_log.mock.calls[0])).toContain(secretTitle) | ||
| }) | ||
|
|
||
| test('Log should contain variables and their values', async () => { | ||
| const query = gql` | ||
| query ($filter: [AdminService_Books_filter]) { | ||
| AdminService { | ||
| Books(filter: $filter) { | ||
| nodes { | ||
| title | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
| const variables = { filter: { ID: { ge: 250 } } } | ||
| await GET(`/graphql?query=${query}&variables=${JSON.stringify(variables)}`) | ||
| expect(_format(_log.mock.calls[0])).toContain(`variables: ${_format(variables)}`) | ||
| }) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.