Tracing method calls via Proxies

[2017-11-01] dev, javascript, js proxies
(Ad, please don’t block)

In this blog post, I explain how you can trace method calls via ECMAScript Proxies. The techniques I show are relevant whenever you want to intercept and forward method calls via Proxies.

Required knowledge: You should be loosely familiar with Proxies. If not, please consult chapter “Metaprogramming with proxies” in “Exploring ES6”.

The object to be traced  

For our examples, we’ll use the following class.

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    dist(other) {
        return Math.sqrt(
            (other.x-this.x)**2 + (other.y-this.y)**2);
    }
    me() {
        return this;
    }
}

Tracing “get” operations  

Let’s start by tracing whenever someone reads properties. The following tool function lets us do that.

function traceGets(obj) {
    const handler = {
        get(target, propKey, receiver) {
            console.log('GET', propKey);
            return Reflect.get(target, propKey, receiver);
        }
    };
    return new Proxy(obj, handler);    
}

We can try it out on an instance of Point:

const pt = traceGets(new Point(3, 2));
console.log(pt.x);

// Output:
// GET x
// 3

Why use Reflect.get()?  

Why are we using the first one of the following two expressions and not the second one?

Reflect.get(target, propKey, receiver)
target[propKey]

Answer: The difference matters with getters. Then you want to invoke the getter that is stored in target, but you want its this to be set to receiver. That allows you to continue with tracing, because this still points to the proxy. More on that at the end of this blog post.

Tracing method calls  

Tracing method calls is more complicated, because Proxies don’t have traps for method calls, but instead translate them to a “get” and a function call. In principle, obj.prop and obj.method(x, y) are two different kinds of dot operators. The second one is an abbreviation for:

obj.method.call(obj, x, y)

As a consequence, you must trace method calls by returning appropriate values from “get” traps:

function traceMethodCalls(obj) {
    const handler = {
        get(target, propKey, receiver) {
            const targetValue = Reflect.get(target, propKey, receiver);
            if (typeof targetValue === 'function') {
                return function (...args) {
                    console.log('CALL', propKey, args);
                    return targetValue.apply(this, args); // (A)
                }
            } else {
                return targetValue;
            }
        }
    };
    return new Proxy(obj, handler);    
}

If the property value is a function, we return a function that traces and forwards both this and the arguments (line A). Otherwise, we simply return the target’s property value.

The following code traces a call to .dist().

const pt = traceMethodCalls(new Point(3, 2));
console.log(pt.dist(new Point(5, 4)));

// Output:
// CALL dist [ Point { x: 5, y: 4 } ]
// 2.8284271247461903

this remains the Proxy  

The way we have set up things ensures that this continues to point at the Proxy. Even if a traced method calls other traced methods. And even if a traced method returns this. You can see that in the following example – both method calls .me() and .dist() are being traced.

console.log(pt.me().dist(new Point(5, 4)));

// Output:
// CALL me []
// CALL dist [ Point { x: 5, y: 4 } ]
// 2.8284271247461903