Skip to content

Commit

Permalink
✨ [RUMF-1409] Provide setUser and related functions for logs SDK (#1801)
Browse files Browse the repository at this point in the history
* 🐛 shallow clone of user object
* ✨ Add setUser and related functions to logs SDK
* 📝 Updated user management documentation for logs SDK
* 👌♻️  Mutualize checkUser
  • Loading branch information
yannickadam authored Nov 8, 2022
1 parent 4521a9d commit 0ab07cb
Show file tree
Hide file tree
Showing 15 changed files with 431 additions and 40 deletions.
2 changes: 2 additions & 0 deletions packages/core/src/domain/user/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './user.types'
export * from './user'
35 changes: 35 additions & 0 deletions packages/core/src/domain/user/user.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { checkUser, sanitizeUser } from './user'
import type { User } from './user.types'

describe('sanitize user function', () => {
it('should sanitize a user object', () => {
const obj = { id: 42, name: true, email: null }
const user = sanitizeUser(obj)

expect(user).toEqual({ id: '42', name: 'true', email: 'null' })
})

it('should not mutate the original data', () => {
const obj = { id: 42, name: 'test', email: null }
const user = sanitizeUser(obj)

expect(user.id).toEqual('42')
expect(obj.id).toEqual(42)
})
})

describe('check user function', () => {
it('should only accept valid user objects', () => {
const obj: any = { id: 42, name: true, email: null } // Valid, even though not sanitized
const user: User = { id: '42', name: 'John', email: '[email protected]' }
const undefUser: any = undefined
const nullUser: any = null
const invalidUser: any = 42

expect(checkUser(obj)).toBe(true)
expect(checkUser(user)).toBe(true)
expect(checkUser(undefUser)).toBe(false)
expect(checkUser(nullUser)).toBe(false)
expect(checkUser(invalidUser)).toBe(false)
})
})
32 changes: 32 additions & 0 deletions packages/core/src/domain/user/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Context } from '../../tools/context'
import { display } from '../../tools/display'
import { assign, getType } from '../../tools/utils'
import type { User } from './user.types'

/**
* Clone input data and ensure known user properties (id, name, email)
* are strings, as defined here:
* https://docs.datadoghq.com/logs/log_configuration/attributes_naming_convention/#user-related-attributes
*/
export function sanitizeUser(newUser: Context): Context {
// We shallow clone only to prevent mutation of user data.
const user = assign({}, newUser)
const keys = ['id', 'name', 'email']
keys.forEach((key) => {
if (key in user) {
user[key] = String(user[key])
}
})
return user
}

/**
* Simple check to ensure user is valid
*/
export function checkUser(newUser: User): boolean {
const isValid = getType(newUser) === 'object'
if (!isValid) {
display.error('Unsupported user:', newUser)
}
return isValid
}
6 changes: 6 additions & 0 deletions packages/core/src/domain/user/user.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface User {
id?: string | undefined
email?: string | undefined
name?: string | undefined
[key: string]: unknown
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ export {
getSyntheticsTestId,
getSyntheticsResultId,
} from './domain/synthetics/syntheticsWorkerValues'
export { User, checkUser, sanitizeUser } from './domain/user'
102 changes: 95 additions & 7 deletions packages/logs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ To receive all logs and errors, load and configure the SDK at the beginning of t
</html>
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

### TypeScript

Expand Down Expand Up @@ -174,7 +174,7 @@ DD_LOGS.onReady(function () {
window.DD_LOGS && DD_LOGS.logger.info('Button clicked', { name: 'buttonName', id: 123 })
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

#### Results

Expand Down Expand Up @@ -471,7 +471,7 @@ if (window.DD_LOGS) {
}
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

### Overwrite context

Expand Down Expand Up @@ -570,7 +570,95 @@ window.DD_LOGS && DD_LOGS.clearGlobalContext()
window.DD_LOGS && DD_LOGS.getGlobalContext() // => {}
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

#### User context

The Datadog logs SDK provides convenient functions to associate a `User` with generated logs.

- Set the user for all your loggers with the `setUser (newUser: User)` API.
- Add or modify a user property to all your loggers with the `setUserProperty (key: string, value: any)` API.
- Get the currently stored user with the `getUser ()` API.
- Remove a user property with the `removeUserProperty (key: string)` API.
- Clear all existing user properties with the `clearUser ()` API.

**Note**: The user context is applied before the global context. Hence, every user property included in the global context will override the user context when generating logs.

##### NPM

For NPM, use:

```javascript
import { datadogLogs } from '@datadog/browser-logs'

datadogLogs.setUser({ id: '1234', name: 'John Doe', email: '[email protected]' })
datadogLogs.setUserProperty('type', 'customer')
datadogLogs.getUser() // => {id: '1234', name: 'John Doe', email: '[email protected]', type: 'customer'}

datadogLogs.removeUserProperty('type')
datadogLogs.getUser() // => {id: '1234', name: 'John Doe', email: '[email protected]'}

datadogLogs.clearUser()
datadogLogs.getUser() // => {}
```

#### CDN async

For CDN async, use:

```javascript
DD_LOGS.onReady(function () {
DD_LOGS.setUser({ id: '1234', name: 'John Doe', email: '[email protected]' })
})

DD_LOGS.onReady(function () {
DD_LOGS.setUserProperty('type', 'customer')
})

DD_LOGS.onReady(function () {
DD_LOGS.getUser() // => {id: '1234', name: 'John Doe', email: '[email protected]', type: 'customer'}
})

DD_LOGS.onReady(function () {
DD_LOGS.removeUserProperty('type')
})

DD_LOGS.onReady(function () {
DD_LOGS.getUser() // => {id: '1234', name: 'John Doe', email: '[email protected]'}
})

DD_LOGS.onReady(function () {
DD_LOGS.clearUser()
})

DD_LOGS.onReady(function () {
DD_LOGS.getUser() // => {}
})
```

**Note:** Early API calls must be wrapped in the `DD_LOGS.onReady()` callback. This ensures the code only gets executed once the SDK is properly loaded.

##### CDN sync

For CDN sync, use:

```javascript
window.DD_LOGS && DD_LOGS.setUser({ id: '1234', name: 'John Doe', email: '[email protected]' })

window.DD_LOGS && DD_LOGS.setUserProperty('type', 'customer')

window.DD_LOGS && DD_LOGS.getUser() // => {id: '1234', name: 'John Doe', email: '[email protected]', type: 'customer'}

window.DD_LOGS && DD_LOGS.removeUserProperty('type')

window.DD_LOGS && DD_LOGS.getUser() // => {id: '1234', name: 'John Doe', email: '[email protected]'}

window.DD_LOGS && DD_LOGS.clearUser()

window.DD_LOGS && DD_LOGS.getUser() // => {}
```

**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

#### Logger context

Expand Down Expand Up @@ -617,7 +705,7 @@ window.DD_LOGS && DD_LOGS.setContext("{'env': 'staging'}")
window.DD_LOGS && DD_LOGS.addContext('referrer', document.referrer)
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

### Filter by status

Expand Down Expand Up @@ -659,7 +747,7 @@ For CDN sync, use:
window.DD_LOGS && DD_LOGS.logger.setLevel('<LEVEL>')
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

### Change the destination

Expand Down Expand Up @@ -706,7 +794,7 @@ window.DD_LOGS && DD_LOGS.logger.setHandler('<HANDLER>')
window.DD_LOGS && DD_LOGS.logger.setHandler(['<HANDLER1>', '<HANDLER2>'])
```

**Note**: The `window.DD_LOGS` check is used to prevent issues if a loading failure occurs with the SDK.
**Note**: The `window.DD_LOGS` check prevents issues when a loading failure occurs with the SDK.

### Access internal context

Expand Down
145 changes: 144 additions & 1 deletion packages/logs/src/boot/logsPublicApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe('logs entry', () => {
})

it('should have the current date, view and global context', () => {
LOGS.addLoggerGlobalContext('foo', 'bar')
LOGS.setGlobalContextProperty('foo', 'bar')

const getCommonContext = startLogs.calls.mostRecent().args[2]
expect(getCommonContext()).toEqual({
Expand All @@ -147,6 +147,7 @@ describe('logs entry', () => {
url: window.location.href,
},
context: { foo: 'bar' },
user: {},
})
})
})
Expand Down Expand Up @@ -325,5 +326,147 @@ describe('logs entry', () => {
expect(LOGS.getInternalContext()?.session_id).toEqual(mockSessionId)
})
})

describe('setUser', () => {
let logsPublicApi: LogsPublicApi
let displaySpy: jasmine.Spy<() => void>

beforeEach(() => {
displaySpy = spyOn(display, 'error')
logsPublicApi = makeLogsPublicApi(startLogs)
logsPublicApi.init(DEFAULT_INIT_CONFIGURATION)
})

it('should store user in common context', () => {
const user = { id: 'foo', name: 'bar', email: 'qux', foo: { bar: 'qux' } }
logsPublicApi.setUser(user)

const getCommonContext = startLogs.calls.mostRecent().args[2]
expect(getCommonContext().user).toEqual({
email: 'qux',
foo: { bar: 'qux' },
id: 'foo',
name: 'bar',
})
})

it('should sanitize predefined properties', () => {
const user = { id: null, name: 2, email: { bar: 'qux' } }
logsPublicApi.setUser(user as any)
const getCommonContext = startLogs.calls.mostRecent().args[2]
expect(getCommonContext().user).toEqual({
email: '[object Object]',
id: 'null',
name: '2',
})
})

it('should clear a previously set user', () => {
const user = { id: 'foo', name: 'bar', email: 'qux' }
logsPublicApi.setUser(user)
logsPublicApi.clearUser()

const getCommonContext = startLogs.calls.mostRecent().args[2]
expect(getCommonContext().user).toEqual({})
})

it('should reject non object input', () => {
logsPublicApi.setUser(2 as any)
logsPublicApi.setUser(null as any)
logsPublicApi.setUser(undefined as any)
expect(displaySpy).toHaveBeenCalledTimes(3)
})
})

describe('getUser', () => {
let logsPublicApi: LogsPublicApi

beforeEach(() => {
logsPublicApi = makeLogsPublicApi(startLogs)
logsPublicApi.init(DEFAULT_INIT_CONFIGURATION)
})

it('should return empty object if no user has been set', () => {
const userClone = logsPublicApi.getUser()
expect(userClone).toEqual({})
})

it('should return a clone of the original object if set', () => {
const user = { id: 'foo', name: 'bar', email: 'qux', foo: { bar: 'qux' } }
logsPublicApi.setUser(user)
const userClone = logsPublicApi.getUser()
const userClone2 = logsPublicApi.getUser()

expect(userClone).not.toBe(user)
expect(userClone).not.toBe(userClone2)
expect(userClone).toEqual(user)
})
})

describe('setUserProperty', () => {
const user = { id: 'foo', name: 'bar', email: 'qux', foo: { bar: 'qux' } }
const addressAttribute = { city: 'Paris' }
let logsPublicApi: LogsPublicApi

beforeEach(() => {
logsPublicApi = makeLogsPublicApi(startLogs)
logsPublicApi.init(DEFAULT_INIT_CONFIGURATION)
})

it('should add attribute', () => {
logsPublicApi.setUser(user)
logsPublicApi.setUserProperty('address', addressAttribute)
const userClone = logsPublicApi.getUser()

expect(userClone.address).toEqual(addressAttribute)
})

it('should not contain original reference to object', () => {
const userDetails: { [key: string]: any } = { name: 'john' }
logsPublicApi.setUser(user)
logsPublicApi.setUserProperty('userDetails', userDetails)
userDetails.DOB = '11/11/1999'
const userClone = logsPublicApi.getUser()

expect(userClone.userDetails).not.toBe(userDetails)
})

it('should override attribute', () => {
logsPublicApi.setUser(user)
logsPublicApi.setUserProperty('foo', addressAttribute)
const userClone = logsPublicApi.getUser()

expect(userClone).toEqual({ ...user, foo: addressAttribute })
})

it('should sanitize properties', () => {
logsPublicApi.setUserProperty('id', 123)
logsPublicApi.setUserProperty('name', ['Adam', 'Smith'])
logsPublicApi.setUserProperty('email', { foo: 'bar' })
const userClone = logsPublicApi.getUser()

expect(userClone.id).toEqual('123')
expect(userClone.name).toEqual('Adam,Smith')
expect(userClone.email).toEqual('[object Object]')
})
})

describe('removeUserProperty', () => {
let logsPublicApi: LogsPublicApi

beforeEach(() => {
logsPublicApi = makeLogsPublicApi(startLogs)
logsPublicApi.init(DEFAULT_INIT_CONFIGURATION)
})

it('should remove property', () => {
const user = { id: 'foo', name: 'bar', email: 'qux', foo: { bar: 'qux' } }

logsPublicApi.setUser(user)
logsPublicApi.removeUserProperty('foo')
const userClone = logsPublicApi.getUser()
expect(userClone.foo).toBeUndefined()
})
})
})
})
Loading

0 comments on commit 0ab07cb

Please sign in to comment.