Passed
Pull Request — 8.x-2.x (#71)
by Frédéric G.
05:50
created

Logger::templates()   B

Complexity

Conditions 6
Paths 16

Size

Total Lines 36
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 6
eloc 20
c 2
b 0
f 1
nc 16
nop 4
dl 0
loc 36
rs 8.9777
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
  const CONFIG_NAME = 'mongodb_watchdog.settings';
39
40
  // The individual configuration keys.
41
  const CONFIG_ITEMS = 'items';
42
43
  const CONFIG_REQUESTS = 'requests';
44
45
  const CONFIG_LIMIT = 'limit';
46
47
  const CONFIG_ITEMS_PER_PAGE = 'items_per_page';
48
49
  const CONFIG_REQUEST_TRACKING = 'request_tracking';
50
51
  // The logger database alias.
52
  const DB_LOGGER = 'logger';
53
54
  // The default channel exposed when using the raw PSR-3 contract.
55
  const DEFAULT_CHANNEL = 'psr-3';
56
57
  const MODULE = 'mongodb_watchdog';
58
59
  // The service for the specific PSR-3 logger for MongoDB.
60
  const SERVICE_LOGGER = 'mongodb.logger';
61
62
  // The service for the Drupal LoggerChannel for this module, logging to all
63
  // active loggers.
64
  const SERVICE_CHANNEL = 'logger.channel.mongodb_watchdog';
65
66
  // The service for hook_requirements().
67
  const SERVICE_REQUIREMENTS = 'mongodb.watchdog_requirements';
68
69
  const SERVICE_SANITY_CHECK = 'mongodb.watchdog.sanity_check';
70
71
  const TRACKER_COLLECTION = 'watchdog_tracker';
72
73
  const TEMPLATE_COLLECTION = 'watchdog';
74
75
  const EVENT_COLLECTION_PREFIX = 'watchdog_event_';
76
77
  const EVENT_COLLECTIONS_PATTERN = '^watchdog_event_[[:xdigit:]]{32}$';
78
79
  const LEGACY_TYPE_MAP = [
80
    'typeMap' => [
81
      'array' => 'array',
82
      'document' => 'array',
83
      'root' => 'array',
84
    ],
85
  ];
86
87
  /**
88
   * Map of PSR3 log constants to RFC 5424 log constants.
89
   *
90
   * @var array
91
   *
92
   * @see \Drupal\Core\Logger\LoggerChannel
93
   * @see \Drupal\mongodb_watchdog\Logger::log()
94
   */
95
  protected $rfc5424levels = [
96
    LogLevel::EMERGENCY => RfcLogLevel::EMERGENCY,
97
    LogLevel::ALERT => RfcLogLevel::ALERT,
98
    LogLevel::CRITICAL => RfcLogLevel::CRITICAL,
99
    LogLevel::ERROR => RfcLogLevel::ERROR,
100
    LogLevel::WARNING => RfcLogLevel::WARNING,
101
    LogLevel::NOTICE => RfcLogLevel::NOTICE,
102
    LogLevel::INFO => RfcLogLevel::INFO,
103
    LogLevel::DEBUG => RfcLogLevel::DEBUG,
104
  ];
105
106
  /**
107
   * The logger storage.
108
   *
109
   * @var \MongoDB\Database
110
   */
111
  protected $database;
112
113
  /**
114
   * The limit for the capped event collections.
115
   *
116
   * @var int
117
   */
118
  protected $items;
119
120
  /**
121
   * The minimum logging level.
122
   *
123
   * @var int
124
   *
125
   * @see https://drupal.org/node/1355808
126
   */
127
  protected $limit = RfcLogLevel::DEBUG;
128
129
  /**
130
   * The messenger service.
131
   *
132
   * @var \Drupal\Core\Messenger\MessengerInterface
133
   */
134
  protected $messenger;
135
136
  /**
137
   * The message's placeholders parser.
138
   *
139
   * @var \Drupal\Core\Logger\LogMessageParserInterface
140
   */
141
  protected $parser;
142
143
  /**
144
   * The "requests" setting.
145
   *
146
   * @var int
147
   */
148
  protected $requests;
149
150
  /**
151
   * The request_stack service.
152
   *
153
   * @var \Symfony\Component\HttpFoundation\RequestStack
154
   */
155
  protected $requestStack;
156
157
  /**
158
   * Is request tracking enabled ?
159
   *
160
   * @var bool
161
   */
162
  protected $requestTracking;
163
164
  /**
165
   * A sequence number for log events during a request.
166
   *
167
   * @var int
168
   */
169
  protected $sequence = 0;
170
171
  /**
172
   * An array of templates already used in this request.
173
   *
174
   * Used only with request tracking enabled.
175
   *
176
   * @var string[]
177
   */
178
  protected $templates = [];
179
180
  /**
181
   * The datetime.time service.
182
   *
183
   * @var \Drupal\Component\Datetime\TimeInterface
184
   */
185
  protected $time;
186
187
  /**
188
   * Logger constructor.
189
   *
190
   * @param \MongoDB\Database $database
191
   *   The database object.
192
   * @param \Drupal\Core\Logger\LogMessageParserInterface $parser
193
   *   The parser to use when extracting message variables.
194
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
195
   *   The core config_factory service.
196
   * @param \Symfony\Component\HttpFoundation\RequestStack $stack
197
   *   The core request_stack service.
198
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
199
   *   The messenger service.
200
   * @param \Drupal\Component\Datetime\TimeInterface $time
201
   *   The datetime.time service.
202
   */
203
  public function __construct(
204
    Database $database,
205
    LogMessageParserInterface $parser,
206
    ConfigFactoryInterface $configFactory,
207
    RequestStack $stack,
208
    MessengerInterface $messenger,
209
    TimeInterface $time
210
  ) {
211
    $this->database = $database;
212
    $this->messenger = $messenger;
213
    $this->parser = $parser;
214
    $this->requestStack = $stack;
215
    $this->time = $time;
216
217
    $config = $configFactory->get(static::CONFIG_NAME);
218
    // During install, a logger will be invoked 3 times, the first 2 without any
219
    // configuration information, so hard-coded defaults are needed on all
220
    // config keys.
221
    $this->setLimit($config->get(static::CONFIG_LIMIT) ?? RfcLogLevel::DEBUG);
222
    // Do NOT use 1E4 / 1E5: these are doubles, but config is typed to integers.
223
    $this->items = $config->get(static::CONFIG_ITEMS) ?? 10000;
224
    $this->requests = $config->get(static::CONFIG_REQUESTS) ?? 100000;
225
    $this->requestTracking = $config->get(static::CONFIG_REQUEST_TRACKING) ?? FALSE;
226
  }
227
228
  /**
229
   * Fill in the log_entry function, file, and line.
230
   *
231
   * @param array $entry
232
   *   An event information to be logger.
233
   * @param array $backtrace
234
   *   A call stack.
235
   *
236
   * @throws \ReflectionException
237
   */
238
  protected function enhanceLogEntry(array &$entry, array $backtrace): void {
239
    // Create list of functions to ignore in backtrace.
240
    static $ignored = [
241
      'call_user_func_array' => 1,
242
      '_drupal_log_error' => 1,
243
      '_drupal_error_handler' => 1,
244
      '_drupal_error_handler_real' => 1,
245
      'Drupal\mongodb_watchdog\Logger::log' => 1,
246
      'Drupal\Core\Logger\LoggerChannel::log' => 1,
247
      'Drupal\Core\Logger\LoggerChannel::alert' => 1,
248
      'Drupal\Core\Logger\LoggerChannel::critical' => 1,
249
      'Drupal\Core\Logger\LoggerChannel::debug' => 1,
250
      'Drupal\Core\Logger\LoggerChannel::emergency' => 1,
251
      'Drupal\Core\Logger\LoggerChannel::error' => 1,
252
      'Drupal\Core\Logger\LoggerChannel::info' => 1,
253
      'Drupal\Core\Logger\LoggerChannel::notice' => 1,
254
      'Drupal\Core\Logger\LoggerChannel::warning' => 1,
255
      'Psr\Log\AbstractLogger::alert' => 1,
256
      'Psr\Log\AbstractLogger::critical' => 1,
257
      'Psr\Log\AbstractLogger::debug' => 1,
258
      'Psr\Log\AbstractLogger::emergency' => 1,
259
      'Psr\Log\AbstractLogger::error' => 1,
260
      'Psr\Log\AbstractLogger::info' => 1,
261
      'Psr\Log\AbstractLogger::notice' => 1,
262
      'Psr\Log\AbstractLogger::warning' => 1,
263
    ];
264
265
    foreach ($backtrace as $bt) {
266
      if (isset($bt['function'])) {
267
        $function = empty($bt['class']) ? $bt['function'] : $bt['class'] . '::' . $bt['function'];
268
        if (empty($ignored[$function])) {
269
          $entry['%function'] = $function;
270
          /* Some part of the stack, like the line or file info, may be missing.
271
           * From research in 2021-01, this only appears to happen on PHP < 7.0.
272
           *
273
           * @see http://goo.gl/8s75df
274
           *
275
           * No need to fetch the line using reflection: it would be redundant
276
           * with the name of the function.
277
           */
278
          $entry['%line'] = $bt['line'] ?? NULL;
279
          $file = $bt['file'] ?? '';
280
          if (empty($file) && is_callable($function)) {
281
            $reflectionObj = empty($bt['class'])
282
              ? new \ReflectionFunction($function)
283
              : new \ReflectionMethod($function);
284
            $file = $reflectionObj->getFileName();
285
          }
286
287
          $entry['%file'] = $file;
288
          break;
289
        }
290
        elseif ($bt['function'] == '_drupal_exception_handler') {
291
          $e = $bt['args'][0];
292
          $this->enhanceLogEntry($entry, $e->getTrace());
293
        }
294
      }
295
    }
296
  }
297
298
  /**
299
   * {@inheritdoc}
300
   *
301
   * @see https://httpd.apache.org/docs/2.4/en/mod/mod_unique_id.html
302
   */
303
  public function log($level, $template, array $context = []): void {
304
    // PSR-3 LoggerInterface documents level as "mixed", while the RFC itself
305
    // in §1.1 implies implementations may know about non-standard levels. In
306
    // the case of Drupal implementations, this includes the 8 RFC5424 levels.
307
    if (is_string($level)) {
308
      $level = $this->rfc5424levels[$level];
309
    }
310
311
    if ($level > $this->limit) {
312
      return;
313
    }
314
315
    // Convert PSR3-style messages to SafeMarkup::format() style, so they can be
316
    // translated at runtime too.
317
    $placeholders = $this->parser->parseMessagePlaceholders($template,
318
      $context);
319
320
    // If code location information is all present, as for errors/exceptions,
321
    // then use it to build the message template id.
322
    $type = $context['channel'] ?? static::DEFAULT_CHANNEL;
323
    $location = [
324
      '%type' => 1,
325
      '@message' => 1,
326
      '%function' => 1,
327
      '%file' => 1,
328
      '%line' => 1,
329
    ];
330
    if (!empty(array_diff_key($location, $placeholders))) {
331
      $this->enhanceLogEntry(
332
        $placeholders,
333
        debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10)
334
      );
335
    }
336
    $file = $placeholders['%file'];
337
    $line = $placeholders['%line'];
338
    $function = $placeholders['%function'];
339
    $key = implode(":", [$type, $level, $file, $line, $function]);
340
    $templateId = md5($key);
341
342
    $selector = ['_id' => $templateId];
343
    $update = [
344
      '$inc' => ['count' => 1],
345
      '$set' => [
346
        '_id' => $templateId,
347
        'message' => $template,
348
        'severity' => $level,
349
        'changed' => $this->time->getCurrentTime(),
350
        'type' => mb_substr($type, 0, 64),
351
      ],
352
    ];
353
    $options = ['upsert' => TRUE];
354
    $templateResult = $this->database
355
      ->selectCollection(static::TEMPLATE_COLLECTION)
356
      ->updateOne($selector, $update, $options);
357
358
    // Only insert each template once per request.
359
    if ($this->requestTracking && !isset($this->templates[$templateId])) {
360
      $requestId = $this->requestStack
361
        ->getCurrentRequest()
362
        ->server
363
        ->get('UNIQUE_ID');
364
365
      $this->templates[$templateId] = 1;
366
      $track = [
367
        'requestId' => $requestId,
368
        'templateId' => $templateId,
369
      ];
370
      $this->trackerCollection()->insertOne($track);
371
    }
372
    else {
373
      // 24-byte format like mod_unique_id values.
374
      $requestId = '@@Not-a-valid-request@@';
375
    }
376
377
    $eventCollection = $this->eventCollection($templateId);
378
    if ($templateResult->getUpsertedCount()) {
379
      // Capped collections are actually size-based, not count-based, so "items"
380
      // is only a maximum, assuming event documents weigh 1kB, but the actual
381
      // number of items stored may be lower if items are heavier.
382
      // We do not use 'autoindexid' for greater speed, because:
383
      // - it does not work on replica sets,
384
      // - it is deprecated in MongoDB 3.2 and going away in 3.4.
385
      $options = [
386
        'capped' => TRUE,
387
        'size' => $this->items * 1024,
388
        'max' => $this->items,
389
      ];
390
      $this->database->createCollection($eventCollection->getCollectionName(),
391
        $options);
392
393
      // Do not create this index by default, as its cost is useless if request
394
      // tracking is not enabled.
395
      if ($this->requestTracking) {
396
        $key = ['requestTracking_id' => 1];
397
        $options = ['name' => 'admin-by-request'];
398
        $eventCollection->createIndex($key, $options);
399
      }
400
    }
401
402
    foreach ($placeholders as &$placeholder) {
403
      if ($placeholder instanceof MarkupInterface) {
404
        $placeholder = Xss::filterAdmin($placeholder);
405
      }
406
    }
407
    $event = [
408
      'hostname' => mb_substr($context['ip'] ?? '', 0, 128),
409
      'link' => $context['link'] ?? NULL,
410
      'location' => $context['request_uri'] ?? NULL,
411
      'referer' => $context['referer'] ?? NULL,
412
      'timestamp' => $context['timestamp'] ?? $this->time->getCurrentTime(),
413
      'user' => ['uid' => $context['uid'] ?? 0],
414
      'variables' => $placeholders,
415
    ];
416
    if ($this->requestTracking) {
417
      // Fetch the current request on each event to support subrequest nesting.
418
      $event['requestTracking_id'] = $requestId;
419
      $event['requestTracking_sequence'] = $this->sequence;
420
      $this->sequence++;
421
    }
422
    $eventCollection->insertOne($event);
423
  }
424
425
  /**
426
   * Ensure a collection is capped with the proper size.
427
   *
428
   * @param string $name
429
   *   The collection name.
430
   * @param int $size
431
   *   The collection size cap.
432
   *
433
   * @return \MongoDB\Collection
434
   *   The collection, usable for additional commands like index creation.
435
   *
436
   * @throws \MongoDB\Exception\InvalidArgumentException
437
   * @throws \MongoDB\Exception\UnsupportedException
438
   * @throws \MongoDB\Exception\UnexpectedValueException
439
   * @throws \MongoDB\Driver\Exception\RuntimeException
440
   *
441
   * @see https://docs.mongodb.com/manual/reference/command/convertToCapped
442
   *
443
   * Note that MongoDB 4.2 still misses a proper exists() command, which is the
444
   * reason for the weird try/catch logic.
445
   *
446
   * @see https://jira.mongodb.org/browse/SERVER-1938
447
   * @todo support sharded clusters: convertToCapped does not support them.
448
   */
449
  public function ensureCappedCollection(string $name, int $size): Collection {
450
    if ($size === 0) {
451
      $this->messenger->addError($this->t('Abnormal size 0 ensuring capped collection, defaulting.'));
452
      $size = 100000;
453
    }
454
455
    $collection = $this->ensureCollection($name);
456
    $stats = $this->database
457
      ->command(['collStats' => $name], static::LEGACY_TYPE_MAP)
458
      ->toArray()[0];
459
    if (!empty($stats['capped'])) {
460
      return $collection;
461
    }
462
463
    $command = [
464
      'convertToCapped' => $name,
465
      'size' => $size,
466
    ];
467
    $this->database->command($command);
468
    $this->messenger->addStatus(
469
      $this->t(
470
        '@name converted to capped collection size @size.',
471
        [
472
          '@name' => $name,
473
          '@size' => $size,
474
        ]
475
      )
476
    );
477
    return $collection;
478
  }
479
480
  /**
481
   * Ensure a collection exists in the logger database.
482
   *
483
   * - If it already existed, it will not lose any data.
484
   * - If it gets created, it will be empty.
485
   *
486
   * @param string $name
487
   *   The name of the collection.
488
   *
489
   * @return \MongoDB\Collection
490
   *   The chosen collection, guaranteed to exist.
491
   *
492
   * @throws \MongoDB\Exception\InvalidArgumentException
493
   * @throws \MongoDB\Exception\UnsupportedException
494
   * @throws \MongoDB\Driver\Exception\RuntimeException
495
   */
496
  public function ensureCollection(string $name): Collection {
497
    $collection = $this->database
498
      ->selectCollection($name);
499
500
    $info = current(
501
      iterator_to_array(
502
        $this->database->listCollections(['filter' => ['name' => $name]])
503
      )
504
    );
505
    // If the collection doesn't exist, create it, ensuring later operations are
506
    // actually run after the server writes:
507
    // https://docs.mongodb.com/manual/reference/write-concern/#acknowledgment-behavior
508
    if ($info === FALSE) {
509
      $res = $collection->insertOne(
510
        [
511
          '_id' => 'dummy',
512
          ['writeConcern' => ['w' => WriteConcern::MAJORITY, 'j' => TRUE]],
513
        ]
514
      );
515
      // With these options, all writes should be acknowledged.
516
      if (!$res->isAcknowledged()) {
517
        throw new RuntimeException("Failed inserting document during ensureCollection");
518
      }
519
      $collection->deleteMany([]);
520
    }
521
522
    return $collection;
523
  }
524
525
  /**
526
   * Ensure indexes are set on the collections and tracker collection is capped.
527
   *
528
   * First index is on <line, timestamp> instead of <function, line, timestamp>,
529
   * because we write to this collection a lot, and the smaller index on two
530
   * numbers should be much faster to create than one with a string included.
531
   */
532
  public function ensureSchema(): void {
533
    $trackerCollection = $this->ensureCappedCollection(static::TRACKER_COLLECTION,
534
      $this->requests * 1024);
535
    $indexes = [
536
      [
537
        'name' => 'tracker-request',
538
        'key' => ['request_id' => 1],
539
      ],
540
    ];
541
    $trackerCollection->createIndexes($indexes);
542
543
    $indexes = [
544
      // Index for adding/updating increments.
545
      [
546
        'name' => 'for-increments',
547
        'key' => ['line' => 1, 'changed' => -1],
548
      ],
549
550
      // Index for overview page without filters.
551
      [
552
        'name' => 'overview-no-filters',
553
        'key' => ['changed' => -1],
554
      ],
555
556
      // Index for overview page filtering by type.
557
      [
558
        'name' => 'overview-by-type',
559
        'key' => ['type' => 1, 'changed' => -1],
560
      ],
561
562
      // Index for overview page filtering by severity.
563
      [
564
        'name' => 'overview-by-severity',
565
        'key' => ['severity' => 1, 'changed' => -1],
566
      ],
567
568
      // Index for overview page filtering by type and severity.
569
      [
570
        'name' => 'overview-by-both',
571
        'key' => ['type' => 1, 'severity' => 1, 'changed' => -1],
572
      ],
573
    ];
574
575
    $this->templateCollection()->createIndexes($indexes);
576
  }
577
578
  /**
579
   * Return a collection, given its template id.
580
   *
581
   * @param string $templateId
582
   *   The string representation of a template \MongoId.
583
   *
584
   * @return \MongoDB\Collection
585
   *   A collection object for the specified template id.
586
   */
587
  public function eventCollection($templateId): Collection {
588
    $name = static::EVENT_COLLECTION_PREFIX . $templateId;
589
    if (!preg_match('/' . static::EVENT_COLLECTIONS_PATTERN . '/', $name)) {
590
      throw new InvalidArgumentException(
591
        new FormattableMarkup(
592
          'Invalid watchdog template id `@id`.',
593
          [
594
            '@id' => $name,
595
          ]
596
        )
597
      );
598
    }
599
    $collection = $this->database->selectCollection($name);
600
    return $collection;
601
  }
602
603
  /**
604
   * List the event collections.
605
   *
606
   * @return \MongoDB\Model\CollectionInfoIterator
607
   *   The collections with a name matching the event pattern.
608
   */
609
  public function eventCollections(): CollectionInfoIterator {
610
    $options = [
611
      'filter' => [
612
        'name' => ['$regex' => static::EVENT_COLLECTIONS_PATTERN],
613
      ],
614
    ];
615
    $result = $this->database->listCollections($options);
616
    return $result;
617
  }
618
619
  /**
620
   * Return the number of events for a template.
621
   *
622
   * @param \Drupal\mongodb_watchdog\EventTemplate $template
623
   *   A template for which to count events.
624
   *
625
   * @return int
626
   *   The number of matching events.
627
   */
628
  public function eventCount(EventTemplate $template): int {
629
    return $this->eventCollection($template->_id)
630
      ->countDocuments();
631
  }
632
633
  /**
634
   * Return the events having occurred during a given request.
635
   *
636
   * @param string $requestId
637
   *   The request unique_id.
638
   * @param int $skip
639
   *   The number of events to skip in the result.
640
   * @param int $limit
641
   *   The maximum number of events to return.
642
   *
643
   * @return \Drupal\mongodb_watchdog\EventTemplate|\Drupal\mongodb_watchdog\Event[]
644
   *   An array of [template, event] arrays, ordered by occurrence order.
645
   */
646
  public function requestEvents($requestId, $skip = 0, $limit = 0): array {
647
    $templates = $this->requestTemplates($requestId);
648
    $selector = [
649
      'requestTracking_id' => $requestId,
650
      'requestTracking_sequence' => [
651
        '$gte' => $skip,
652
        '$lt' => $skip + $limit,
653
      ],
654
    ];
655
    $events = [];
656
    $options = [
657
      'typeMap' => [
658
        'array' => 'array',
659
        'document' => 'array',
660
        'root' => '\Drupal\mongodb_watchdog\Event',
661
      ],
662
    ];
663
664
    /** @var string $templateId */
665
    /** @var \Drupal\mongodb_watchdog\EventTemplate $template */
666
    foreach ($templates as $templateId => $template) {
667
      $eventCollection = $this->eventCollection($templateId);
668
      $cursor = $eventCollection->find($selector, $options);
669
      /** @var \Drupal\mongodb_watchdog\Event $event */
670
      foreach ($cursor as $event) {
671
        $events[$event->requestTracking_sequence] = [
672
          $template,
673
          $event,
674
        ];
675
      }
676
    }
677
678
    ksort($events);
679
    return $events;
680
  }
681
682
  /**
683
   * Count events matching a request unique_id.
684
   *
685
   * XXX This implementation may be very inefficient in case of a request gone
686
   * bad generating non-templated varying messages: #requests is O(#templates).
687
   *
688
   * @param string $requestId
689
   *   The unique_id of the request.
690
   *
691
   * @return int
692
   *   The number of events matching the unique_id.
693
   */
694
  public function requestEventsCount($requestId): int {
695
    if (empty($requestId)) {
696
      return 0;
697
    }
698
699
    $templates = $this->requestTemplates($requestId);
700
    $count = 0;
701
    foreach ($templates as $template) {
702
      $eventCollection = $this->eventCollection($template->_id);
703
      $selector = [
704
        'requestTracking_id' => $requestId,
705
      ];
706
      $count += $eventCollection->countDocuments($selector);
707
    }
708
709
    return $count;
710
  }
711
712
  /**
713
   * Setter for limit.
714
   *
715
   * @param int $limit
716
   *   The limit value.
717
   */
718
  public function setLimit(int $limit): void {
719
    $this->limit = $limit;
720
  }
721
722
  /**
723
   * Return the number of event templates.
724
   *
725
   * @throws \ReflectionException
726
   */
727
  public function templatesCount(): int {
728
    return $this->templateCollection()
729
      ->countDocuments();
730
  }
731
732
  /**
733
   * Return an array of templates uses during a given request.
734
   *
735
   * @param string $unsafeRequestId
736
   *   A request "unique_id".
737
   *
738
   * @return \Drupal\mongodb_watchdog\EventTemplate[]
739
   *   An array of EventTemplate instances.
740
   *
741
   * @SuppressWarnings(PHPMD.UnusedFormalParameter)
742
   * @see https://github.com/phpmd/phpmd/issues/561
743
   */
744
  public function requestTemplates($unsafeRequestId): array {
745
    $selector = [
746
      // Variable quoted to avoid passing an object and risk a NoSQL injection.
747
      'requestId' => "${unsafeRequestId}",
748
    ];
749
750
    $cursor = $this
751
      ->trackerCollection()
752
      ->find($selector, static::LEGACY_TYPE_MAP + [
753
        'projection' => [
754
          '_id' => 0,
755
          'template_id' => 1,
756
        ],
757
      ]);
758
    $templateIds = [];
759
    foreach ($cursor as $request) {
760
      $templateIds[] = $request['template_id'];
761
    }
762
    if (empty($templateIds)) {
763
      return [];
764
    }
765
766
    $selector = ['_id' => ['$in' => $templateIds]];
767
    $options = [
768
      'typeMap' => [
769
        'array' => 'array',
770
        'document' => 'array',
771
        'root' => '\Drupal\mongodb_watchdog\EventTemplate',
772
      ],
773
    ];
774
    $templates = [];
775
    $cursor = $this->templateCollection()->find($selector, $options);
776
    /** @var \Drupal\mongodb_watchdog\EventTemplate $template */
777
    foreach ($cursor as $template) {
778
      $templates[$template->_id] = $template;
779
    }
780
781
    return $templates;
782
  }
783
784
  /**
785
   * Return the request events tracker collection.
786
   *
787
   * @return \MongoDB\Collection
788
   *   The collection.
789
   */
790
  public function trackerCollection(): Collection {
791
    return $this->database->selectCollection(static::TRACKER_COLLECTION);
792
  }
793
794
  /**
795
   * Return the event templates collection.
796
   *
797
   * @return \MongoDB\Collection
798
   *   The collection.
799
   */
800
  public function templateCollection(): Collection {
801
    return $this->database->selectCollection(static::TEMPLATE_COLLECTION);
802
  }
803
804
  /**
805
   * Return templates matching type and level criteria.
806
   *
807
   * @param string[] $types
808
   *   An array of EventTemplate types. May be a hash.
809
   * @param string[]|int[] $levels
810
   *   An array of severity levels.
811
   * @param int $skip
812
   *   The number of templates to skip before the first one displayed.
813
   * @param int $limit
814
   *   The maximum number of templates to return.
815
   *
816
   * @return \MongoDB\Driver\Cursor
817
   *   A query result for the templates.
818
   */
819
  public function templates(
820
    array $types = [],
821
    array $levels = [],
822
    $skip = 0,
823
    $limit = 0
824
  ): Cursor {
825
    $selector = [];
826
    if (!empty($types)) {
827
      $selector['type'] = ['$in' => array_values($types)];
828
    }
829
    if (!empty($levels) && count($levels) !== count(RfcLogLevel::getLevels())) {
830
      // Severity levels come back from the session as strings, not integers.
831
      $selector['severity'] = [
832
        '$in' => array_values(array_map('intval', $levels)),
833
      ];
834
    }
835
    $options = [
836
      'sort' => [
837
        'count' => -1,
838
        'changed' => -1,
839
      ],
840
      'typeMap' => [
841
        'array' => 'array',
842
        'document' => 'array',
843
        'root' => '\Drupal\mongodb_watchdog\EventTemplate',
844
      ],
845
    ];
846
    if ($skip) {
847
      $options['skip'] = $skip;
848
    }
849
    if ($limit) {
850
      $options['limit'] = $limit;
851
    }
852
853
    $cursor = $this->templateCollection()->find($selector, $options);
854
    return $cursor;
855
  }
856
857
  /**
858
   * Return the template types actually present in storage.
859
   *
860
   * @return string[]
861
   *   An array of distinct EventTemplate types.
862
   */
863
  public function templateTypes(): array {
864
    $ret = $this->templateCollection()->distinct('type');
865
    return $ret;
866
  }
867
868
}
869