You can create an access log for a specific object using Proxy and Reflect, which can keep track of all getting and setting of properties on the object.
Proxy is pretty crucial for this pattern, and although it's not necessary to use Reflect in this specific example, Proxy and Reflect were introduced to ECMAScript together and share many methods that complement each other (Proxy.get
/Reflect.get
, Proxy.apply
/Reflect.apply
, Proxy.construct
/Reflect.construct
, etc).
This is a really powerful pattern, and access logging is just one possible use case.
const obj = new Proxy({
foo: 'bar',
baz: () => console.log('hello from baz'),
log: [],
}, {
get(target, key) {
target.log.push(`get ${key}`)
if (key in target) return Reflect.get(target, key)
else return 'default value'
},
set(target, key, value) {
target.log.push(`set ${key}: ${value}`)
return Reflect.set(target, key, value)
},
})
// creates a log entry
console.log(obj.foo)
// creates a log entry
obj.cat = 'meow'
// reading a new property creates a log entry
const copyCat = obj.cat
// creates a log entry
obj.baz()
// creates a log entry
const thing = obj.thing // sets thing to 'default value'
// checking the log also creates a log entry!
console.log(obj.log)
/* at this point, obj.log is:
[
'get foo',
'set cat: meow',
'get cat',
'get baz',
'get thing',
'get log'
]
*/
In the above example, we create an object called obj
that's an instance of Proxy
. For the Proxy instance's first argument, we can pass in an object with default properties:
{
foo: 'bar',
baz: () => console.log('hello from baz'),
log: [],
}
We give this Proxy
instance get()
and set()
traps that will intercept all getting and setting of properties on the object.
{
get(target, key) {
target.log.push(`get ${key}`)
if (key in target) return Reflect.get(target, key)
else return 'default value'
},
set(target, key, value) {
target.log.push(`set ${key}: ${value}`)
return Reflect.set(target, key, value)
},
}
Any time a property is read or set on obj
, we push a new log to log
:
target.log.push(`get ${key}`)
Another interesting thing we can easily implement with Proxy
is to provide default values if the property doesn't exist on the object:
if (key in target) return Reflect.get(target, key)
else return 'default value'
if (key in target) return target[key]
would also work here, but note that Proxy and Reflect share many related methods that complement each other, and this simple example doesn't show how powerful these two objects are together.
Now, any activity that occurs on the object will be logged:
// creates a log entry
console.log(obj.foo)
// creates a log entry
obj.cat = 'meow'
// reading a new property creates a log entry
const copyCat = obj.cat
// creates a log entry
obj.baz()
// creates a log entry
const thing = obj.thing // sets thing to 'default value'
// checking the log also creates a log entry!
console.log(obj.log)
/* at this point, obj.log is:
[
'get foo',
'set cat: meow',
'get cat',
'get baz',
'get thing',
'get log'
]
*/
We can also further distinguish the kinds of access by adding a call
log type inside the get
trap, and we can also pass the current value of the key into the log (or a default value if it's empty):
get(target, key) {
let value = 'empty'
if (key in target) value = Reflect.get(target, key)
target.log.push(`${typeof value === 'function' ? 'call' : 'get'} ${key}: ${value}`)
return value
},
/* obj.log:
[
'get foo: bar',
'set cat: meow',
'get cat: meow',
"call baz: () => console.log('hello from baz')",
'get thing: empty',
"get log: get foo: bar,set cat: meow,get cat: meow,call baz: () => console.log('hello from baz'),get thing: empty"
]
*/
Note that the full log value is now printed when log
is accessed, and the body of functions that were called is also printed, which is probably not wanted, but we can filter these out:
get(target, key) {
let value = 'empty'
if (key in target) value = Reflect.get(target, key)
const isFunction = typeof value === 'function'
target.log.push(
`${isFunction ? 'call' : 'get'} ${key}${isFunction || key === 'log' ? '' : `: ${value}`}`
)
return value
},
/* obj.log:
[
'get foo: bar',
'set cat: meow',
'get cat: meow',
'call baz',
'get thing: empty',
'get log'
]
*/
We can also add a timestamp to each log by adding a createLog
function that handles timestamping, and passing logs to this function using Reflect.apply
. createLog
is an external function here, but it could also be a property on obj
, which would keep all our logging logic inside the Proxy:
function createLog(logString) {
this.log.push(`${new Date().toISOString()} / ${logString}`)
}
const obj = new Proxy({
foo: 'bar',
baz: () => console.log('hello from baz'),
log: [],
}, {
get(target, key) {
let value = 'empty'
if (key in target) value = Reflect.get(target, key)
const isFunction = typeof value === 'function'
const log = `${isFunction ? 'call' : 'get'} ${key}${isFunction || key === 'log' ? '' : `: ${value}`}`
Reflect.apply(createLog, target, [log])
return value
},
set(target, key, value) {
Reflect.apply(createLog, target, [`set ${key}: ${value}`])
return Reflect.set(target, key, value)
},
})
/* obj.log:
[
'2020-11-30T15:34:58.010Z / get foo: bar',
'2020-11-30T15:34:58.017Z / set cat: meow',
'2020-11-30T15:34:58.017Z / get cat: meow',
'2020-11-30T15:34:58.017Z / call baz',
'2020-11-30T15:34:58.017Z / get thing: empty',
'2020-11-30T15:34:58.017Z / get log'
]
*/