Skip to content

Commit b46cbcc

Browse files
feat: improved query logging (#85)
* Don't log 'GET undefined' when missing query * WIP improved request logger * Add operationName and variables to object * Extract request logger to own module * Add changelog entry * Reformat changelog entry * Remove outdated changelog entry * Remove padding after query * Prettier format * Log `operationName` and `variables` from URL query parameter * Parse variables JSON string if taken from `req.query` * Add typeof object short-circuiting before Object.keys * Sanitize arguments and their values in production * Reorder changelog entries * Rename requestLogger to queryLogger * Add query logging tests * Rename `logger.test.js` to `logger-dev.test.js` * Add test for log sanetization in production * Prettier format * Fix logger module path * Fix truncation of nested variables * Fix typo * Move object formatting to only affect query info object * Add and improve comments * Log variables object as '***' in production * Fix typo * Use mocked instead of dummy auth * Add dev logging tests to prod to ensure no unwanted values are sanitized * Add tests that ensures literal values are not sanitized in dev * Use `{color:false,depth:null}` options for formatting everywhere * Prettier format * Fix property name typo `color`->`colors` and helper function call * Move changelog entries from added to changed section --------- Co-authored-by: Johannes Vogel <[email protected]>
1 parent d9b71a5 commit b46cbcc

File tree

6 files changed

+590
-8
lines changed

6 files changed

+590
-8
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Changed
1313

14+
- Improved query logging:
15+
+ Don't log queries that are `undefined`
16+
+ Log `operationName`
17+
+ Log `variables` when not in production
18+
+ Sanitize arguments and their values in queries when in production
19+
1420
### Fixed
1521

1622
### Removed

lib/GraphQLAdapter.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
const cds = require('@sap/cds')
2-
const LOG = cds.log('graphql')
31
const express = require('express')
4-
const { createHandler } = require('graphql-http/lib/use/express')
52
const { generateSchema4 } = require('./schema')
3+
const queryLogger = require('./logger')
64
const graphiql = require('../app/graphiql')
7-
const { decodeURIComponent } = cds.utils
5+
const { createHandler } = require('graphql-http/lib/use/express')
86

97
function GraphQLAdapter(options) {
108
const router = express.Router()
@@ -15,13 +13,12 @@ function GraphQLAdapter(options) {
1513

1614
router
1715
.use(express.json()) //> required by logger below
18-
.use((req, _, next) => {
19-
LOG.info(req.method, req.body?.query || decodeURIComponent(req.query.query))
20-
next()
21-
})
16+
.use(queryLogger)
2217

2318
if (options.graphiql) router.use(graphiql)
19+
2420
router.use((req, res) => createHandler({ schema, context: { req, res }, ...options })(req, res))
21+
2522
return router
2623
}
2724

lib/logger.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const cds = require('@sap/cds')
2+
const { decodeURIComponent } = cds.utils
3+
const LOG = cds.log('graphql')
4+
const util = require('util')
5+
6+
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
7+
8+
const queryLogger = (req, _, next) => {
9+
let query = req.body?.query || (req.query.query && decodeURIComponent(req.query.query))
10+
// Only log requests that contain a query
11+
if (!query) {
12+
next()
13+
return
14+
}
15+
query = query.trim()
16+
17+
const operationName = req.body?.operationName || req.query?.operationName
18+
19+
let variables = req.body?.variables || req.query?.variables
20+
if (typeof variables === 'string') {
21+
try {
22+
// variables is a JSON string if taken from req.query.variables
23+
variables = JSON.parse(variables)
24+
} catch (e) {
25+
// Ignore parsing errors, handled by GraphQL server
26+
}
27+
}
28+
if (IS_PRODUCTION && variables) variables = '***'
29+
30+
// Only add properties to object that aren't undefined
31+
const queryInfo = Object.fromEntries(Object.entries({ operationName, variables }).filter(([, v]) => v))
32+
// Only format queryInfo if it contains properties
33+
const formattedQueryInfo =
34+
Object.keys(queryInfo).length > 0 ? util.formatWithOptions({ colors: false, depth: null }, queryInfo) : undefined
35+
36+
// If query is multiline string, add newline padding to front
37+
let formattedQuery = query.includes('\n') ? `\n${query}` : query
38+
// Sanitize all values between parentheses
39+
if (IS_PRODUCTION) formattedQuery = formattedQuery.replace(/\([\s\S]*?\)/g, '( *** )')
40+
41+
// Don't log undefined values
42+
LOG.info(...[req.method, formattedQueryInfo, formattedQuery].filter(e => e))
43+
44+
next()
45+
}
46+
47+
module.exports = queryLogger
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"cds": {
3+
"requires": {
4+
"db": {
5+
"kind": "sqlite",
6+
"credentials": {
7+
"database": ":memory:"
8+
}
9+
},
10+
"auth": {
11+
"kind": "mocked"
12+
}
13+
}
14+
}
15+
}

test/tests/logger-dev.test.js

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
describe('graphql - query logging in development', () => {
2+
const cds = require('@sap/cds/lib')
3+
const path = require('path')
4+
const { gql } = require('../util')
5+
const util = require('util')
6+
7+
const { axios, GET, POST, data } = cds.test(path.join(__dirname, '../resources/bookshop-graphql'))
8+
// Prevent axios from throwing errors for non 2xx status codes
9+
axios.defaults.validateStatus = false
10+
data.autoReset(true)
11+
12+
const _format = e => util.formatWithOptions({ colors: false, depth: null }, ...(Array.isArray(e) ? e : [e]))
13+
14+
let _log
15+
16+
beforeEach(() => {
17+
_log = jest.spyOn(console, 'info')
18+
})
19+
20+
afterEach(() => {
21+
jest.clearAllMocks()
22+
})
23+
24+
describe('POST requests', () => {
25+
test('Do not log requests with undefined queries', async () => {
26+
await POST('/graphql')
27+
expect(_log.mock.calls.length).toEqual(0)
28+
})
29+
30+
test('Log should contain HTTP method', async () => {
31+
const query = gql`
32+
{
33+
AdminService {
34+
Books {
35+
nodes {
36+
title
37+
}
38+
}
39+
}
40+
}
41+
`
42+
await POST('/graphql', { query })
43+
expect(_format(_log.mock.calls[0])).toContain('POST')
44+
})
45+
46+
test('Log should not contain operationName if none was provided', async () => {
47+
const query = gql`
48+
{
49+
AdminService {
50+
Books {
51+
nodes {
52+
title
53+
}
54+
}
55+
}
56+
}
57+
`
58+
await POST('/graphql', { query })
59+
expect(_format(_log.mock.calls[0])).not.toContain('operationName')
60+
})
61+
62+
test('Log should not contain variables if none were provided', async () => {
63+
const query = gql`
64+
{
65+
AdminService {
66+
Books {
67+
nodes {
68+
title
69+
}
70+
}
71+
}
72+
}
73+
`
74+
await POST('/graphql', { query })
75+
expect(_format(_log.mock.calls[0])).not.toContain('variables')
76+
})
77+
78+
test('Log should contain operationName and its value', async () => {
79+
const operationName = 'ListBooks'
80+
const query = gql`
81+
query ListAuthors {
82+
AdminService {
83+
Authors {
84+
nodes {
85+
name
86+
}
87+
}
88+
}
89+
}
90+
query ${operationName} {
91+
AdminService {
92+
Books {
93+
nodes {
94+
title
95+
}
96+
}
97+
}
98+
}
99+
`
100+
await POST('/graphql', { operationName, query })
101+
expect(_format(_log.mock.calls[0])).toContain(`operationName: '${operationName}'`)
102+
})
103+
104+
test('Log should contain literal values', async () => {
105+
const secretTitle = 'secret'
106+
const query = gql`
107+
mutation {
108+
AdminService {
109+
Books {
110+
create (input: { title: "${secretTitle}" }) {
111+
title
112+
}
113+
}
114+
}
115+
}
116+
`
117+
await POST('/graphql', { query })
118+
expect(_format(_log.mock.calls[0])).toContain(secretTitle)
119+
})
120+
121+
test('Log should contain variables and their values', async () => {
122+
const query = gql`
123+
query ($filter: [AdminService_Books_filter]) {
124+
AdminService {
125+
Books(filter: $filter) {
126+
nodes {
127+
title
128+
}
129+
}
130+
}
131+
}
132+
`
133+
const variables = { filter: { ID: { ge: 250 } } }
134+
await POST('/graphql', { query, variables })
135+
expect(_format(_log.mock.calls[0])).toContain(`variables: ${_format(variables)}`)
136+
})
137+
})
138+
139+
describe('GET requests', () => {
140+
test('Do not log requests with undefined queries', async () => {
141+
await GET('/graphql')
142+
expect(_log.mock.calls.length).toEqual(0)
143+
})
144+
145+
test('Log should contain HTTP method', async () => {
146+
const query = gql`
147+
{
148+
AdminService {
149+
Books {
150+
nodes {
151+
title
152+
}
153+
}
154+
}
155+
}
156+
`
157+
await GET(`/graphql?query=${query}`)
158+
expect(_format(_log.mock.calls[0])).toContain('GET')
159+
})
160+
161+
test('Log should not contain operationName if none was provided', async () => {
162+
const query = gql`
163+
{
164+
AdminService {
165+
Books {
166+
nodes {
167+
title
168+
}
169+
}
170+
}
171+
}
172+
`
173+
await GET(`/graphql?query=${query}`)
174+
expect(_format(_log.mock.calls[0])).not.toContain('operationName')
175+
})
176+
177+
test('Log should not contain variables if none were provided', async () => {
178+
const query = gql`
179+
{
180+
AdminService {
181+
Books {
182+
nodes {
183+
title
184+
}
185+
}
186+
}
187+
}
188+
`
189+
await GET(`/graphql?query=${query}`)
190+
expect(_format(_log.mock.calls[0])).not.toContain('variables')
191+
})
192+
193+
test('Log should contain operationName and its value', async () => {
194+
const operationName = 'ListBooks'
195+
const query = gql`
196+
query ListAuthors {
197+
AdminService {
198+
Authors {
199+
nodes {
200+
name
201+
}
202+
}
203+
}
204+
}
205+
query ${operationName} {
206+
AdminService {
207+
Books {
208+
nodes {
209+
title
210+
}
211+
}
212+
}
213+
}
214+
`
215+
await GET(`/graphql?operationName=${operationName}&query=${query}`)
216+
expect(_format(_log.mock.calls[0])).toContain(`operationName: '${operationName}'`)
217+
})
218+
219+
test('Log should contain literal values', async () => {
220+
const secretTitle = 'secret'
221+
const query = gql`
222+
query {
223+
AdminService {
224+
Books(filter: { title: { ne: "${secretTitle}"}}) {
225+
nodes {
226+
title
227+
}
228+
}
229+
}
230+
}
231+
`
232+
await GET(`/graphql?query=${query}`)
233+
expect(_format(_log.mock.calls[0])).toContain(secretTitle)
234+
})
235+
236+
test('Log should contain variables and their values', async () => {
237+
const query = gql`
238+
query ($filter: [AdminService_Books_filter]) {
239+
AdminService {
240+
Books(filter: $filter) {
241+
nodes {
242+
title
243+
}
244+
}
245+
}
246+
}
247+
`
248+
const variables = { filter: { ID: { ge: 250 } } }
249+
await GET(`/graphql?query=${query}&variables=${JSON.stringify(variables)}`)
250+
expect(_format(_log.mock.calls[0])).toContain(`variables: ${_format(variables)}`)
251+
})
252+
})
253+
})

0 commit comments

Comments
 (0)