Logger   C
last analyzed

Complexity

Total Complexity 53

Size/Duplication

Total Lines 835
Duplicated Lines 0 %

Importance

Changes 25
Bugs 2 Features 6
Metric Value
eloc 317
c 25
b 2
f 6
dl 0
loc 835
rs 6.96
wmc 53

18 Methods

Rating   Name   Duplication   Size   Complexity  
A ensureSchema() 0 44 1
A ensureCappedCollection() 0 29 3
A ensureCollection() 0 27 3
B enhanceLogEntry() 0 55 9
A __construct() 0 23 1
C log() 0 119 11
A trackerCollection() 0 2 1
A templatesCount() 0 3 1
A requestTemplates() 0 38 4
A eventCollection() 0 12 2
A requestEventsCount() 0 16 3
A eventCount() 0 3 1
B templates() 0 36 6
A setLimit() 0 2 1
A templateCollection() 0 2 1
A eventCollections() 0 8 1
A templateTypes() 0 3 1
A requestEvents() 0 34 3

How to fix   Complexity   

Complex Class

Complex classes like Logger often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Logger, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Drupal\mongodb_watchdog;
6
7
use Drupal\Component\Datetime\TimeInterface;
0 ignored issues
show
Bug introduced by
The type Drupal\Component\Datetime\TimeInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
8
use Drupal\Component\Render\FormattableMarkup;
0 ignored issues
show
Bug introduced by
The type Drupal\Component\Render\FormattableMarkup was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
9
use Drupal\Component\Render\MarkupInterface;
0 ignored issues
show
Bug introduced by
The type Drupal\Component\Render\MarkupInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
10
use Drupal\Component\Utility\Xss;
0 ignored issues
show
Bug introduced by
The type Drupal\Component\Utility\Xss was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use Drupal\Core\Config\ConfigFactoryInterface;
0 ignored issues
show
Bug introduced by
The type Drupal\Core\Config\ConfigFactoryInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
12
use Drupal\Core\Logger\LogMessageParserInterface;
0 ignored issues
show
Bug introduced by
The type Drupal\Core\Logger\LogMessageParserInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
13
use Drupal\Core\Logger\RfcLogLevel;
0 ignored issues
show
Bug introduced by
The type Drupal\Core\Logger\RfcLogLevel was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use Drupal\Core\Messenger\MessengerInterface;
0 ignored issues
show
Bug introduced by
The type Drupal\Core\Messenger\MessengerInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
15
use Drupal\Core\StringTranslation\StringTranslationTrait;
0 ignored issues
show
Bug introduced by
The type Drupal\Core\StringTransl...\StringTranslationTrait was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use MongoDB\Collection;
17
use MongoDB\Database;
18
use MongoDB\Driver\Cursor;
19
use MongoDB\Driver\Exception\InvalidArgumentException;
20
use MongoDB\Driver\Exception\RuntimeException;
21
use MongoDB\Driver\WriteConcern;
22
use MongoDB\Model\CollectionInfoIterator;
23
use Psr\Log\AbstractLogger;
0 ignored issues
show
Bug introduced by
The type Psr\Log\AbstractLogger was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
24
use Psr\Log\LogLevel;
0 ignored issues
show
Bug introduced by
The type Psr\Log\LogLevel was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
25
use Symfony\Component\HttpFoundation\RequestStack;
0 ignored issues
show
Bug introduced by
The type Symfony\Component\HttpFoundation\RequestStack was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
26
27
/**
28
 * Class Logger is a PSR/3 Logger using a MongoDB data store.
29
 *
30
 * @package Drupal\mongodb_watchdog
31
 */
32
class Logger extends AbstractLogger {
33
34
  use StringTranslationTrait;
35
36
  // Configuration-related constants.
37
  // The configuration item.
38
  public const CONFIG_NAME = 'mongodb_watchdog.settings';
39
40
  // The individual configuration keys.
41
  public const CONFIG_ITEMS = 'items';
42
43
  public const CONFIG_REQUESTS = 'requests';
44
45
  public const CONFIG_LIMIT = 'limit';
46
47
  public const CONFIG_ITEMS_PER_PAGE = 'items_per_page';
48
49
  public const CONFIG_REQUEST_TRACKING = 'request_tracking';
50
51
  // The logger database alias.
52
  public const DB_LOGGER = 'logger';
53
54
  // The default channel exposed when using the raw PSR-3 contract.
55
  public const DEFAULT_CHANNEL = 'psr-3';
56
57
  // The magic invalid request ID used in events not triggered by a Web request
58
  // with a valid UNIQUE_ID. 23-byte format, unlike mod_unique_id values (24).
59
  public const INVALID_REQUEST = '@@Not-a-valid-request@@';
60
61
  public const MODULE = 'mongodb_watchdog';
62
63
  // The service for the specific PSR-3 logger for MongoDB.
64
  public const SERVICE_LOGGER = 'mongodb.logger';
65
66
  // The service for the Drupal LoggerChannel for this module, logging to all
67
  // active loggers.
68
  public const SERVICE_CHANNEL = 'logger.channel.mongodb_watchdog';
69
70
  // The service for hook_requirements().
71
  public const SERVICE_REQUIREMENTS = 'mongodb.watchdog_requirements';
72
73
  public const SERVICE_SANITY_CHECK = 'mongodb.watchdog.sanity_check';
74
75
  public const TRACKER_COLLECTION = 'watchdog_tracker';
76
77
  public const TEMPLATE_COLLECTION = 'watchdog';
78
79
  public const EVENT_COLLECTION_PREFIX = 'watchdog_event_';
80
81
  public const EVENT_COLLECTIONS_PATTERN = '^watchdog_event_[[:xdigit:]]{32}$';
82
83
  public const LEGACY_TYPE_MAP = [
84
    'typeMap' => [
85
      'array' => 'array',
86
      'document' => 'array',
87
      'root' => 'array',
88
    ],
89
  ];
90
91
  /**
92
   * Map of PSR3 log constants to RFC 5424 log constants.
93
   *
94
   * @var array<string,int>
95
   *
96
   * @see \Drupal\Core\Logger\LoggerChannel
97
   * @see \Drupal\mongodb_watchdog\Logger::log()
98
   */
99
  protected $rfc5424levels = [
100
    LogLevel::EMERGENCY => RfcLogLevel::EMERGENCY,
101
    LogLevel::ALERT => RfcLogLevel::ALERT,
102
    LogLevel::CRITICAL => RfcLogLevel::CRITICAL,
103
    LogLevel::ERROR => RfcLogLevel::ERROR,
104
    LogLevel::WARNING => RfcLogLevel::WARNING,
105
    LogLevel::NOTICE => RfcLogLevel::NOTICE,
106
    LogLevel::INFO => RfcLogLevel::INFO,
107
    LogLevel::DEBUG => RfcLogLevel::DEBUG,
108
  ];
109
110
  /**
111
   * The logger storage.
112
   *
113
   * @var \MongoDB\Database
114
   */
115
  protected $database;
116
117
  /**
118
   * The limit for the capped event collections.
119
   *
120
   * @var int
121
   */
122
  protected $items;
123
124
  /**
125
   * The minimum logging level.
126
   *
127
   * @var int
128
   *
129
   * @see https://drupal.org/node/1355808
130
   */
131
  protected $limit = RfcLogLevel::DEBUG;
132
133
  /**
134
   * The messenger service.
135
   *
136
   * @var \Drupal\Core\Messenger\MessengerInterface
137
   */
138
  protected $messenger;
139
140
  /**
141
   * The message's placeholders parser.
142
   *
143
   * @var \Drupal\Core\Logger\LogMessageParserInterface
144
   */
145
  protected $parser;
146
147
  /**
148
   * The "requests" setting.
149
   *
150
   * @var int
151
   */
152
  protected $requests;
153
154
  /**
155
   * The request_stack service.
156
   *
157
   * @var \Symfony\Component\HttpFoundation\RequestStack
158
   */
159
  protected $requestStack;
160
161
  /**
162
   * Is request tracking enabled ?
163
   *
164
   * @var bool
165
   */
166
  protected $requestTracking;
167
168
  /**
169
   * A sequence number for log events during a request.
170
   *
171
   * @var int
172
   */
173
  protected $sequence = 0;
174
175
  /**
176
   * An array of templates already used in this request.
177
   *
178
   * Used only with request tracking enabled.
179
   *
180
   * @var string[]
181
   */
182
  protected $templates = [];
183
184
  /**
185
   * The datetime.time service.
186
   *
187
   * @var \Drupal\Component\Datetime\TimeInterface
188
   */
189
  protected $time;
190
191
  /**
192
   * Logger constructor.
193
   *
194
   * @param \MongoDB\Database $database
195
   *   The database object.
196
   * @param \Drupal\Core\Logger\LogMessageParserInterface $parser
197
   *   The parser to use when extracting message variables.
198
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
199
   *   The core config_factory service.
200
   * @param \Symfony\Component\HttpFoundation\RequestStack $stack
201
   *   The core request_stack service.
202
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
203
   *   The messenger service.
204
   * @param \Drupal\Component\Datetime\TimeInterface $time
205
   *   The datetime.time service.
206
   */
207
  public function __construct(
208
    Database $database,
209
    LogMessageParserInterface $parser,
210
    ConfigFactoryInterface $configFactory,
211
    RequestStack $stack,
212
    MessengerInterface $messenger,
213
    TimeInterface $time
214
  ) {
215
    $this->database = $database;
216
    $this->messenger = $messenger;
217
    $this->parser = $parser;
218
    $this->requestStack = $stack;
219
    $this->time = $time;
220
221
    $config = $configFactory->get(static::CONFIG_NAME);
222
    // During install, a logger will be invoked 3 times, the first 2 without any
223
    // configuration information, so hard-coded defaults are needed on all
224
    // config keys.
225
    $this->setLimit($config->get(static::CONFIG_LIMIT) ?? RfcLogLevel::DEBUG);
226
    // Do NOT use 1E4 / 1E5: these are doubles, but config is typed to integers.
227
    $this->items = $config->get(static::CONFIG_ITEMS) ?? 10000;
228
    $this->requests = $config->get(static::CONFIG_REQUESTS) ?? 100000;
229
    $this->requestTracking = $config->get(static::CONFIG_REQUEST_TRACKING) ?? FALSE;
230
  }
231
232
  /**
233
   * Fill in the log_entry function, file, and line.
234
   *
235
   * @param array<string,mixed> $entry
236
   *   An event information to be logger.
237
   * @param array<int,array<string,mixed>> $backtrace
238
   *   A call stack.
239
   *
240
   * @throws \ReflectionException
241
   */
242
  protected function enhanceLogEntry(array &$entry, array $backtrace): void {
243
    // Create list of functions to ignore in backtrace.
244
    static $ignored = [
245
      'call_user_func_array' => 1,
246
      '_drupal_log_error' => 1,
247
      '_drupal_error_handler' => 1,
248
      '_drupal_error_handler_real' => 1,
249
      'Drupal\mongodb_watchdog\Logger::log' => 1,
250
      'Drupal\Core\Logger\LoggerChannel::log' => 1,
251
      'Drupal\Core\Logger\LoggerChannel::alert' => 1,
252
      'Drupal\Core\Logger\LoggerChannel::critical' => 1,
253
      'Drupal\Core\Logger\LoggerChannel::debug' => 1,
254
      'Drupal\Core\Logger\LoggerChannel::emergency' => 1,
255
      'Drupal\Core\Logger\LoggerChannel::error' => 1,
256
      'Drupal\Core\Logger\LoggerChannel::info' => 1,
257
      'Drupal\Core\Logger\LoggerChannel::notice' => 1,
258
      'Drupal\Core\Logger\LoggerChannel::warning' => 1,
259
      'Psr\Log\AbstractLogger::alert' => 1,
260
      'Psr\Log\AbstractLogger::critical' => 1,
261
      'Psr\Log\AbstractLogger::debug' => 1,
262
      'Psr\Log\AbstractLogger::emergency' => 1,
263
      'Psr\Log\AbstractLogger::error' => 1,
264
      'Psr\Log\AbstractLogger::info' => 1,
265
      'Psr\Log\AbstractLogger::notice' => 1,
266
      'Psr\Log\AbstractLogger::warning' => 1,
267
    ];
268
269
    foreach ($backtrace as $bt) {
270
      if (isset($bt['function'])) {
271
        $function = empty($bt['class']) ? $bt['function'] : $bt['class'] . '::' . $bt['function'];
272
        if (empty($ignored[$function])) {
273
          $entry['%function'] = $function;
274
          /* Some part of the stack, like the line or file info, may be missing.
275
           * From research in 2021-01, this only appears to happen on PHP < 7.0.
276
           *
277
           * @see http://goo.gl/8s75df
278
           *
279
           * No need to fetch the line using reflection: it would be redundant
280
           * with the name of the function.
281
           */
282
          $entry['%line'] = $bt['line'] ?? NULL;
283
          $file = $bt['file'] ?? '';
284
          if (empty($file) && is_callable($function)) {
285
            $reflectionObj = empty($bt['class'])
286
              ? new \ReflectionFunction($function)
287
              : new \ReflectionMethod($function);
288
            $file = $reflectionObj->getFileName();
289
          }
290
291
          $entry['%file'] = $file;
292
          break;
293
        }
294
        elseif ($bt['function'] == '_drupal_exception_handler') {
295
          $e = $bt['args'][0];
296
          $this->enhanceLogEntry($entry, $e->getTrace());
297
        }
298
      }
299
    }
300
  }
301
302
  /**
303
   * {@inheritdoc}
304
   *
305
   * @see https://httpd.apache.org/docs/2.4/en/mod/mod_unique_id.html
306
   */
307
  public function log($level, $template, array $context = []): void {
308
    // PSR-3 LoggerInterface documents level as "mixed", while the RFC itself
309
    // in §1.1 implies implementations may know about non-standard levels. In
310
    // the case of Drupal implementations, this includes the 8 RFC5424 levels.
311
    if (is_string($level)) {
312
      $level = $this->rfc5424levels[$level];
313
    }
314
315
    if ($level > $this->limit) {
316
      return;
317
    }
318
319
    // Convert PSR3-style messages to SafeMarkup::format() style, so they can be
320
    // translated at runtime too.
321
    $placeholders = $this->parser->parseMessagePlaceholders($template,
322
      $context);
323
324
    // If code location information is all present, as for errors/exceptions,
325
    // then use it to build the message template id.
326
    $type = $context['channel'] ?? static::DEFAULT_CHANNEL;
327
    $location = [
328
      '%type' => 1,
329
      '@message' => 1,
330
      '%function' => 1,
331
      '%file' => 1,
332
      '%line' => 1,
333
    ];
334
    if (!empty(array_diff_key($location, $placeholders))) {
335
      $this->enhanceLogEntry(
336
        $placeholders,
337
        debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10)
338
      );
339
    }
340
    $file = $placeholders['%file'];
341
    $line = $placeholders['%line'];
342
    $function = $placeholders['%function'];
343
    $key = implode(":", [$type, $level, $file, $line, $function]);
344
    $templateId = md5($key);
345
346
    $selector = ['_id' => $templateId];
347
    $update = [
348
      '$inc' => ['count' => 1],
349
      '$set' => [
350
        '_id' => $templateId,
351
        'message' => $template,
352
        'severity' => $level,
353
        'changed' => $this->time->getCurrentTime(),
354
        'type' => mb_substr($type, 0, 64),
355
      ],
356
    ];
357
    $options = ['upsert' => TRUE];
358
    $templateResult = $this->database
359
      ->selectCollection(static::TEMPLATE_COLLECTION)
360
      ->updateOne($selector, $update, $options);
361
362
    // Only insert each template once per request.
363
    if ($this->requestTracking && !isset($this->templates[$templateId])) {
364
      $requestId = $this->requestStack
365
        ->getCurrentRequest()
366
        ->server
367
        ->get('UNIQUE_ID');
368
369
      $this->templates[$templateId] = 1;
370
      $track = [
371
        'requestId' => $requestId,
372
        'templateId' => $templateId,
373
      ];
374
      $this->trackerCollection()->insertOne($track);
375
    }
376
    else {
377
      $requestId = self::INVALID_REQUEST;
378
    }
379
380
    $eventCollection = $this->eventCollection($templateId);
381
    if ($templateResult->getUpsertedCount()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $templateResult->getUpsertedCount() of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
382
      // Capped collections are actually size-based, not count-based, so "items"
383
      // is only a maximum, assuming event documents weigh 1kB, but the actual
384
      // number of items stored may be lower if items are heavier.
385
      // We do not use 'autoindexid' for greater speed, because:
386
      // - it does not work on replica sets,
387
      // - it is deprecated in MongoDB 3.2 and going away in 3.4.
388
      $options = [
389
        'capped' => TRUE,
390
        'size' => $this->items * 1024,
391
        'max' => $this->items,
392
      ];
393
      $this->database->createCollection($eventCollection->getCollectionName(),
394
        $options);
395
396
      // Do not create this index by default, as its cost is useless if request
397
      // tracking is not enabled.
398
      if ($this->requestTracking) {
399
        $key = ['requestTracking_id' => 1];
400
        $options = ['name' => 'admin-by-request'];
401
        $eventCollection->createIndex($key, $options);
402
      }
403
    }
404
405
    foreach ($placeholders as &$placeholder) {
406
      if ($placeholder instanceof MarkupInterface) {
407
        $placeholder = Xss::filterAdmin((string) $placeholder);
408
      }
409
    }
410
    $event = [
411
      'hostname' => mb_substr($context['ip'] ?? '', 0, 128),
412
      'link' => $context['link'] ?? NULL,
413
      'location' => $context['request_uri'] ?? NULL,
414
      'referer' => $context['referer'] ?? NULL,
415
      'timestamp' => $context['timestamp'] ?? $this->time->getCurrentTime(),
416
      'user' => ['uid' => $context['uid'] ?? 0],
417
      'variables' => $placeholders,
418
    ];
419
    if ($this->requestTracking) {
420
      // Fetch the current request on each event to support subrequest nesting.
421
      $event['requestTracking_id'] = $requestId;
422
      $event['requestTracking_sequence'] = $this->sequence;
423
      $this->sequence++;
424
    }
425
    $eventCollection->insertOne($event);
426
  }
427
428
  /**
429
   * Ensure a collection is capped with the proper size.
430
   *
431
   * @param string $name
432
   *   The collection name.
433
   * @param int $size
434
   *   The collection size cap.
435
   *
436
   * @return \MongoDB\Collection
437
   *   The collection, usable for additional commands like index creation.
438
   *
439
   * @throws \MongoDB\Exception\InvalidArgumentException
440
   * @throws \MongoDB\Exception\UnsupportedException
441
   * @throws \MongoDB\Exception\UnexpectedValueException
442
   * @throws \MongoDB\Driver\Exception\RuntimeException
443
   *
444
   * @see https://docs.mongodb.com/manual/reference/command/convertToCapped
445
   *
446
   * Note that MongoDB 4.2 still misses a proper exists() command, which is the
447
   * reason for the weird try/catch logic.
448
   *
449
   * @see https://jira.mongodb.org/browse/SERVER-1938
450
   * @todo support sharded clusters: convertToCapped does not support them.
451
   */
452
  public function ensureCappedCollection(string $name, int $size): Collection {
453
    if ($size === 0) {
454
      $this->messenger->addError($this->t('Abnormal size 0 ensuring capped collection, defaulting.'));
455
      $size = 100000;
456
    }
457
458
    $collection = $this->ensureCollection($name);
459
    $stats = $this->database
460
      ->command(['collStats' => $name], static::LEGACY_TYPE_MAP)
461
      ->toArray()[0];
462
    if (!empty($stats['capped'])) {
463
      return $collection;
464
    }
465
466
    $command = [
467
      'convertToCapped' => $name,
468
      'size' => $size,
469
    ];
470
    $this->database->command($command);
471
    $this->messenger->addStatus(
472
      $this->t(
473
        '@name converted to capped collection size @size.',
474
        [
475
          '@name' => $name,
476
          '@size' => $size,
477
        ]
478
      )
479
    );
480
    return $collection;
481
  }
482
483
  /**
484
   * Ensure a collection exists in the logger database.
485
   *
486
   * - If it already existed, it will not lose any data.
487
   * - If it gets created, it will be empty.
488
   *
489
   * @param string $name
490
   *   The name of the collection.
491
   *
492
   * @return \MongoDB\Collection
493
   *   The chosen collection, guaranteed to exist.
494
   *
495
   * @throws \MongoDB\Exception\InvalidArgumentException
496
   * @throws \MongoDB\Exception\UnsupportedException
497
   * @throws \MongoDB\Driver\Exception\RuntimeException
498
   */
499
  public function ensureCollection(string $name): Collection {
500
    $collection = $this->database
501
      ->selectCollection($name);
502
503
    $info = current(
504
      iterator_to_array(
505
        $this->database->listCollections(['filter' => ['name' => $name]])
506
      )
507
    );
508
    // If the collection doesn't exist, create it, ensuring later operations are
509
    // actually run after the server writes:
510
    // https://docs.mongodb.com/manual/reference/write-concern/#acknowledgment-behavior
511
    if ($info === FALSE) {
512
      $res = $collection->insertOne(
513
        [
514
          '_id' => 'dummy',
515
          ['writeConcern' => ['w' => WriteConcern::MAJORITY, 'j' => TRUE]],
516
        ]
517
      );
518
      // With these options, all writes should be acknowledged.
519
      if (!$res->isAcknowledged()) {
520
        throw new RuntimeException("Failed inserting document during ensureCollection");
521
      }
522
      $collection->deleteMany([]);
523
    }
524
525
    return $collection;
526
  }
527
528
  /**
529
   * Ensure indexes are set on the collections and tracker collection is capped.
530
   *
531
   * First index is on <line, timestamp> instead of <function, line, timestamp>,
532
   * because we write to this collection a lot, and the smaller index on two
533
   * numbers should be much faster to create than one with a string included.
534
   */
535
  public function ensureSchema(): void {
536
    $trackerCollection = $this->ensureCappedCollection(static::TRACKER_COLLECTION,
537
      $this->requests * 1024);
538
    $indexes = [
539
      [
540
        'name' => 'tracker-request',
541
        'key' => ['request_id' => 1],
542
      ],
543
    ];
544
    $trackerCollection->createIndexes($indexes);
545
546
    $indexes = [
547
      // Index for adding/updating increments.
548
      [
549
        'name' => 'for-increments',
550
        'key' => ['line' => 1, 'changed' => -1],
551
      ],
552
553
      // Index for overview page without filters.
554
      [
555
        'name' => 'overview-no-filters',
556
        'key' => ['changed' => -1],
557
      ],
558
559
      // Index for overview page filtering by type.
560
      [
561
        'name' => 'overview-by-type',
562
        'key' => ['type' => 1, 'changed' => -1],
563
      ],
564
565
      // Index for overview page filtering by severity.
566
      [
567
        'name' => 'overview-by-severity',
568
        'key' => ['severity' => 1, 'changed' => -1],
569
      ],
570
571
      // Index for overview page filtering by type and severity.
572
      [
573
        'name' => 'overview-by-both',
574
        'key' => ['type' => 1, 'severity' => 1, 'changed' => -1],
575
      ],
576
    ];
577
578
    $this->templateCollection()->createIndexes($indexes);
579
  }
580
581
  /**
582
   * Return a collection, given its template id.
583
   *
584
   * @param string $templateId
585
   *   The string representation of a template \MongoId.
586
   *
587
   * @return \MongoDB\Collection
588
   *   A collection object for the specified template id.
589
   */
590
  public function eventCollection($templateId): Collection {
591
    $name = static::EVENT_COLLECTION_PREFIX . $templateId;
592
    if (!preg_match('/' . static::EVENT_COLLECTIONS_PATTERN . '/', $name)) {
593
      throw new InvalidArgumentException(
594
        (string) new FormattableMarkup(
595
          'Invalid watchdog template id `@id`.',
596
          ['@id' => $name]
597
        )
598
      );
599
    }
600
    $collection = $this->database->selectCollection($name);
601
    return $collection;
602
  }
603
604
  /**
605
   * List the event collections.
606
   *
607
   * @return \MongoDB\Model\CollectionInfoIterator
608
   *   The collections with a name matching the event pattern.
609
   */
610
  public function eventCollections(): CollectionInfoIterator {
611
    $options = [
612
      'filter' => [
613
        'name' => ['$regex' => static::EVENT_COLLECTIONS_PATTERN],
614
      ],
615
    ];
616
    $result = $this->database->listCollections($options);
617
    return $result;
618
  }
619
620
  /**
621
   * Return the number of events for a template.
622
   *
623
   * @param \Drupal\mongodb_watchdog\EventTemplate $template
624
   *   A template for which to count events.
625
   *
626
   * @return int
627
   *   The number of matching events.
628
   */
629
  public function eventCount(EventTemplate $template): int {
630
    return $this->eventCollection($template->_id)
631
      ->countDocuments();
632
  }
633
634
  /**
635
   * Return the events having occurred during a given request.
636
   *
637
   * @param string $requestId
638
   *   The request unique_id.
639
   * @param int $skip
640
   *   The number of events to skip in the result.
641
   * @param int $limit
642
   *   The maximum number of events to return.
643
   *
644
   * @return array<int,array{0:\Drupal\mongodb_watchdog\EventTemplate,1:\Drupal\mongodb_watchdog\Event}>
645
   *   An array of [template, event] arrays, ordered by occurrence order.
646
   */
647
  public function requestEvents($requestId, $skip = 0, $limit = 0): array {
648
    $templates = $this->requestTemplates($requestId);
649
    $selector = [
650
      'requestTracking_id' => $requestId,
651
      'requestTracking_sequence' => [
652
        '$gte' => $skip,
653
        '$lt' => $skip + $limit,
654
      ],
655
    ];
656
    $events = [];
657
    $options = [
658
      'typeMap' => [
659
        'array' => 'array',
660
        'document' => 'array',
661
        'root' => '\Drupal\mongodb_watchdog\Event',
662
      ],
663
    ];
664
665
    /** @var string $templateId */
666
    /** @var \Drupal\mongodb_watchdog\EventTemplate $template */
667
    foreach ($templates as $templateId => $template) {
668
      $eventCollection = $this->eventCollection($templateId);
669
      $cursor = $eventCollection->find($selector, $options);
670
      /** @var \Drupal\mongodb_watchdog\Event $event */
671
      foreach ($cursor as $event) {
672
        $events[$event->requestTracking_sequence] = [
673
          $template,
674
          $event,
675
        ];
676
      }
677
    }
678
679
    ksort($events);
680
    return $events;
681
  }
682
683
  /**
684
   * Count events matching a request unique_id.
685
   *
686
   * XXX This implementation may be very inefficient in case of a request gone
687
   * bad generating non-templated varying messages: #requests is O(#templates).
688
   *
689
   * @param string $requestId
690
   *   The unique_id of the request.
691
   *
692
   * @return int
693
   *   The number of events matching the unique_id.
694
   */
695
  public function requestEventsCount($requestId): int {
696
    if (empty($requestId)) {
697
      return 0;
698
    }
699
700
    $templates = $this->requestTemplates($requestId);
701
    $count = 0;
702
    foreach ($templates as $template) {
703
      $eventCollection = $this->eventCollection($template->_id);
704
      $selector = [
705
        'requestTracking_id' => $requestId,
706
      ];
707
      $count += $eventCollection->countDocuments($selector);
708
    }
709
710
    return $count;
711
  }
712
713
  /**
714
   * Setter for limit.
715
   *
716
   * @param int $limit
717
   *   The limit value.
718
   */
719
  public function setLimit(int $limit): void {
720
    $this->limit = $limit;
721
  }
722
723
  /**
724
   * Return the number of event templates.
725
   *
726
   * @throws \ReflectionException
727
   */
728
  public function templatesCount(): int {
729
    return $this->templateCollection()
730
      ->countDocuments();
731
  }
732
733
  /**
734
   * Return an array of templates uses during a given request.
735
   *
736
   * @param string $unsafeRequestId
737
   *   A request "unique_id".
738
   *
739
   * @return \Drupal\mongodb_watchdog\EventTemplate[]
740
   *   An array of EventTemplate instances.
741
   *
742
   * @SuppressWarnings(PHPMD.UnusedFormalParameter)
743
   * @see https://github.com/phpmd/phpmd/issues/561
744
   */
745
  public function requestTemplates($unsafeRequestId): array {
746
    $selector = [
747
      // Variable quoted to avoid passing an object and risk a NoSQL injection.
748
      'requestId' => "$unsafeRequestId",
749
    ];
750
751
    $cursor = $this
752
      ->trackerCollection()
753
      ->find($selector, static::LEGACY_TYPE_MAP + [
754
        'projection' => [
755
          '_id' => 0,
756
          'templateId' => 1,
757
        ],
758
      ]);
759
    $templateIds = [];
760
    foreach ($cursor as $request) {
761
      $templateIds[] = $request['templateId'];
762
    }
763
    if (empty($templateIds)) {
764
      return [];
765
    }
766
767
    $selector = ['_id' => ['$in' => $templateIds]];
768
    $options = [
769
      'typeMap' => [
770
        'array' => 'array',
771
        'document' => 'array',
772
        'root' => '\Drupal\mongodb_watchdog\EventTemplate',
773
      ],
774
    ];
775
    $templates = [];
776
    $cursor = $this->templateCollection()->find($selector, $options);
777
    /** @var \Drupal\mongodb_watchdog\EventTemplate $template */
778
    foreach ($cursor as $template) {
779
      $templates[$template->_id] = $template;
780
    }
781
782
    return $templates;
783
  }
784
785
  /**
786
   * Return the request events tracker collection.
787
   *
788
   * @return \MongoDB\Collection
789
   *   The collection.
790
   */
791
  public function trackerCollection(): Collection {
792
    return $this->database->selectCollection(static::TRACKER_COLLECTION);
793
  }
794
795
  /**
796
   * Return the event templates collection.
797
   *
798
   * @return \MongoDB\Collection
799
   *   The collection.
800
   */
801
  public function templateCollection(): Collection {
802
    return $this->database->selectCollection(static::TEMPLATE_COLLECTION);
803
  }
804
805
  /**
806
   * Return templates matching type and level criteria.
807
   *
808
   * @param string[] $types
809
   *   An array of EventTemplate types. May be a hash.
810
   * @param string[]|int[] $levels
811
   *   An array of severity levels.
812
   * @param int $skip
813
   *   The number of templates to skip before the first one displayed.
814
   * @param int $limit
815
   *   The maximum number of templates to return.
816
   *
817
   * @return \MongoDB\Driver\Cursor
818
   *   A query result for the templates.
819
   */
820
  public function templates(
821
    array $types = [],
822
    array $levels = [],
823
    $skip = 0,
824
    $limit = 0
825
  ): Cursor {
826
    $selector = [];
827
    if (!empty($types)) {
828
      $selector['type'] = ['$in' => array_values($types)];
829
    }
830
    if (!empty($levels) && count($levels) !== count(RfcLogLevel::getLevels())) {
831
      // Severity levels come back from the session as strings, not integers.
832
      $selector['severity'] = [
833
        '$in' => array_values(array_map('intval', $levels)),
834
      ];
835
    }
836
    $options = [
837
      'sort' => [
838
        'count' => -1,
839
        'changed' => -1,
840
      ],
841
      'typeMap' => [
842
        'array' => 'array',
843
        'document' => 'array',
844
        'root' => '\Drupal\mongodb_watchdog\EventTemplate',
845
      ],
846
    ];
847
    if ($skip) {
848
      $options['skip'] = $skip;
849
    }
850
    if ($limit) {
851
      $options['limit'] = $limit;
852
    }
853
854
    $cursor = $this->templateCollection()->find($selector, $options);
855
    return $cursor;
856
  }
857
858
  /**
859
   * Return the template types actually present in storage.
860
   *
861
   * @return string[]
862
   *   An array of distinct EventTemplate types.
863
   */
864
  public function templateTypes(): array {
865
    $ret = $this->templateCollection()->distinct('type');
866
    return $ret;
867
  }
868
869
}
870