 core/lib/Drupal/Core/Render/Renderer.php   |  11 +
 modules/renderviz/css/renderviz.layout.css |  19 ++
 modules/renderviz/css/renderviz.theme.css  |  11 +
 modules/renderviz/js/jquery.comments.js    | 351 +++++++++++++++++++++++++++++
 modules/renderviz/js/renderviz.js          | 102 +++++++++
 modules/renderviz/renderviz.info.yml       |   3 +
 modules/renderviz/renderviz.libraries.yml  |  22 ++
 modules/renderviz/renderviz.module         |  13 ++
 8 files changed, 532 insertions(+)

diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index 0ec8fa1..bd0e822 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Render;
 
+use Drupal\Component\Serialization\Json;
 use Drupal\Component\Utility\Crypt;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Cache\Cache;
@@ -247,6 +248,8 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     $elements['#attached'] = isset($elements['#attached']) ? $elements['#attached'] : array();
     $elements['#post_render_cache'] = isset($elements['#post_render_cache']) ? $elements['#post_render_cache'] : array();
 
+    $elements['#debug']['#cache'] = $elements['#cache'];
+
     // Allow #pre_render to abort rendering.
     if (!empty($elements['#printed'])) {
       // The #printed element contains all the bubbleable rendering metadata for
@@ -420,6 +423,14 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     // Rendering is finished, all necessary info collected!
     $this->bubbleStack();
 
+    // Add debug output.
+    $interesting_keys = ['keys', 'contexts', 'tags', 'max-age'];
+    if (array_intersect(array_keys($elements['#cache']), $interesting_keys) || array_intersect(array_keys($elements['#debug']['#cache']), $interesting_keys)) {
+      $prefix = '<!--RENDERER_START-->' . '<!--' . Json::encode($elements['#cache']) .  '-->' . '<!--' . Json::encode($elements['#debug']['#cache']) .  '-->';
+      $suffix = '<!--RENDERER_END-->';
+      $elements['#markup'] = $prefix . $elements['#markup'] . $suffix;
+    }
+
     $elements['#printed'] = TRUE;
     $elements['#markup'] = SafeMarkup::set($elements['#markup']);
     return $elements['#markup'];
diff --git a/modules/renderviz/css/renderviz.layout.css b/modules/renderviz/css/renderviz.layout.css
new file mode 100644
index 0000000..3231646
--- /dev/null
+++ b/modules/renderviz/css/renderviz.layout.css
@@ -0,0 +1,19 @@
+html.renderviz {
+  perspective: 3000px;
+  transition: perspective 1s;
+}
+
+html.renderviz body {
+  transform-style: preserve-3d;
+  transform: rotateY(30deg) rotateX(30deg);
+}
+
+.renderviz-trace-layer {
+  transform: translateZ(10px);
+  transition: transform 2s, outline 0.5s;
+  transform-style: preserve-3d;
+}
+
+.renderviz-trace-focus {
+  transform: rotateX(-30deg) rotateY(-30deg) translateZ(300px);
+}
diff --git a/modules/renderviz/css/renderviz.theme.css b/modules/renderviz/css/renderviz.theme.css
new file mode 100644
index 0000000..7a2886a
--- /dev/null
+++ b/modules/renderviz/css/renderviz.theme.css
@@ -0,0 +1,11 @@
+.renderviz-trace-layer {
+  outline: 1px solid red !important;
+}
+
+.renderviz-trace-layer-root {
+  box-shadow: inset 0 0 100px 30px red !important;
+}
+
+.renderviz-trace-focus {
+  box-shadow: inset 0 0 100px 30px blue !important;
+}
diff --git a/modules/renderviz/js/jquery.comments.js b/modules/renderviz/js/jquery.comments.js
new file mode 100644
index 0000000..bca7c37
--- /dev/null
+++ b/modules/renderviz/js/jquery.comments.js
@@ -0,0 +1,351 @@
+;(function( $ ) {
+
+  "use strict";
+
+  // When invoked, the arguments can be defined in several ways:
+  // --
+  // .comments() - Gets all child comments.
+  // .comments( true ) - Gets all comments (deep search).
+  // .comments( value ) - Gets all child comments with the current value.
+  // .comments( value, true ) - Gets all comments with the current value (deep search).
+  // .comments( name, value ) - Gets all child comments with given name-value pair.
+  // .comments( name, value, true ) - Gets all comments with given name-value pair (deep search).
+  $.fn.comments = function() {
+
+    var settings = normalizeArguments( arguments );
+
+    var comments = [];
+
+    // Search for comments in each of the current context nodes.
+    for ( var i = 0, length = this.length ; i < length ; i++ ) {
+
+      appendAll(
+        comments,
+        findComments( this[ i ], settings.deep, settings.name, settings.value )
+      );
+
+    }
+
+    // If there is more than one comment, make sure the collection is unique.
+    if ( comments.length > 1 ) {
+
+      comments = $.unique( comments );
+
+    }
+
+    // Add the found comments to the stack of jQuery selector execution so that the
+    // user can tranverse back up the stack when done.
+    return( this.pushStack( comments, "comments", arguments ) );
+
+  };
+
+
+  // ---
+  // PRIVATE METHODS.
+  // ---
+
+
+  // I add all items in the incoming collection to the end of the existing collection.
+  // This performs an in-place append; meaning, the existing array is mutated directly.
+  function appendAll( existing, incoming ) {
+
+    for ( var i = 0, length = incoming.length ; i < length ; i++ ) {
+
+      existing.push( incoming[ i ] );
+
+    }
+
+    return( existing );
+
+  }
+
+
+  // I collect the comment nodes contained within the given root node.
+  function collectComments( rootNode, isDeepSearch ) {
+
+    // Check for modern browser optimization.
+    if ( isDeepSearch && document.createTreeWalker ) {
+
+      return( collectCommentsWithTreeWalker( rootNode ) );
+
+    }
+
+    return( collectCommentsWithRecursion( rootNode, isDeepSearch ) );
+
+  }
+
+
+  // I collect the comment nodes contained within the given root node using the
+  // universally supported node API. This method can handle both shallow and deep
+  // searches using recursion.
+  function collectCommentsWithRecursion( rootNode, isDeepSearch ) {
+
+    var comments = [];
+
+    var node = rootNode.firstChild;
+
+    while ( node ) {
+
+      // Is comment node.
+      if ( node.nodeType === 8 ) {
+
+        comments.push( node );
+
+        // Is element node (and we want to recurse).
+      } else if ( isDeepSearch && ( node.nodeType === 1 ) ) {
+
+        appendAll( comments, collectCommentsWithRecursion( node, isDeepSearch ) );
+
+      }
+
+      node = node.nextSibling;
+
+    }
+
+    return( comments );
+
+  }
+
+
+  // I collect the comment nodes contained within the given root node using the newer
+  // TreeWalker API. This method can only handle deep searches since the search cannot
+  // be [easily] limited to a single level of the DOM.
+  function collectCommentsWithTreeWalker( rootNode ) {
+
+    var comments = [];
+
+    // NOTE: Last two arguments use default values but are NOT optional in Internet
+    // Explorer. As such, we have to use them here for broader support.
+    var treeWalker = document.createTreeWalker( rootNode, NodeFilter.SHOW_COMMENT, null, false );
+
+    while ( treeWalker.nextNode() ) {
+
+      comments.push( treeWalker.currentNode );
+
+    }
+
+    return( comments );
+
+  }
+
+
+  // I determine if the given name-value pair is contained within the given text.
+  function containsAttribute( text, name, value ) {
+
+    if ( ! text ) {
+
+      return( false );
+
+    }
+
+    // This is an attempt to quickly disqualify the comment value without having to
+    // incur the overhead of parsing the comment value into name-value pairs.
+    if ( value && ( text.indexOf( value ) === -1 ) ) {
+
+      return( false );
+
+    }
+
+    // NOTE: Using "==" to allow some type coersion.
+    if ( parseAttributes( text )[ name.toLowerCase() ] == value ) {
+
+      return( true );
+
+    }
+
+    return( false );
+
+  }
+
+
+  // I filter the given comments collection based on the existing of a "pseudo"
+  // attributes with the given name-value pair.
+  function filterCommentsByAttribute( comments, name, value ) {
+
+    var filteredComments = [];
+
+    for ( var i = 0, length = comments.length ; i < length ; i++ ) {
+
+      var comment = comments[ i ];
+
+      if ( containsAttribute( comment.nodeValue, name, value ) ) {
+
+        filteredComments.push( comment );
+
+      }
+
+    }
+
+    return( filteredComments );
+
+  }
+
+
+  // I filter the given comments based on the full-text match of the given value.
+  // --
+  // NOTE: Leading and trailing white-space is trimmed from the node content before
+  // being compared to the given value.
+  function filterCommentsByText( comments, value ) {
+
+    var filteredComments = [];
+
+    var whitespace = /^\s+|\s+$/g;
+
+    for ( var i = 0, length = comments.length ; i < length ; i++ ) {
+
+      var comment = comments[ i ];
+      var text = ( comment.nodeValue || "" ).replace( whitespace, "" );
+
+      if ( text === value ) {
+
+        filteredComments.push( comment );
+
+      }
+
+    }
+
+    return( filteredComments );
+
+  }
+
+
+
+  // I find the comments in the given node using the given, normalized settings.
+  function findComments( node, isDeepSearch, name, value ) {
+
+    var comments = collectComments( node, isDeepSearch );
+
+    if ( name ) {
+
+      return( filterCommentsByAttribute( comments, name, value ) );
+
+    } else if ( value ) {
+
+      return( filterCommentsByText( comments, value ) );
+
+    }
+
+    return( comments );
+
+  }
+
+
+  // I convert the invocation arguments into a normalized settings hash that the search
+  // algorithm can use with confidence.
+  function normalizeArguments( argumentCollection ) {
+
+    if ( argumentCollection.length > 3 ) {
+
+      throw( new Error( "Unexpected number of arguments." ) );
+
+    }
+
+    if ( ! argumentCollection.length ) {
+
+      return({
+        deep: false,
+        name: "",
+        value: ""
+      });
+
+    }
+
+    if ( argumentCollection.length === 3 ) {
+
+      return({
+        deep: !! argumentCollection[ 2 ],
+        name: argumentCollection[ 0 ],
+        value: argumentCollection[ 1 ]
+      });
+
+    }
+
+    var lastValue = Array.prototype.pop.call( argumentCollection );
+
+    if ( ( lastValue === true ) || ( lastValue === false ) ) {
+
+      if ( ! argumentCollection.length ) {
+
+        return({
+          deep: lastValue,
+          name: "",
+          value: ""
+        });
+
+      }
+
+      if ( argumentCollection.length === 1 ) {
+
+        return({
+          deep: lastValue,
+          name: "",
+          value: argumentCollection[ 0 ]
+        });
+
+      }
+
+      if ( argumentCollection.length === 2 ) {
+
+        return({
+          deep: lastValue,
+          name: argumentCollection[ 0 ],
+          value: argumentCollection[ 1 ]
+        });
+
+      }
+
+    }
+
+    if ( ! argumentCollection.length ) {
+
+      return({
+        deep: false,
+        name: "",
+        value: lastValue
+      });
+
+    }
+
+    if ( argumentCollection.length === 1 ) {
+
+      return({
+        deep: false,
+        name: argumentCollection[ 0 ],
+        value: lastValue
+      });
+
+    }
+
+    if ( argumentCollection.length === 2 ) {
+
+      return({
+        deep: false,
+        name: argumentCollection[ 1 ],
+        value: lastValue
+      });
+
+    }
+
+  }
+
+
+  // I parse the given text value into a collection of name-value pairs.
+  function parseAttributes( text ) {
+
+    var attributes = {};
+
+    var pairPattern = /([a-zA-Z][^=\s]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s]+)))?/gi;
+
+    var matches = null;
+
+    while ( matches = pairPattern.exec( text ) ) {
+
+      attributes[ matches[ 1 ].toLowerCase() ] = ( matches[ 2 ] || matches[ 3 ] || matches[ 4 ] || "" );
+
+    }
+
+    return( attributes );
+
+  }
+
+})( jQuery );
diff --git a/modules/renderviz/js/renderviz.js b/modules/renderviz/js/renderviz.js
new file mode 100644
index 0000000..1a127e4
--- /dev/null
+++ b/modules/renderviz/js/renderviz.js
@@ -0,0 +1,102 @@
+(function ($, _, JSON) {
+
+  "use strict";
+
+  // Map the metadata to data- attributes.
+  function setMetadata(commentNode, element) {
+    var metadata = JSON.parse(commentNode.textContent);
+    for (var key in metadata) {
+      var value = metadata[key];
+      if (value instanceof Array) {
+        if (value.length === 0) {
+          continue;
+        }
+        value = value.join(' ');
+      }
+      element.setAttribute('data-renderviz-' + key, value);
+    }
+  }
+
+  // Map the pre-bubbling metadata to data- attributes.
+  function setPrebubblingMetadata(commentNode, element) {
+    var prebubblingMetadata = JSON.parse(commentNode.textContent);
+    for (var key in prebubblingMetadata) {
+      var value = prebubblingMetadata[key];
+      if (value instanceof Array) {
+        if (value.length === 0) {
+          continue;
+        }
+        value = value.join(' ');
+      }
+      element.setAttribute('data-renderviz-prebubbling-' + key, value);
+    }
+  }
+
+  function visualize(queryMetadataType, queryMetadataValue) {
+    // One-time initialization.
+    $('html').once('renderviz').addClass('renderviz');
+    // Reset.
+    $('.renderviz-trace-layer').removeClass('renderviz-trace-layer');
+    $('.renderviz-trace-layer-root').removeClass('renderviz-trace-layer-root');
+    $('.renderviz-trace-focus').removeClass('renderviz-trace-focus');
+    // Apply the new query.
+    var result = $('[data-renderviz-' + queryMetadataType + '~="' + queryMetadataValue + '"]');
+    console.log('' + result.length + ' matches found.');
+    console.log(result);
+    result.addClass('renderviz-trace-layer');
+    $('[data-renderviz-prebubbling-' + queryMetadataType + '~="' + queryMetadataValue + '"]').addClass('renderviz-trace-layer-root');
+    window.rendervizLastType = queryMetadataType;
+    window.rendervizLastValue = queryMetadataValue;
+  }
+
+  function focus(index) {
+    // Reset.
+    $('.renderviz-trace-focus').removeClass('renderviz-trace-focus');
+    // Apply the new focus.
+    var el = $('[data-renderviz-' + window.rendervizLastType + '~="' + window.rendervizLastValue+ '"]')[index];
+    console.log(el);
+    $(el).addClass('renderviz-trace-focus');
+  }
+
+  Drupal.behaviors.renderviz = {
+    attach: function (context) {
+      // Transplant the data from the HTML comments onto the parent element.
+      var comments = $(context).comments(true);
+      for (var i = 0; i < comments.length; i++) {
+        if (comments[i].textContent === 'RENDERER_START') {
+          var element = comments[i].nextElementSibling;
+          if (element) {
+            // Mark the element for renderviz treatment.
+            element.setAttribute('data-renderviz-element', true);
+            setMetadata(comments[i+1], element);
+            setPrebubblingMetadata(comments[i+2], element);
+          }
+          // @todo improve this; might need some complex merging logic.
+          // If we have renderer metadata, but it's not for an Element node,
+          // then it is for a Text node. In that case, set the pre-bubbling
+          // metadata of the Text node on the parent Element node.
+          // e.g. TimestampFormatter — the node timestamp
+          else {
+            element = comments[i].parentElement;
+            setPrebubblingMetadata(comments[i+2], element);
+          }
+        }
+      }
+
+      $('body').once('renderviz-init').each(function() {
+        var contexts = [], tags = [];
+        $('[data-renderviz-contexts]').each(function (index, element) {
+          contexts = _.union(contexts, element.attributes['data-renderviz-contexts'].value.split(' '));
+        });
+        $('[data-renderviz-tags]').each(function (index, element) {
+          tags = _.union(tags, element.attributes['data-renderviz-tags'].value.split(' '));
+        });
+        console.log('' + $('[data-renderviz-element]').length + ' unique rendered elements on the page.', "\nContexts:", contexts, "\nTags:", tags);
+        console.log("To use:\n- Querying: `renderviz(metadataType, metadataValue)`, e.g. `renderviz('contexts', 'timezone')`.\n- Focusing: `rendervizFocus(index)`, e.g. `rendervizFocus(0)` to focus on the first element of the last query.");
+        window.renderviz = visualize;
+        window.rendervizFocus = focus;
+      });
+    }
+  };
+
+})(jQuery, _, window.JSON);
diff --git a/modules/renderviz/renderviz.info.yml b/modules/renderviz/renderviz.info.yml
new file mode 100644
index 0000000..08030b1
--- /dev/null
+++ b/modules/renderviz/renderviz.info.yml
@@ -0,0 +1,3 @@
+name: Render Visualization
+type: module
+core: 8.x
diff --git a/modules/renderviz/renderviz.libraries.yml b/modules/renderviz/renderviz.libraries.yml
new file mode 100644
index 0000000..ba4aafd
--- /dev/null
+++ b/modules/renderviz/renderviz.libraries.yml
@@ -0,0 +1,22 @@
+jquery.comments:
+  remote: …
+  version: …
+  license:
+    name: …
+    url: …
+    gpl-compatible: true
+  js:
+    js/jquery.comments.js: {}
+  dependencies:
+    - core/jquery
+
+renderviz:
+  css:
+    layout:
+      css/renderviz.layout.css: {}
+    theme:
+      css/renderviz.theme.css: {}
+  js:
+    js/renderviz.js: {}
+  dependencies:
+    - renderviz/jquery.comments
diff --git a/modules/renderviz/renderviz.module b/modules/renderviz/renderviz.module
new file mode 100644
index 0000000..0945f5d
--- /dev/null
+++ b/modules/renderviz/renderviz.module
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * @file
+ * …
+ */
+
+/**
+ * Implements hook_page_attachments().
+ */
+function renderviz_page_attachments(array &$page) {
+  $page['#attached']['library'][] = 'renderviz/renderviz';
+}
