Finding and killing anonymous sessions in Drupal

Ordinarily, sessions in Drupal are created when you log into Drupal, e.g. when you want to create content, or perform some other function that requires authentication.

You can however (just like in any PHP application) start a session without first authenticating. This is known as an anonymous session.

This is almost always a poor decision, because as soon as you start a session, this generates a session cookie, and all reverse proxy caches will refuse to cache the responses from Drupal. Drupal will also add caching headers to indicate the response is not cacheable as well.

There are often better places to store state for anonymous users, e.g. the browsers localStorage, these have the advantage of not interfering with Drupal or caching.

This post will attempt to highlight some paths and avenues you can explore, the next time you come across this in an application you are working on.

Known contributed module offenders

Acquia publishes a fairly broad list of modules to which are known to break caching, some examples include

If you are looking to store state (e.g. flag, textsize) then localStorage is better suited to that. If you are doing geolocation based things, most CDNs have the ability to do this lookup for you.

If you are using these modules, use with caution, and ensure you are not breaking the caching of your entire site.

Known Drupal core offenders

If you set a message using drupal_set_message() (in Drupal 7) or \Drupal::messenger()->addStatus(); (in Drupal 8/9) this will store the message in a PHP session. What sounds like a cool idea at the time - e.g. "show a message to all users on the homepage" - will end up making the site completely un-cacheable.

This can (and has) taken down high traffic sites.

Custom code

Custom code is another place you will find code that may set anonymous sessions. Doing a quick grep will speed things up:

grep -nrI --exclude-dir=web/vendor --exclude-dir=web/core '$_SESSION' web/

Check the sessions table

It is fairly easy to spot the culprit when you examine the sessions table in Drupal's database. Here is a quick query to show the current count of anonymous sessions. This number ideally should be 0 or close to it.

MySQL [example]> SELECT count(*) FROM sessions WHERE uid = 0;
+----------+
| count(*) |
+----------+
|    10853 |
+----------+
1 row in set (0.004 sec)

If you see you have a lot of sessions, then the next step is to read the contents of one:

MySQL [example]> SELECT SUBSTR(session, 1, 650) as '' FROM sessions WHERE uid = 0 ORDER BY timestamp DESC LIMIT 1;

_symfony_flashes|a:1:{s:5:"error";a:149:{i:0;O:25:"Drupal\Core\Render\Markup":1:{s:9:" * string";s:2078:"<em class="placeholder">Notice</em>: Object of class Drupal\Core\Field\FieldItemList could not be converted to int in <em class="placeholder">example_preprocess_node()</em> (line <em class="placeholder">146</em> of <em class="placeholder">themes/example/example.theme</em>). <pre class="backtrace">example_preprocess_node(Array, &#039;node&#039;, Array) (Line: 287)
Drupal\Core\Theme\ThemeManager-&gt;render(&#039;node&#039;, Array) (Line: 437)
Drupal\Core\Render\Renderer-&gt;doRender(Array, ) (Line: 195)
Drupal\Core\Render\Renderer-&gt;render(Array, ) (Line: 

1 row in set (0.001 sec)

So now you know that there is a bug in the code example_preprocess_node() around line 146. This is the source of the messages in the first place.

After reading this great answer on stackoverflow this is likely due to the fact that the messages block is not rendered in the theme.

After checking the block layout page, the block is listed there

Finding and killing anonymous sessions in Drupal
The Drupal core messages block attempting to render in the highlighted region.

However checking the page templates, the variable {{ page.highlighted }} was completely omitted. This was the root cause of the messages not being able to sent to the browser, and thus they keep piling up in the sessions table.

If you want to see how much data you are storing for these sessions:

MySQL [example]> SELECT uid, CONCAT(SUM(LENGTH(session)) / 1048576.0, ' MB') as size FROM sessions  WHERE uid = 0 GROUP BY uid;
+-----+--------------+
| uid | size         |
+-----+--------------+
|   0 | 2433.6637 MB |
+-----+--------------+
1 row in set (0.513 sec)

Around 2.4GB for this one site 😱. That is a lot of messages.

Comments

If you have any other tips and tricks for hunting down anonymous sessions, or even any (anonymized) war stories - please let me know in the comments.