Passed
Push — v1 ( 83a102...fe1757 )
by Andrew
11:33 queued 04:25
created

DataSamples::rateLimited()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 12
rs 10
c 0
b 0
f 0
cc 3
nc 2
nop 0
1
<?php
2
/**
3
 * Webperf plugin for Craft CMS 3.x
4
 *
5
 * Monitor the performance of your webpages through real-world user timing data
6
 *
7
 * @link      https://nystudio107.com
0 ignored issues
show
Coding Style introduced by
The tag in position 1 should be the @copyright tag
Loading history...
8
 * @copyright Copyright (c) 2019 nystudio107
0 ignored issues
show
Coding Style introduced by
@copyright tag must contain a year and the name of the copyright holder
Loading history...
9
 */
0 ignored issues
show
Coding Style introduced by
PHP version not specified
Loading history...
Coding Style introduced by
Missing @category tag in file comment
Loading history...
Coding Style introduced by
Missing @package tag in file comment
Loading history...
Coding Style introduced by
Missing @author tag in file comment
Loading history...
Coding Style introduced by
Missing @license tag in file comment
Loading history...
10
11
namespace nystudio107\webperf\services;
12
13
use nystudio107\webperf\Webperf;
14
use nystudio107\webperf\base\CraftDataSample;
15
use nystudio107\webperf\base\DbDataSampleInterface;
16
use nystudio107\webperf\events\DataSampleEvent;
17
18
use Craft;
0 ignored issues
show
Bug introduced by
The type Craft 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...
19
use craft\base\Component;
0 ignored issues
show
Bug introduced by
The type craft\base\Component 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...
20
use craft\db\Query;
0 ignored issues
show
Bug introduced by
The type craft\db\Query 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...
21
22
/**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
23
 * @author    nystudio107
0 ignored issues
show
Coding Style introduced by
The tag in position 1 should be the @package tag
Loading history...
Coding Style introduced by
Content of the @author tag must be in the form "Display Name <[email protected]>"
Loading history...
Coding Style introduced by
Tag value for @author tag indented incorrectly; expected 2 spaces but found 4
Loading history...
24
 * @package   Webperf
0 ignored issues
show
Coding Style introduced by
Tag value for @package tag indented incorrectly; expected 1 spaces but found 3
Loading history...
25
 * @since     1.0.0
0 ignored issues
show
Coding Style introduced by
The tag in position 3 should be the @author tag
Loading history...
Coding Style introduced by
Tag value for @since tag indented incorrectly; expected 3 spaces but found 5
Loading history...
26
 */
0 ignored issues
show
Coding Style introduced by
Missing @category tag in class comment
Loading history...
Coding Style introduced by
Missing @license tag in class comment
Loading history...
Coding Style introduced by
Missing @link tag in class comment
Loading history...
27
class DataSamples extends Component
28
{
29
    // Constants
30
    // =========================================================================
31
32
    const LAST_DATASAMPLES_TRIM_CACHE_KEY = 'webperf-last-datasamples-trim';
33
34
    const OUTLIER_PAGELOAD_MULTIPLIER = 10;
35
36
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
37
     * @event DataSampleEvent The event that is triggered before the data sample is saved
38
     * You may set [[DataSampleEvent::isValid]] to `false` to prevent the data sample from getting saved.
39
     *
40
     * ```php
41
     * use nystudio107\webperf\services\DataSamples;
42
     * use nystudio107\webperf\events\DataSampleEvent;
43
     *
44
     * Event::on(DataSamples::class,
45
     *     DataSamples::EVENT_BEFORE_SAVE_DATA_SAMPLE,
46
     *     function(DataSampleEvent $event) {
47
     *         // potentially set $event->isValid;
48
     *     }
49
     * );
50
     * ```
51
     */
52
    const EVENT_BEFORE_SAVE_DATA_SAMPLE = 'beforeSaveDataSample';
53
54
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
55
     * @event DataSampleEvent The event that is triggered after the redirect is saved
56
     *
57
     * ```php
58
     * use nystudio107\webperf\services\DataSamples;
59
     * use nystudio107\webperf\events\DataSampleEvent;
60
     *
61
     * Event::on(DataSamples::class,
62
     *     DataSamples::EVENT_AFTER_SAVE_DATA_SAMPLE,
63
     *     function(DataSampleEvent $event) {
64
     *         // the data sample was saved
65
     *     }
66
     * );
67
     * ```
68
     */
69
    const EVENT_AFTER_SAVE_DATA_SAMPLE = 'afterSaveDataSample';
70
71
    // Public Methods
72
    // =========================================================================
73
74
    /**
75
     * Get the total number of data samples optionally limited by siteId
76
     *
77
     * @param int    $siteId
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
78
     * @param string $column
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
79
     *
80
     * @return int|string
81
     */
82
    public function totalSamples(int $siteId, string $column)
83
    {
84
        // Get the total number of data samples
85
        $query = (new Query())
86
            ->from(['{{%webperf_data_samples}}'])
87
            ->where(['not', [$column => null]])
0 ignored issues
show
Coding Style introduced by
Space after closing parenthesis of function call prohibited
Loading history...
88
            ;
89
        if ((int)$siteId !== 0) {
90
            $query->andWhere(['siteId' => $siteId]);
91
        }
92
93
        return $query->count();
94
    }
95
96
    /**
97
     * Get the page title from data samples by URL and optionally siteId
98
     *
99
     * @param string   $url
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
Coding Style introduced by
Expected 1 spaces after parameter type; 3 found
Loading history...
100
     * @param int $siteId
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
Coding Style introduced by
Expected 4 spaces after parameter type; 1 found
Loading history...
101
     *
102
     * @return string
103
     */
104
    public function pageTitle(string $url, int $siteId = 0): string
105
    {
106
        // Get the page title from a URL
107
        $query = (new Query())
108
            ->select(['title'])
109
            ->from(['{{%webperf_data_samples}}'])
110
            ->where([
0 ignored issues
show
Coding Style introduced by
The opening parenthesis of a multi-line function call should be the last content on the line.
Loading history...
111
                'and', ['url' => $url],
112
                ['not', ['title' => '']],
113
            ])
0 ignored issues
show
Coding Style introduced by
Space after closing parenthesis of function call prohibited
Loading history...
Coding Style introduced by
For multi-line function calls, the closing parenthesis should be on a new line.

If a function call spawns multiple lines, the coding standard suggests to move the closing parenthesis to a new line:

someFunctionCall(
    $firstArgument,
    $secondArgument,
    $thirdArgument
); // Closing parenthesis on a new line.
Loading history...
114
        ;
115
        if ((int)$siteId !== 0) {
116
            $query->andWhere(['siteId' => $siteId]);
117
        }
118
        $result = $query->one();
119
        // Decode any emojis in the title
120
        if (!empty($result['title'])) {
121
            $result['title'] = html_entity_decode($result['title'], ENT_NOQUOTES, 'UTF-8');
122
        }
123
124
        return $result['title'] ?? '';
125
    }
126
127
    /**
128
     * Add a data sample to the webperf_data_samples table
129
     *
130
     * @param DbDataSampleInterface $dataSample
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
131
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
132
    public function addDataSample(DbDataSampleInterface $dataSample)
133
    {
134
        // Validate the model before saving it to the db
135
        if ($dataSample->validate() === false) {
136
            Craft::error(
137
                Craft::t(
138
                    'webperf',
139
                    'Error validating data sample: {errors}',
140
                    ['errors' => print_r($dataSample->getErrors(), true)]
141
                ),
142
                __METHOD__
143
            );
144
145
            return;
146
        }
147
        $isNew = true;
148
        if (!empty($dataSample->requestId)) {
0 ignored issues
show
Bug introduced by
Accessing requestId on the interface nystudio107\webperf\base\DbDataSampleInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
149
            // See if a data sample exists with the same requestId already
150
            $testSample = (new Query())
151
                ->from(['{{%webperf_data_samples}}'])
152
                ->where(['requestId' => $dataSample->requestId])
153
                ->one();
154
            // If it exists, update it rather than having duplicates
155
            if (!empty($testSample)) {
156
                $isNew = false;
157
            }
158
        }
159
        // Trigger a 'beforeSaveDataSample' event
160
        $event = new DataSampleEvent([
0 ignored issues
show
Coding Style introduced by
The opening parenthesis of a multi-line function call should be the last content on the line.
Loading history...
161
            'isNew' => $isNew,
162
            'dataSample' => $dataSample,
163
        ]);
0 ignored issues
show
Coding Style introduced by
For multi-line function calls, the closing parenthesis should be on a new line.

If a function call spawns multiple lines, the coding standard suggests to move the closing parenthesis to a new line:

someFunctionCall(
    $firstArgument,
    $secondArgument,
    $thirdArgument
); // Closing parenthesis on a new line.
Loading history...
164
        $this->trigger(self::EVENT_BEFORE_SAVE_DATA_SAMPLE, $event);
165
        if (!$event->isValid) {
166
            return;
167
        }
168
        // Get the validated model attributes and save them to the db
169
        $dataSampleConfig = $dataSample->getAttributes();
170
        $db = Craft::$app->getDb();
171
        if ($isNew) {
172
            Craft::debug('Creating new data sample', __METHOD__);
173
            // Create a new record
174
            try {
175
                $result = $db->createCommand()->insert(
176
                    '{{%webperf_data_samples}}',
177
                    $dataSampleConfig
178
                )->execute();
179
                Craft::debug($result, __METHOD__);
180
            } catch (\Exception $e) {
181
                Craft::error($e->getMessage(), __METHOD__);
182
            }
183
        } else {
184
            Craft::debug('Updating existing data sample', __METHOD__);
185
            // Update the existing record
186
            try {
187
                $result = $db->createCommand()->update(
188
                    '{{%webperf_data_samples}}',
189
                    $dataSampleConfig,
190
                    [
191
                        'requestId' => $dataSample->requestId,
192
                    ]
193
                )->execute();
194
                Craft::debug($result, __METHOD__);
195
            } catch (\Exception $e) {
196
                Craft::error($e->getMessage(), __METHOD__);
197
            }
198
        }
199
        // Trigger a 'afterSaveDataSample' event
200
        $this->trigger(self::EVENT_AFTER_SAVE_DATA_SAMPLE, $event);
201
        // Trim orphaned samples
202
        $this->trimOrphanedSamples($dataSample->requestId);
203
        // After adding the DataSample, trim the webperf_data_samples db table
204
        if (Webperf::$settings->automaticallyTrimDataSamples && !$this->rateLimited()) {
205
            $this->trimDataSamples();
206
        }
207
    }
208
209
    /**
210
     * Delete a data sample by id
211
     *
212
     * @param int $id
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
213
     *
214
     * @return int The result
215
     */
216
    public function deleteSampleById(int $id): int
217
    {
218
        $db = Craft::$app->getDb();
219
        // Delete a row from the db table
220
        try {
221
            $result = $db->createCommand()->delete(
222
                '{{%webperf_data_samples}}',
223
                [
224
                    'id' => $id,
225
                ]
226
            )->execute();
227
        } catch (\Exception $e) {
228
            Craft::error($e->getMessage(), __METHOD__);
229
            $result = 0;
230
        }
231
232
        return $result;
233
    }
234
235
    /**
236
     * Delete data samples by URL and optionally siteId
237
     *
238
     * @param string   $url
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
239
     * @param int|null $siteId
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
240
     *
241
     * @return int
242
     */
243
    public function deleteDataSamplesByUrl(string $url, int $siteId = null): int
244
    {
245
        $db = Craft::$app->getDb();
246
        // Delete a row from the db table
247
        try {
248
            $conditions = ['url' => $url];
249
            if ($siteId !== null) {
250
                $conditions['siteId'] = $siteId;
251
            }
252
            $result = $db->createCommand()->delete(
253
                '{{%webperf_data_samples}}',
254
                $conditions
255
            )->execute();
256
        } catch (\Exception $e) {
257
            Craft::error($e->getMessage(), __METHOD__);
258
            $result = 0;
259
        }
260
261
        return $result;
262
    }
263
264
    /**
265
     * Delete data all samples optionally siteId
266
     *
267
     * @param int|null $siteId
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
268
     *
269
     * @return int
270
     */
271
    public function deleteAllDataSamples(int $siteId = null): int
272
    {
273
        $db = Craft::$app->getDb();
274
        // Delete a row from the db table
275
        try {
276
            $conditions = [];
277
            if ($siteId !== null) {
278
                $conditions['siteId'] = $siteId;
279
            }
280
            $result = $db->createCommand()->delete(
281
                '{{%webperf_data_samples}}',
282
                $conditions
283
            )->execute();
284
        } catch (\Exception $e) {
285
            Craft::error($e->getMessage(), __METHOD__);
286
            $result = 0;
287
        }
288
289
        return $result;
290
    }
291
292
    /**
293
     * Trim any samples that our outliers
294
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
295
    public function trimOutlierSamples()
296
    {
297
        if (Webperf::$settings->trimOutlierDataSamples) {
298
            $db = Craft::$app->getDb();
299
            Craft::debug('Trimming outlier samples', __METHOD__);
300
            // Get the average pageload time
301
            $stats = (new Query())
302
                ->from('{{%webperf_data_samples}}')
303
                ->average('[[pageLoad]]');
304
            if (!empty($stats)) {
305
                $threshold = $stats * self::OUTLIER_PAGELOAD_MULTIPLIER;
306
                // Delete any samples that are far above average
307
                try {
308
                    $result = $db->createCommand()->delete(
309
                        '{{%webperf_data_samples}}',
310
                        ['>', '[[pageLoad]]', $threshold]
311
                    )->execute();
312
                    Craft::debug($result, __METHOD__);
313
                } catch (\Exception $e) {
314
                    Craft::error($e->getMessage(), __METHOD__);
315
                }
316
            }
317
        }
318
    }
319
320
    /**
321
     * Trim samples that have the placeholder in the URL, aka they never
322
     * received the Boomerang beacon
323
     *
324
     * @param int $requestId
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
325
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
326
    public function trimOrphanedSamples($requestId)
327
    {
328
        $db = Craft::$app->getDb();
329
        Craft::debug('Trimming orphaned samples', __METHOD__);
330
        // Update the existing record
331
        try {
332
            $result = $db->createCommand()->delete(
333
                '{{%webperf_data_samples}}',
334
                [
335
                    'and', ['url' => CraftDataSample::PLACEHOLDER_URL],
336
                    ['not', ['requestId' => $requestId]],
337
                ]
338
            )->execute();
339
            Craft::debug($result, __METHOD__);
340
        } catch (\Exception $e) {
341
            Craft::error($e->getMessage(), __METHOD__);
342
        }
343
    }
344
345
    /**
346
     * Trim the webperf_data_samples db table based on the dataSamplesStoredLimit
347
     * config.php setting
348
     *
349
     * @param int|null $limit
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
350
     *
351
     * @return int
352
     */
353
    public function trimDataSamples(int $limit = null): int
354
    {
355
        $this->trimOutlierSamples();
356
        $affectedRows = 0;
357
        $db = Craft::$app->getDb();
358
        $quotedTable = $db->quoteTableName('{{%webperf_data_samples}}');
359
        $limit = $limit ?? Webperf::$settings->dataSamplesStoredLimit;
360
361
        if ($limit !== null) {
0 ignored issues
show
introduced by
The condition $limit !== null is always true.
Loading history...
362
            //  https://stackoverflow.com/questions/578867/sql-query-delete-all-records-from-the-table-except-latest-n
363
            try {
364
                if ($db->getIsMysql()) {
365
                    // Handle MySQL
366
                    $affectedRows = $db->createCommand(/** @lang mysql */
0 ignored issues
show
Coding Style introduced by
The open comment tag must be the only content on the line
Loading history...
Coding Style introduced by
Missing short description in doc comment
Loading history...
Coding Style introduced by
The close comment tag must be the only content on the line
Loading history...
367
                        "
368
                        DELETE FROM {$quotedTable}
369
                        WHERE id NOT IN (
370
                          SELECT id
371
                          FROM (
372
                            SELECT id
373
                            FROM {$quotedTable}
374
                            ORDER BY dateCreated DESC
375
                            LIMIT {$limit}
376
                          ) foo
377
                        )
378
                        "
379
                    )->execute();
380
                }
381
                if ($db->getIsPgsql()) {
382
                    // Handle Postgres
383
                    $affectedRows = $db->createCommand(/** @lang mysql */
0 ignored issues
show
Coding Style introduced by
The open comment tag must be the only content on the line
Loading history...
Coding Style introduced by
Missing short description in doc comment
Loading history...
Coding Style introduced by
The close comment tag must be the only content on the line
Loading history...
384
                        "
385
                        DELETE FROM {$quotedTable}
386
                        WHERE id NOT IN (
387
                          SELECT id
388
                          FROM (
389
                            SELECT id
390
                            FROM {$quotedTable}
391
                            ORDER BY \"dateCreated\" DESC
392
                            LIMIT {$limit}
393
                          ) foo
394
                        )
395
                        "
396
                    )->execute();
397
                }
398
            } catch (\Exception $e) {
399
                Craft::error($e->getMessage(), __METHOD__);
400
            }
401
            Craft::info(
402
                Craft::t(
403
                    'webperf',
404
                    'Trimmed {rows} from webperf_data_samples table',
405
                    ['rows' => $affectedRows]
406
                ),
407
                __METHOD__
408
            );
409
        }
410
411
        return $affectedRows;
412
    }
413
414
    // Protected Methods
415
    // =========================================================================
416
417
    /**
418
     * Don't trim more than a given interval, so that performance is not affected
419
     *
420
     * @return bool
421
     */
422
    protected function rateLimited(): bool
423
    {
424
        $limited = false;
425
        $now = round(microtime(true) * 1000);
426
        $cache = Craft::$app->getCache();
427
        $then = $cache->get(self::LAST_DATASAMPLES_TRIM_CACHE_KEY);
428
        if (($then !== false) && ($now - (int)$then < Webperf::$settings->samplesRateLimitMs)) {
429
            $limited = true;
430
        }
431
        $cache->set(self::LAST_DATASAMPLES_TRIM_CACHE_KEY, $now, 0);
432
433
        return $limited;
434
    }
435
}
436