Skip to content
This repository was archived by the owner on Dec 22, 2025. It is now read-only.

Commit 4a99107

Browse files
authored
Merge pull request #2 from hellofresh/feature/second-level-id
Added second level ID callback implementation
2 parents 54955b7 + 0a31f0d commit 4a99107

File tree

3 files changed

+372
-2
lines changed

3 files changed

+372
-2
lines changed

README.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dashboards to track activity and problems.
2323
* `noop` for environments that do not require any stats gathering
2424
* Fixed metric sections count for all metrics to allow easy monitoring/alerting setup in `grafana`
2525
* Easy to build HTTP requests metrics - timing and count
26+
* Generalise or modify HTTP Requests metric - e.g. skip ID part
2627

2728
## Installation
2829

@@ -90,6 +91,57 @@ $ordersInTheLast24Hours = OrdersService::count(60 * 60 * 24);
9091
$statsClient->trackState($section, $operation, $ordersInTheLast24Hours);
9192
```
9293

93-
## TODO
94+
### Generalise resources by type and stripping resource ID
9495

95-
* [ ] Generalise or modify HTTP Requests metric - e.g. skip ID part
96+
In some cases you do not need to collect metrics for all unique requests, but a single metric for requests of the similar type,
97+
e.g. access time to concrete users pages does not matter a lot, but average access time is important.
98+
`hellofresh/stats-php` allows HTTP Request metric modification and supports ID filtering out of the box, so
99+
you can get generic metric `get.users.-id-` instead thousands of metrics like `get.users.1`, `get.users.13`,
100+
`get.users.42` etc. that may make your `graphite` suffer from overloading.
101+
102+
To use metric generalisation by second level path ID, you can pass
103+
`HelloFresh\Stats\HTTPMetricAlterCallback\HasIDAtSecondLevel` instance to
104+
`HelloFresh\Stats\Client::setHTTPMetricAlterCallback()`. Also there is a builder method
105+
`HelloFresh\Stats\HTTPMetricAlterCallback\HasIDAtSecondLevel::createFromStringMap()`
106+
that builds a callback instance from string map, so you can get these values from config.
107+
It accepts a list of sections with test callback in the following format: `<section>:<test-callback-name>`.
108+
You can use either double colon or new line character as section-callback pairs separator, so all of the following
109+
forms are correct:
110+
111+
* `<section-0>:<test-callback-name-0>:<section-1>:<test-callback-name-1>:<section-2>:<test-callback-name-2>`
112+
* `<section-0>:<test-callback-name-0>\n<section-1>:<test-callback-name-1>\n<section-2>:<test-callback-name-2>`
113+
* `<section-0>:<test-callback-name-0>:<section-1>:<test-callback-name-1>\n<section-2>:<test-callback-name-2>`
114+
115+
Currently the following test callbacks are implemented:
116+
117+
* `true` - second path level is always treated as ID,
118+
e.g. `/users/13` -> `users.-id-`, `/users/search` -> `users.-id-`, `/users` -> `users.-id-`
119+
* `numeric` - only numeric second path level is interpreted as ID,
120+
e.g. `/users/13` -> `users.-id-`, `/users/search` -> `users.search`
121+
* `not_empty` - only not empty second path level is interpreted as ID,
122+
e.g. `/users/13` -> `users.-id-`, `/users` -> `users.-`
123+
124+
You can register your own test callback functions using the
125+
`HelloFresh\Stats\HTTPMetricAlterCallback\HasIDAtSecondLevel::registerSectionTest()` instance method
126+
or the second parameter of builder method - builder method validates test callback functions against the registered list.
127+
128+
```php
129+
<?php
130+
131+
use HelloFresh\Stats\Factory;
132+
use HelloFresh\Stats\HTTPMetricAlterCallback\HasIDAtSecondLevel;
133+
134+
$statsClient = Factory::build(getenv('STATS_DSN'), $logger);
135+
// STATS_IDS=users:numeric:search:not_empty
136+
$callback = HasIDAtSecondLevel::createFromStringMap(getenv('STATS_IDS'));
137+
$statsClient->setHTTPMetricAlterCallback($callback);
138+
139+
$timer = $statsClient->buildTimer()->start();
140+
141+
// GET /users/42 -> get.users.-id-
142+
// GET /users/edit -> get.users.edit
143+
// POST /users -> post.users.-
144+
// GET /search -> get.search.-
145+
// GET /search/friday%20beer -> get.search.-id-
146+
$statsClient->trackRequest($request, $imer, true);
147+
```
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
namespace HelloFresh\Stats\HTTPMetricAlterCallback;
3+
4+
use HelloFresh\Stats\Bucket;
5+
use HelloFresh\Stats\Bucket\MetricOperation;
6+
use HelloFresh\Stats\HTTPMetricAlterCallback;
7+
use Psr\Http\Message\RequestInterface;
8+
9+
/**
10+
* HTTPMetricAlterCallback implementation for filtering IDs on the second level of HTTP path,
11+
* e.g. to build for all requests like "GET /users/1", "GET /users/2", "GET /users/3" metric
12+
* like "get.users.-id-". See usage examples in README for the library.
13+
*/
14+
class HasIDAtSecondLevel implements HTTPMetricAlterCallback
15+
{
16+
const SECTION_TEST_TRUE = 'true';
17+
const SECTION_TEST_IS_NUMERIC = 'numeric';
18+
const SECTION_TEST_IS_NOT_EMPTY = 'not_empty';
19+
const SECTIONS_DELIMITER = ':';
20+
21+
/** @var array */
22+
protected $map = [];
23+
24+
/** @var callable[] */
25+
protected $sectionsTest = [];
26+
27+
/**
28+
* HasIDAtSecondLevel constructor.
29+
*
30+
* @param array $map sections test map with key as the first section of request path
31+
* and value as section test callback.
32+
*/
33+
public function __construct(array $map)
34+
{
35+
$this->map = $map;
36+
37+
$this->registerSectionTest(static::SECTION_TEST_TRUE, function ($pathSection) {
38+
return true;
39+
})->registerSectionTest(static::SECTION_TEST_IS_NUMERIC, function ($pathSection) {
40+
return is_numeric($pathSection);
41+
})->registerSectionTest(static::SECTION_TEST_IS_NOT_EMPTY, function ($pathSection) {
42+
return $pathSection != Bucket::METRIC_EMPTY_PLACEHOLDER;
43+
});
44+
}
45+
46+
/**
47+
* Creates HasIDAtSecondLevel instance by building sections test map from string value.
48+
* Main use-case for this builder method is for settings loaded from config file or environment variable.
49+
*
50+
* @param string $map
51+
* @param array $registerSectionTests section tests that must be registered for a new instance
52+
* @return self
53+
*/
54+
public static function createFromStringMap($map, array $registerSectionTests = [])
55+
{
56+
$parts = [];
57+
foreach (explode("\n", $map) as $line) {
58+
$line = trim($line);
59+
if ($line !== '') {
60+
foreach (explode(static::SECTIONS_DELIMITER, $line) as $part) {
61+
$part = trim($part);
62+
if ($part !== '') {
63+
$parts[] = $part;
64+
}
65+
}
66+
}
67+
}
68+
69+
if (count($parts) % 2 !== 0) {
70+
throw new \InvalidArgumentException('Invalid sections format');
71+
}
72+
73+
$instance = new static([]);
74+
foreach ($registerSectionTests as $name => $callback) {
75+
$instance->registerSectionTest($name, $callback);
76+
}
77+
78+
$arrayMap = [];
79+
for ($i = 0; $i < count($parts); $i += 2) {
80+
$pathSection = $parts[$i];
81+
$sectionTestName = $parts[$i + 1];
82+
if (!isset($instance->sectionsTest[$sectionTestName])) {
83+
throw new \InvalidArgumentException('Unknown section test callback name: ' . $sectionTestName);
84+
}
85+
$arrayMap[$pathSection] = $sectionTestName;
86+
}
87+
88+
$instance->map = $arrayMap;
89+
90+
return $instance;
91+
}
92+
93+
/**
94+
* @param string $name
95+
* @param callable $callback section test callback that accepts string test section as parameter and returns bool
96+
* if given parameter passes the test.
97+
*
98+
* @return $this
99+
*/
100+
public function registerSectionTest($name, callable $callback)
101+
{
102+
$this->sectionsTest[$name] = $callback;
103+
104+
return $this;
105+
}
106+
107+
/**
108+
* @inheritdoc
109+
*/
110+
public function __invoke(MetricOperation $metricParts, RequestInterface $request)
111+
{
112+
$firstFragment = '/';
113+
foreach (explode('/', $request->getUri()->getPath()) as $fragment) {
114+
if ($fragment !== '') {
115+
$firstFragment = $fragment;
116+
break;
117+
}
118+
}
119+
120+
if (isset($this->map[$firstFragment]) && isset($this->sectionsTest[$this->map[$firstFragment]])) {
121+
if (call_user_func($this->sectionsTest[$this->map[$firstFragment]], $metricParts[2])) {
122+
$metricParts[2] = Bucket::METRIC_ID_PLACEHOLDER;
123+
}
124+
}
125+
126+
return $metricParts;
127+
}
128+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
namespace HelloFresh\Stats\HTTPMetricAlterCallback;
3+
4+
use HelloFresh\Stats\Bucket;
5+
use HelloFresh\Stats\Bucket\MetricOperation;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class HasIDAtSecondLevelTest extends TestCase
9+
{
10+
/**
11+
* @dataProvider defaultSectionTestsProvider
12+
*
13+
* @param MetricOperation $inputMetric
14+
* @param MetricOperation $result
15+
* @param array $map
16+
*/
17+
public function testDefaultSectionTests(MetricOperation $inputMetric, MetricOperation $result, array $map)
18+
{
19+
$uri = $this->getMockBuilder('\Psr\Http\Message\UriInterface')->getMock();
20+
21+
$uri->expects($this->atLeastOnce())
22+
->method('getPath')
23+
->will($this->returnValue(sprintf('/%s/%s', $inputMetric[1], $inputMetric[2])));
24+
25+
/** @var \PHPUnit_Framework_MockObject_MockObject|\Psr\Http\Message\RequestInterface $request */
26+
$request = $this->getMockBuilder('\Psr\Http\Message\RequestInterface')->getMock();
27+
28+
$request->expects($this->atLeastOnce())
29+
->method('getUri')
30+
->will($this->returnValue($uri));
31+
32+
$callback = new HasIDAtSecondLevel($map);
33+
$this->assertEquals($result->toArray(), $callback($inputMetric, $request)->toArray());
34+
}
35+
36+
public function testRegisterSectionTest()
37+
{
38+
$inputMetric = new MetricOperation(['get', 'users', 'edit']);
39+
40+
$uri = $this->getMockBuilder('\Psr\Http\Message\UriInterface')->getMock();
41+
42+
$uri->expects($this->atLeastOnce())
43+
->method('getPath')
44+
->will($this->returnValue(sprintf('/%s/%s', $inputMetric[1], $inputMetric[2])));
45+
46+
/** @var \PHPUnit_Framework_MockObject_MockObject|\Psr\Http\Message\RequestInterface $request */
47+
$request = $this->getMockBuilder('\Psr\Http\Message\RequestInterface')->getMock();
48+
49+
$request->expects($this->atLeastOnce())
50+
->method('getUri')
51+
->will($this->returnValue($uri));
52+
53+
$callback = new HasIDAtSecondLevel(['users' => 'edit']);
54+
$this->assertEquals($inputMetric->toArray(), $callback($inputMetric, $request)->toArray());
55+
56+
$callback->registerSectionTest('edit', function ($pathSection) {
57+
return $pathSection == 'edit';
58+
});
59+
$this->assertEquals(
60+
(new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]))->toArray(),
61+
$callback($inputMetric, $request)->toArray()
62+
);
63+
}
64+
65+
/**
66+
* @dataProvider createFromStringMapProvider
67+
* @expectedException \InvalidArgumentException
68+
* @expectedExceptionMessage Invalid sections format
69+
*
70+
* @param string $map
71+
*/
72+
public function testCreateFromStringMap_InvalidFormat($map)
73+
{
74+
HasIDAtSecondLevel::createFromStringMap($map);
75+
}
76+
77+
/**
78+
* @expectedException \InvalidArgumentException
79+
* @expectedExceptionMessage Unknown section test callback name: foo
80+
*/
81+
public function testCreateFromStringMap_UnknownSectionTest()
82+
{
83+
HasIDAtSecondLevel::createFromStringMap('users:foo');
84+
}
85+
86+
public function testCreateFromStringMap()
87+
{
88+
$inputMetric1 = new MetricOperation(['get', 'users', '1']);
89+
$inputMetric2 = new MetricOperation(['get', 'users', 'foo']);
90+
91+
$uri = $this->getMockBuilder('\Psr\Http\Message\UriInterface')->getMock();
92+
93+
$uri->expects($this->at(0))
94+
->method('getPath')
95+
->will($this->returnValue(sprintf('/%s/%s', $inputMetric1[1], $inputMetric1[2])));
96+
$uri->expects($this->at(1))
97+
->method('getPath')
98+
->will($this->returnValue(sprintf('/%s/%s', $inputMetric2[1], $inputMetric2[2])));
99+
100+
/** @var \PHPUnit_Framework_MockObject_MockObject|\Psr\Http\Message\RequestInterface $request */
101+
$request = $this->getMockBuilder('\Psr\Http\Message\RequestInterface')->getMock();
102+
103+
$request->expects($this->atLeastOnce())
104+
->method('getUri')
105+
->will($this->returnValue($uri));
106+
107+
$callback = HasIDAtSecondLevel::createFromStringMap('users:foo', ['foo' => function ($pathSection) {
108+
return $pathSection == 'foo';
109+
}]);
110+
111+
$this->assertEquals(
112+
$inputMetric1->toArray(),
113+
$callback($inputMetric1, $request)->toArray()
114+
);
115+
$this->assertEquals(
116+
(new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]))->toArray(),
117+
$callback($inputMetric2, $request)->toArray()
118+
);
119+
}
120+
121+
public function defaultSectionTestsProvider()
122+
{
123+
return [
124+
// GET /users/1
125+
[
126+
new MetricOperation(['get', 'users', '1']),
127+
new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]),
128+
['users' => HasIDAtSecondLevel::SECTION_TEST_TRUE],
129+
],
130+
[
131+
new MetricOperation(['get', 'users', '1']),
132+
new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]),
133+
['users' => HasIDAtSecondLevel::SECTION_TEST_IS_NUMERIC],
134+
],
135+
[
136+
new MetricOperation(['get', 'users', '1']),
137+
new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]),
138+
['users' => HasIDAtSecondLevel::SECTION_TEST_IS_NOT_EMPTY],
139+
],
140+
// GET /users/edit
141+
[
142+
new MetricOperation(['get', 'users', 'edit']),
143+
new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]),
144+
['users' => HasIDAtSecondLevel::SECTION_TEST_TRUE],
145+
],
146+
[
147+
new MetricOperation(['get', 'users', 'edit']),
148+
new MetricOperation(['get', 'users', 'edit']),
149+
['users' => HasIDAtSecondLevel::SECTION_TEST_IS_NUMERIC],
150+
],
151+
[
152+
new MetricOperation(['get', 'users', 'edit']),
153+
new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]),
154+
['users' => HasIDAtSecondLevel::SECTION_TEST_IS_NOT_EMPTY],
155+
],
156+
// GET /users
157+
[
158+
new MetricOperation(['get', 'users']),
159+
new MetricOperation(['get', 'users', Bucket::METRIC_ID_PLACEHOLDER]),
160+
['users' => HasIDAtSecondLevel::SECTION_TEST_TRUE],
161+
],
162+
[
163+
new MetricOperation(['get', 'users']),
164+
new MetricOperation(['get', 'users', Bucket::METRIC_EMPTY_PLACEHOLDER]),
165+
['users' => HasIDAtSecondLevel::SECTION_TEST_IS_NUMERIC],
166+
],
167+
[
168+
new MetricOperation(['get', 'users']),
169+
new MetricOperation(['get', 'users', Bucket::METRIC_EMPTY_PLACEHOLDER]),
170+
['users' => HasIDAtSecondLevel::SECTION_TEST_IS_NOT_EMPTY],
171+
],
172+
// does not match
173+
[
174+
new MetricOperation(['get', 'clients']),
175+
new MetricOperation(['get', 'clients', Bucket::METRIC_EMPTY_PLACEHOLDER]),
176+
['users' => HasIDAtSecondLevel::SECTION_TEST_TRUE],
177+
],
178+
];
179+
}
180+
181+
public function createFromStringMapProvider()
182+
{
183+
return [
184+
['foo'],
185+
['foo:bar:baz'],
186+
["foo\n"],
187+
["foo:bar\nbaz"],
188+
];
189+
}
190+
}

0 commit comments

Comments
 (0)