Conditionally load Munchkin based on IP ranges

A pesky problem with Munchkin is that you can't selectively turn it off based on visitor characteristics.

You can choose Disable Munchkin Tracking on Marketo LPs, but that's for every visitor, and on your corporate site you're unlikely to have access to an on/off switch. So internal visitors show up in your stats (okay when testing, but bad after go-live) and it would be great to be able to exclude them, wouldn't it?

With other analytics packages, you configure included and excluded IP ranges in some sort of control panel. But unless you load Munchkin via your own CDN to get that kind of granularity (not really recommended), Munchkin is on for every visitor — unless it's turned off for every visitor.

Although the best place to check IPs would of course be on the Munchkin server itself (thus no extra requests) we can ping a remote service to get the end user's IP, then use a l'il JS wrapper to conditionally load Munchkin based on whether the IP appears in allowed/disallowed Access Control Lists (ACLs).

We're going to use the ipify service developed by Randall Degges (renowned engineer/thoughtish leaderish guy). Ipify is free, highly scalable and serves billions of requests a month.*

Setting your ACLs

As you'll see in the full code below, the ACLs are arrays of strings with a simple wildcard-like syntax:

         allowedPatterns: [
             '1.2.3.*',
             '108.12.130.200',
             '*.*.*.*'
         ],
         disallowedPatterns: [
             '69.28.212.*'
         ]

The rules:

  • Wildcards only match full IP octets. So you can't do 1.2.*43.1 or 1.2.3.10*. But you can, of course, do 1.2.*.*. A subnet mask or CIDR matcher, whatever, was out of scope for something like this.**

  • You probably want the catch-all *.*.*.* in your allowed section. If you don't, you're only opting known sources into Munchkin, and everyone else won't get it.

  • Disallowed patterns take precedence over allowed ones. So in the above example, even though 69.28.212.111 matches the allowed catch-all pattern *.*.*.*, it also matches the disallowed 69.28.212.*, so the final verdict is Munchkin will not load from that IP.

It caches

I've built smart caching into the code, so ipify is only called once per session per domain. In this case, “session” is defined as any tab until it closes, and “domain” is an exact origin, not its subdomains or parent domains (spoiler alert, I'm using the sessionStorage API, which always works like this).

If it's not already clear, the reason I don't use longer cross-session storage is that if you move from your office WiFi to the road, and restart your browser, your new IP must not automatically be given the same treatment as your old one. (In fact, you may be switching networks specifically to emulate public and private viewers.)

If you don't restart your browser after switching nets, then the original setting is used. There's no cross-browser way to detect a network change (without calling ipify constantly) so I didn't bother caring about it.

Get the code

More lines of code than you might think, but given that it's totally self-contained (has a mini-script injector and IP ACL thingy) it's pretty short. Honestly, it should be even longer as those utility functions are quite barebones, but it's fine for today.

 /**
  * Munchkin/ipify v1.0.1 -- http://codepen.io/figureone/pen/861d82839b97a778c3fc0651b4c81af1.js
  * @author (c) 2017 Sanford Whiteman, TEKNKL
  * @license MIT
  */
 (function() {

         /* ---- SET UP YOUR SITE-SPECIFIC OPTIONS HERE ---- */

         var options = {
             allowedPatterns: [
                 '1.2.3.*',
                 '108.12.130.200',
                 '*.*.*.*'
             ],
             disallowedPatterns: [
                 '69.28.212.*'
             ],
             munchkinId: '111-XOR-222',
             munchkinInitOptions: {},
             debug: false, // set to true to see console logs 
             skipCache: false // set to true to suspend cache use 
         }

         /* ---- NO NEED TO EDIT BELOW THIS LINE! ---- */

         window.IpifiedMunch = {
             loadConditionally: function(ip, fromCache, forceState) {

                 var listAllowed = IpifiedMunch.Util.simpleIPInList(ip, options.allowedPatterns),
                     listDisallowed = IpifiedMunch.Util.simpleIPInList(ip, options.disallowedPatterns);

                 var doLoad = (forceState == 'allowed') ||
                     (listAllowed && !listDisallowed && forceState != 'disallowed');

                 if (doLoad) {
                     IpifiedMunch.Util.simpleInject(
                         '//munchkin.marketo.net/munchkin-beta.js',
                         'munchkin-bootstrap', {
                             onload: function(e) {
                                 Munchkin.init(
                                     options.munchkinId,
                                     options.munchkinInitOptions
                                 );
                             }
                         }
                     );
                 }

                 options.debug && console.log(
                     'Caching enabled: ' + !options.skipCache,
                     '\nIP: ' + ip,
                     '\nIP was cached: ' + fromCache,
                     '\nAllowed by ACL: ' + listAllowed,
                     '\nDisallowed by ACL: ' + listDisallowed,
                     '\nForced: ' + forceState,
                     '\nMunchkin loaded: ' + doLoad
                 );
             },

             cacheAndCondition: function(ipifyResult) {
                 IpifiedMunch.loadConditionally(ipifyResult.ip, false);

                 if (!options.skipCache) {
                     sessionStorage.last_ipify_result = ipifyResult.ip;
                 }

             },

             init: function(ip, forceState) {
                 if ((!options.skipCache && ip) || forceState) {
                     IpifiedMunch.loadConditionally(ip, true, forceState);
                 } else {
                     IpifiedMunch.Util.simpleInject(
                         '//api.ipify.org?format=jsonp',
                         'ipify-jsonp', {
                             wrap: 'IpifiedMunch.cacheAndCondition'
                         }
                     );
                 }
             },

             Util: {
                 simpleIPInList: function(ip, acl) {
                     return acl
                         .map(function(pattern) {
                             return pattern instanceof RegExp ?
                                 pattern :
                                 new RegExp('^' + pattern.replace(/(\.)/g, '\\$1').replace(/(\*)/g, '\\d{1,3}') + '$');
                         })
                         .some(function(rePattern) {
                             return rePattern.test(ip);
                         });
                 },

                 simpleInject: function(src, id, cb) {
                     var cb = cb || {},
                         loc = document.createElement('a'),
                         el = document.createElement('script');

                     loc.href = src;
                     if (cb.wrap) {
                         loc.search += (loc.search ? '&' : '?') + 'callback=' + cb.wrap;
                     }

                     var attrs = [
                         ['type', 'application/javascript'],
                         ['src', loc.href],
                         ['id', id],
                         ['onerror', cb.onerror],
                         ['onload', cb.onload]
                     ];

                     attrs
                         .filter(function(itm) {
                             return function(key, value) {
                                 return typeof value != 'undefined';
                             }.apply(el, itm);
                         })
                         .forEach(function(itm) {
                             return function(key, value) {
                                 el[key] = value;
                             }.apply(el, itm);
                         });

                     document.head.appendChild(el);
                 }
             }
         }

         IpifiedMunch.init(sessionStorage.last_ipify_result, localStorage.munchkin_always);

 })();

Can I override the IP lookup in specific cases?

Yep, one step ahead of you. :) These three links are bookmarklets. After they're dragged to your bookmarks bar, they do as the labels suggest.

Munchkin Force On Munchkin Force Off Restore Munchkin Auto-detect
  • Force on/off/auto applies to the exact domain you're on when you click the bookmarklet, not to subdomains or parent domains. If you're on www.example.com and click the Force Off link, that doesn't apply to pages.example.com. You'd have to click when you're on the other domain, if you wanted to cover that one too.

  • The “force” settings do persist across browser restarts. (This doesn't apply to Incognito/InPrivate browsing, though, since stored data is always cleared when you exit those modes.)

  • Tech note: these links are setting localStorage.munchkin_always to 'allowed' or 'disallowed' or deleting the property entirely. So you could manage this property in other ways (check to see if a particular internal user is logged in to an internal service, whatever).

A few cautions

As always, be careful what you wish for. If you disable Munchkin from your corporate IPs, you may find that people didn't really know what Munchkin did, after all. A good clue is if they say, “I know it's turned off, but clicks are still registering.” (Clicked Email activities don't use Munchkin.) Or if they say, “But forms aren't pre-filling anymore.” So be prepared to show people the force allow/disallow feature… or maybe back out the script completely.

More important, if you turn Munchkin off for your HQ network, you're misrepresenting the response time of your site. Without logging Clicked Link on Web Page activities, page-to-page navigation will be 50-200ms faster. That's kinda the way it goes. So when benchmarking real user experience, don't turn off Munchkin.

Also, since you're putting your allow/disallow ACLs in the source of a web page, it's true that you're telling the world which public IPs belong to your company. This isn't really private information (any site you visit knows your current public IP, by definition) but a real security nut might balk, muttering about “network mapping” until you gently shove them out of the room. I'm somewhat paranoid, too, but really, the number of ways you could gather the IP ranges belonging to a company (namely, their purposeful registration via ARIN!) are so numerous that this is hardly notable. Just don't accidentally add internal IP addresses.


Notes

* It's also open-source, so you could rehost it yourself if you wanted to be absolutely sure. I wouldn't worry about it (and that's saying something, as I am usually skeptical of hosted services that are free + anonymous + always available).

** But you can pass an entire custom JS /regex/ if you're feeling daring. String wildcards are converted to regexes internally, and if the code sees you passed a RegExp object it'll use that as-is.