Skip to content

Instantly share code, notes, and snippets.

@jhmaster2000
Created February 16, 2023 07:25
Show Gist options
  • Save jhmaster2000/9be14e01b97079ac00f0a4218874652c to your computer and use it in GitHub Desktop.
Save jhmaster2000/9be14e01b97079ac00f0a4218874652c to your computer and use it in GitHub Desktop.
Chainable console.log PoC implementation
/*
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|=======================================================|
| Chainable console.log proof of concept implementation |
|=======================================================|
Conceptually, this would allow for libraries to plug into console.log like plugins,
rather than overriding each other without rules and breaking end programs using them.
In reality, it is unlikely a majority of developers would make proper use of this for it to work.
Far too many people forget to even properly monkeypatch the console.log function arguments.
It takes rest arguments everyone, it's not a single argument function.
Attaching a single argument function would already hurt this chain, although not break it.
Other things like injections that don't result in returning control back to the original log function,
such as instead writing to a file or making HTTP requests, would break the chain entirely.
This isn't to say those aren't valid use-cases of monkeypatching console.log, just that they are fundamentally
incompatible with the concept of this implementation, which makes it pointless in real world use, so I won't be
making it into an npm package, nor do I recommend anyone does. I made this just for fun and curiosity if it was feasible.
Further details on how this works and why actually using it would be a bad idea are in the comments on the code below.
As specified at the top of the file, it is licensed under MPL 2.0, and per section 3.4 of the license,
this comment shall be considered a notice and may not be removed from, and must be present on,
any copy of this file and/or snippet of code from it.
*/
// This check here allows this to be used, in theory, as a library.
// With this, package A, B and C can all make independent use of it,
// and when project ABC depends on all three of them, there won't be issues of console being overriden thrice.
// The first package to load this file will trigger the override, and the others will simply do nothing.
if (!Reflect.get(console, '@@isProxiedConsole')) {
const consoleDebug = console.debug;
const IsDebug = false; // set to true to see how it all works as it runs.
const DebugLog = IsDebug ? consoleDebug : () => void 0;
// This also relies on the assumption nobody else wants to override the entire console global.
// Which let's be real, there's probably some packages that do...
console = new Proxy(console, {
// @ts-expect-error --- I really wanted to make it self-contained, I'm sorry typescript.
loggers: new Set(), // Stores the custom loggers registered
get(target, prop, proxy) {
// Allow the check against multiple overrides to work without polluting the global scope.
if (prop === '@@isProxiedConsole') return true;
// Intercept accesses to console.log (console.info is merely an alias to it!)
if (prop === 'log' || prop === 'info') {
// "this" here refers to the Proxy handler object.
// Once again abusing it to avoid the need for global pollution.
// These functions are needed here to use the correct "this",
// since where they are called from "this" would refer to a different Proxy's handler.
const getToLog = () => Reflect.get(this, '@@toLog');
const setToLog = v => Reflect.set(this, '@@toLog', v);
const toggleLogChain = b => Reflect.set(this, '@@logChain', b);
const isLogChain = () => Reflect.get(this, '@@logChain');
const loggers = Reflect.get(this, 'loggers');
// Here is the core of our trickery, a Proxy returning a Proxy, Proxyception!
// This Proxy wraps around the default console.log (target = console).
// This way, any non-call access to console.log will still work as normal,
// for example, `console.log.name` will still return "log" as you'd expect.
return new Proxy(target.log, {
// Our trap comes into play once an attempt to call the function is made.
// fn refers to the default console.log function
// argz will be the array of arguments passed to the call (the stuff to be logged)
//
// thiz is what allows everything here to work, it refers to the `this` value
// which the called function is attached to, and for console.log, under normal
// circumstances, it will always be the `console` global.
// However, when one creates a backup copy of console.log prior to monkeypatching it,
// that copy becomes dettached from the `console` global.
// If assigned to a variable, it will have nothing as `this`, if assigned to any other
// object, it will have said non-console object as `this`.
// In summary, this basically allows us to more or less "detect" when a console.log call is made
// from the "true" console.log, or from a copied backup reference of it inside a monkeypatch.
// Appendix:
// To put emphasis on the "more or less", this is not even close to flawless,
// people could be making copies of console.log for many other reasons, even basic ones,
// such as simply aliasing the function to a different name, which would already be a false positive,
// and all of that person's logs would be silently broken by this code.
// There is also the Function.prototype functions, bind, call and apply, all which are capable of
// artificially setting the `this` context of a called function, which can also trip this system.
// These are more reasons why this should and probably will never be an npm package to actually be used,
// it is merely a proof of concept.
apply(fn, thiz, argz) {
setToLog(argz); // handler.@@toLog keeps the current "state" of the array of arguments to be logged
// as explained above, check if this is a "true" call to console.log
// by the way, did you know? a proxified object is not equal to a non-proxied reference to itself.
// so if you were wondering why we are checking against "proxy", that's why.
if (thiz === proxy) {
DebugLog('Starting log chain vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv');
toggleLogChain(true); // handler.@@logChain is a boolean that keeps track of whether we are in a log chain or not.
Reflect.set(this, '@@toLog', []);
// Call every custom logger in orderly fashion.
// This is another key difference between this overengineered mess and simply doing raw monkeypatches ontop of others,
// in the latter, the order of the loggers is backwards from the insertion order, which may be unexpected to some,
// and puts the burden of reversing ensuring your new logger doesn't break all the previous ones on the last user.
// Here, the order is the insertion order, which is more intuitive, and also allows for the user to make use of
// the return value of the previous logger to build upon it, which is not possible with the other approach.
for (const logger of loggers) {
DebugLog('Calling logger:', logger.name, 'with args:', getToLog());
// For accuracy, loggers are bound to the console object.
// Keep in mind these are the user functions, so they are not proxied,
// therefore this won't trip our "true call" check.
logger.apply(thiz, getToLog());
}
DebugLog('Calling logger:', 'console.log', 'with args:', getToLog());
// End the chain with a call to the default console.log to actually log the final result.
fn.apply(thiz, getToLog());
toggleLogChain(false); // Announce the end of the log chain for future calls.
DebugLog('Ending log chain ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^');
return;
} else {
// If this wasn't a "true" console.log call,
// all we need to do is update the @@toLog variable for the next call,
// in case we are taking part of a "true" call's logger chain sequence.
// Because we also need to initialize the @@toLog variable for the "true" call,
// it is already unconditionally set at the very start of the apply trap.
//
// This is also where we check if we are in a log chain at all or not,
// if we aren't, we need to call the default console.log to actually log the result,
// otherwise just return, since we are already did our part in the current log chain.
if (!isLogChain()) fn.apply(thiz, getToLog());
}
}
});
} else return target[prop]; // you know the drill with proxies
},
// Another fun thing I learned about proxies while making this...
// I've always used the `set` trap as it is the simpler one and shorter to write one,
// but turns out it isn't actually very useful, as it only works for direct property assignments,
// and not for things like Object.defineProperty, which is pretty much allows you bypass the trap entirely...
// Thankfully, the `defineProperty` trap exists, and it captures both what `set` captures AND what it misses, phew.
// Can you imagine if you had to write both traps for every proxy you wrote?!
defineProperty(target, prop, descriptor) {
// Intercept assignments to console.log (console.info is merely an alias to it!)
if (prop === 'log' || prop === 'info') {
// Play nice and allow getters even though I doubt anyone would ever use them for console.log
// I'm aware this isn't technically accurate and that the getter should be called when I'm about to call the logger,
// but I'm not going to bother that much for a proof of concept.
const value = descriptor.value ?? descriptor.get?.();
// I mean monkeypatching is already a bad idea, but at least do it right... What else would I even do here.
// But something tells me at least one person out there decided to override console.log with a custom object for whatever reason.
if (typeof value !== 'function') throw new TypeError('console.log logger must be a function');
Reflect.get(this, 'loggers').add(value); // add the assigned function to the set of custom loggers
DebugLog('Added logger:', value.name, '\n', Reflect.get(this, 'loggers'));
} else {
Object.defineProperty(target, prop, descriptor); // your usual drill with proxies again
}
return true;
},
});
}
console.log('original log', 7); // normal "true" console.log call with nothing applied yet
// now let's imagine our code depends on packages A, B and C, and they are imported in that order
// imaginary package A's first monkeypatch to console.log
const logBackup = console.log;
function logPrefixer(...args) {
//console.debug('+ prefixing log');
args.unshift('[prefix]');
logBackup(...args);
}
// thanks to our log chain detection, this will still work like a normal console.log
// despite not being a "true" console.log call
logBackup('backup log', 7);
// Attach the monkeypatch to console.log
console.log = logPrefixer;
// This will now be logged with the prefix, so far just a normal one-time monkeypatch
console.log('injected logPrefixer', 7);
// Now here is where things get interesting
// imaginary package B's first monkeypatch to console.log
// notice how package B's author decided to use console.info instead of console.log
// this would normally already be a problem for traditional monkeypatch chains,
// based on just keeping copying the last monkeypatched function, however,
// since console.log has multiple aliases it can be grabbed from,
// (note that these "aliases" are internal and in userland JS code they are considered different functions,
// since JS functions are passed by value, not by reference, thus `console.log !== console.info`)
// this usage of console.info has started a new monkeypatch chain,
// separate from the one started by package A's monkeypatch, which is undesired.
// However, since we are using a "proper" chainable log implementation, this is not a problem,
// as our Proxy traps account for both "log" and "info".
const infoBackup = console.info;
function doubleNumbersLogger(...args) {
//console.debug('+ doubling logged numbers');
args = args.map(x => typeof x === 'number' ? x * 2 : x);
infoBackup(...args);
}
console.info = doubleNumbersLogger;
// This will now be logged with the prefix AND doubled numbers, in this case, 7 -> 14
console.info('injected doubleNumbersLogger', 7);
// Now let's imagine package C's author decided to use console.log again.
// Thankfully, this swap from console.info to console.log does not affect anything for this implementation.
const otherLogBackup = console.log;
function logSuffixer(...args) {
//console.debug('+ suffixing log');
args.push('[suffix]');
otherLogBackup(...args);
}
console.log = logSuffixer;
// This will now be logged with the prefix, doubled numbers AND a suffix.
console.log('injected logSuffixer', 7);
// as it was previously shown prior to any monkeypatches, this will still work like a normal console.log
// as you'd expect, despite not being a "true" console.log call
otherLogBackup('other backup log', 7);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment