Skip to content
Merged
Show file tree
Hide file tree
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 Jan 9, 2023
df41694
Merge branch 'main' into don't-log-GET-undefined
schwma Jan 19, 2023
def64fa
WIP improved request logger
schwma Jan 20, 2023
c45e345
Merge branch 'main' into don't-log-GET-undefined
schwma Apr 18, 2023
51a1b81
Add operationName and variables to object
schwma Apr 18, 2023
144131e
Extract request logger to own module
schwma Apr 18, 2023
1a10437
Add changelog entry
schwma Apr 18, 2023
1da7670
Reformat changelog entry
schwma Apr 18, 2023
efcb5d3
Remove outdated changelog entry
schwma Apr 18, 2023
9dfcbf6
Remove padding after query
schwma Apr 18, 2023
e9f440d
Prettier format
schwma Apr 18, 2023
af5dc0e
Log `operationName` and `variables` from URL query parameter
schwma Apr 18, 2023
283b4e1
Parse variables JSON string if taken from `req.query`
schwma Apr 18, 2023
5e30635
Add typeof object short-circuiting before Object.keys
schwma Apr 18, 2023
8df47d7
Sanitize arguments and their values in production
schwma Apr 19, 2023
b5e39af
Reorder changelog entries
schwma Apr 19, 2023
c7bfc36
Rename requestLogger to queryLogger
schwma Apr 19, 2023
2341fe7
Add query logging tests
schwma Apr 21, 2023
a7d566d
Merge branch 'main' into don't-log-GET-undefined
schwma May 22, 2023
d1e414d
Rename `logger.test.js` to `logger-dev.test.js`
schwma Jun 29, 2023
bcb8899
Add test for log sanetization in production
schwma Jun 29, 2023
9512f6c
Prettier format
schwma Jun 29, 2023
a0045bc
Merge branch 'main' into don't-log-GET-undefined
schwma Jun 29, 2023
dd1a772
Fix logger module path
schwma Jun 29, 2023
f62ffb8
Fix truncation of nested variables
schwma Jun 29, 2023
7f58f6b
Fix typo
schwma Jun 29, 2023
e881919
Move object formatting to only affect query info object
schwma Jun 30, 2023
37e2e58
Add and improve comments
schwma Jun 30, 2023
0f795c7
Log variables object as '***' in production
schwma Jun 30, 2023
61b7948
Fix typo
schwma Jun 30, 2023
33284ce
Use mocked instead of dummy auth
schwma Jun 30, 2023
dbd68fc
Add dev logging tests to prod to ensure no unwanted values are sanitized
schwma Jun 30, 2023
2187ac2
Add tests that ensures literal values are not sanitized in dev
schwma Jun 30, 2023
9a08b36
Use `{color:false,depth:null}` options for formatting everywhere
schwma Jun 30, 2023
13c98c4
Prettier format
schwma Jun 30, 2023
52d24c9
Fix property name typo `color`->`colors` and helper function call
schwma Jun 30, 2023
1d66c1d
Merge branch 'main' into don't-log-GET-undefined
johannes-vogel Jul 3, 2023
d1cd6dd
Move changelog entries from added to changed section
schwma Jul 4, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Improved query logging:
+ Don't log queries that are `undefined`
+ Log `operationName`
+ Log `variables` when not in production
+ Sanitize arguments and their values in queries when in production

### Fixed

### Removed
Expand Down
13 changes: 5 additions & 8 deletions lib/GraphQLAdapter.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
const cds = require('@sap/cds')
const LOG = cds.log('graphql')
const express = require('express')
const { createHandler } = require('graphql-http/lib/use/express')
const { generateSchema4 } = require('./schema')
const queryLogger = require('./logger')
const graphiql = require('../app/graphiql')
const { decodeURIComponent } = cds.utils
const { createHandler } = require('graphql-http/lib/use/express')

function GraphQLAdapter(options) {
const router = express.Router()
Expand All @@ -15,13 +13,12 @@ function GraphQLAdapter(options) {

router
.use(express.json()) //> required by logger below
.use((req, _, next) => {
LOG.info(req.method, req.body?.query || decodeURIComponent(req.query.query))
next()
})
.use(queryLogger)

if (options.graphiql) router.use(graphiql)

router.use((req, res) => createHandler({ schema, context: { req, res }, ...options })(req, res))

return router
}

Expand Down
47 changes: 47 additions & 0 deletions lib/logger.js
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
15 changes: 15 additions & 0 deletions test/resources/bookshop-graphql/package.json
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"
}
}
}
}
253 changes: 253 additions & 0 deletions test/tests/logger-dev.test.js
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)}`)
})
})
})
Loading