Skip to content

Commit a773642

Browse files
authored
Merge pull request #13 from elvina-ero/TS-5797
Add ExternalApiResponseBody tracker
2 parents 3896d48 + 1eac46c commit a773642

8 files changed

Lines changed: 507 additions & 0 deletions

File tree

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,66 @@ However if you want track common time of response, set this option to `true`.
166166

167167
Field `val` can be configured by option `main_metric`. This field must be one of list in `metrics` option.
168168

169+
### External api response body
170+
171+
Some external APIs use a single endpoint for many different operations (e.g. JSON-RPC style APIs) and always answer
172+
with the same HTTP status code, putting the actual operation name and error code inside the request/response JSON
173+
body instead. In that case `external_api_response` tracker cannot tell those operations apart, because it only has
174+
access to the host and the HTTP status code.
175+
176+
This tracker counts requests, extracting extra tags out of the request's and response's JSON bodies by a dot-notation
177+
path (powered by Laravel's `data_get()`), so you can split the metric by things like the RPC method name or an
178+
application-level error code, on top of the same `host`/`status` tags as `external_api_response`.
179+
180+
Integration is the same as for `external_api_response`, but using classes from the `ExternalApiResponseBody`
181+
namespace:
182+
183+
```
184+
$callbackCreator = app(Umbrellio\EventTracker\Trackers\ExternalApiResponseBody\GuzzleClientOnStatsCallbackCreator::class);
185+
$client = new GuzzleHttp\Client(['on_stats' => $callbackCreator->create()]);
186+
```
187+
188+
If you need to track both `external_api_response` and `external_api_response_body` on the same client, combine both
189+
callbacks into one:
190+
191+
```
192+
$responseCallback = app(Umbrellio\EventTracker\Trackers\ExternalApiResponse\GuzzleClientOnStatsCallbackCreator::class)->create();
193+
$bodyCallback = app(Umbrellio\EventTracker\Trackers\ExternalApiResponseBody\GuzzleClientOnStatsCallbackCreator::class)->create();
194+
195+
$client = new GuzzleHttp\Client([
196+
'on_stats' => static function (GuzzleHttp\TransferStats $stats) use ($responseCallback, $bodyCallback): void {
197+
$responseCallback($stats);
198+
$bodyCallback($stats);
199+
},
200+
]);
201+
```
202+
203+
Configuration:
204+
205+
```php
206+
'external_api_response_body' => [
207+
'measurement' => 'event_tracker_external_api_response_body',
208+
209+
// tag name => dot-notation path in decoded json body
210+
'request_fields' => ['method' => 'method'],
211+
'response_fields' => ['code' => 'error.code'],
212+
213+
// used when a field is missing from the body, the body isn't valid json, or the field's value is null
214+
'default_value' => 'unknown',
215+
216+
// bodies bigger than this (in bytes) are skipped and default_value is used instead
217+
'max_body_bytes' => 65536,
218+
],
219+
```
220+
221+
> **Prometheus**
222+
>
223+
> Metrics have follow format: app_prefix_external_api_response_body{namespace="app-ns",host="domain.com",status="200",method="pointg/sessions/create",code="incorrect_device_type"} 1
224+
225+
Bodies are read without disturbing the rest of the application: the stream is rewound right after reading, and
226+
non-seekable streams (e.g. clients created with the `stream` option) are skipped entirely, exactly like
227+
`external_api_response` does for its `main_metric`.
228+
169229
### Custom trackers
170230

171231
#### Influx

config/event_tracker.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,18 @@
7272

7373
'prom_buckets' => [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
7474
],
75+
'external_api_response_body' => [
76+
'measurement' => 'event_tracker_external_api_response_body',
77+
78+
// tag name => dot-notation path in decoded json body
79+
'request_fields' => [],
80+
'response_fields' => [],
81+
82+
// used when a field is missing from the body or the body isn't valid json
83+
'default_value' => 'unknown',
84+
85+
// bodies bigger than this (in bytes) are skipped and default_value is used instead
86+
'max_body_bytes' => 65536,
87+
],
7588
],
7689
];

src/EventTrackerServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Umbrellio\EventTracker\Repositories\PrometheusRepository\Installer as PrometheusRepositoryInstaller;
1111
use Umbrellio\EventTracker\Trackers\BaseInstaller;
1212
use Umbrellio\EventTracker\Trackers\ExternalApiResponse\Installer as ExternalApiResponseInstaller;
13+
use Umbrellio\EventTracker\Trackers\ExternalApiResponseBody\Installer as ExternalApiResponseBodyInstaller;
1314
use Umbrellio\EventTracker\Trackers\JobsDuration\Installer as JobsDurationInstaller;
1415
use Umbrellio\EventTracker\Trackers\JobsLog\Installer as JobsLogInstaller;
1516
use Umbrellio\EventTracker\Trackers\ResponseTime\Installer as ResponseTimeInstaller;
@@ -21,6 +22,7 @@ class EventTrackerServiceProvider extends ServiceProvider
2122
'jobs_log' => JobsLogInstaller::class,
2223
'response_time' => ResponseTimeInstaller::class,
2324
'external_api_response' => ExternalApiResponseInstaller::class,
25+
'external_api_response_body' => ExternalApiResponseBodyInstaller::class,
2426
];
2527

2628
private const REPOSITORY_INSTALLER_CONNECTION_MAP = [
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Umbrellio\EventTracker\Trackers\ExternalApiResponseBody;
6+
7+
use Closure;
8+
use GuzzleHttp\TransferStats;
9+
use Umbrellio\EventTracker\Services\Adapters\BaseAdapter;
10+
11+
class GuzzleClientOnStatsCallbackCreator
12+
{
13+
private const DEFAULT_STATUS_CODE = 0;
14+
15+
private BaseAdapter $adapter;
16+
private MessageBodyFieldsExtractor $extractor;
17+
private array $config;
18+
19+
public function __construct(BaseAdapter $adapter, MessageBodyFieldsExtractor $extractor, array $config)
20+
{
21+
$this->adapter = $adapter;
22+
$this->extractor = $extractor;
23+
$this->config = $config;
24+
}
25+
26+
public function create(): callable
27+
{
28+
return Closure::fromCallable([$this, 'saveStats']);
29+
}
30+
31+
private function saveStats(TransferStats $stats): void
32+
{
33+
$response = $stats->getResponse();
34+
35+
$tags = [
36+
'host' => $stats->getRequest()
37+
->getUri()
38+
->getHost(),
39+
'status' => optional($response)
40+
->getStatusCode() ?? self::DEFAULT_STATUS_CODE,
41+
];
42+
43+
$tags = array_merge(
44+
$tags,
45+
$this->extractor->extract($stats->getRequest(), $this->config['request_fields'] ?? [], $this->config),
46+
$this->extractor->extract($response, $this->config['response_fields'] ?? [], $this->config)
47+
);
48+
49+
$this->adapter->write($this->config['measurement'], 1, $tags);
50+
}
51+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Umbrellio\EventTracker\Trackers\ExternalApiResponseBody;
6+
7+
use Illuminate\Contracts\Foundation\Application;
8+
use Prometheus\Counter;
9+
use Umbrellio\EventTracker\Trackers\BaseInstaller;
10+
11+
class Installer extends BaseInstaller
12+
{
13+
public function install(Application $app, string $connection, array $metricConfig): void
14+
{
15+
$app->singleton(GuzzleClientOnStatsCallbackCreator::class, function () use ($app, $connection, $metricConfig) {
16+
$adapterClass = $this->resolveAdapter($connection, Counter::TYPE);
17+
18+
return new GuzzleClientOnStatsCallbackCreator(
19+
$app->make($adapterClass, compact('metricConfig')),
20+
$app->make(MessageBodyFieldsExtractor::class),
21+
$metricConfig
22+
);
23+
});
24+
}
25+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Umbrellio\EventTracker\Trackers\ExternalApiResponseBody;
6+
7+
use Psr\Http\Message\MessageInterface;
8+
use Throwable;
9+
10+
class MessageBodyFieldsExtractor
11+
{
12+
private const DEFAULT_VALUE = 'unknown';
13+
private const DEFAULT_MAX_BODY_BYTES = 65536;
14+
15+
public function extract(?MessageInterface $message, array $fields, array $config = []): array
16+
{
17+
if (!$fields) {
18+
return [];
19+
}
20+
21+
$default = $config['default_value'] ?? self::DEFAULT_VALUE;
22+
$data = $this->decodeBody($message, $config['max_body_bytes'] ?? self::DEFAULT_MAX_BODY_BYTES);
23+
24+
$result = [];
25+
foreach ($fields as $tag => $path) {
26+
$value = data_get($data, $path, $default);
27+
$result[$tag] = $value ?? $default;
28+
}
29+
30+
return $result;
31+
}
32+
33+
private function decodeBody(?MessageInterface $message, int $maxBodyBytes): array
34+
{
35+
if (!$message) {
36+
return [];
37+
}
38+
39+
$body = $message->getBody();
40+
41+
/**
42+
* Non-seekable bodies belong to streamed requests/responses - reading them here would consume data the
43+
* application still needs to process.
44+
*/
45+
if (!$body->isSeekable() || ($body->getSize() !== null && $body->getSize() > $maxBodyBytes)) {
46+
return [];
47+
}
48+
49+
try {
50+
$decoded = json_decode($body->__toString(), true, 512, JSON_THROW_ON_ERROR);
51+
} catch (Throwable $exception) {
52+
return [];
53+
} finally {
54+
$body->rewind();
55+
}
56+
57+
return is_array($decoded) ? $decoded : [];
58+
}
59+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature\Trackers\ExternalApiResponseBody;
6+
7+
use GuzzleHttp\Psr7\Request;
8+
use GuzzleHttp\Psr7\Response;
9+
use GuzzleHttp\TransferStats;
10+
use PHPUnit\Framework\TestCase;
11+
use Umbrellio\EventTracker\Services\Adapters\EventAdapter;
12+
use Umbrellio\EventTracker\Trackers\ExternalApiResponseBody\GuzzleClientOnStatsCallbackCreator;
13+
use Umbrellio\EventTracker\Trackers\ExternalApiResponseBody\MessageBodyFieldsExtractor;
14+
15+
class GuzzleClientOnStatsCallbackCreatorTest extends TestCase
16+
{
17+
/**
18+
* @test
19+
*/
20+
public function writesMetricWithFieldsParsedFromRequestAndResponseBodies(): void
21+
{
22+
$eventAdapter = $this->createMock(EventAdapter::class);
23+
$eventAdapter->expects($this->once())
24+
->method('write')
25+
->with('external_api_response_body', 1, [
26+
'host' => 'domain.com',
27+
'status' => 200,
28+
'method' => 'pointg/sessions/create',
29+
'code' => 'incorrect_device_type',
30+
]);
31+
32+
$callback = $this->creator($eventAdapter)
33+
->create();
34+
35+
$response = new Response(200, [], json_encode([
36+
'error' => [
37+
'code' => 'incorrect_device_type',
38+
],
39+
]));
40+
41+
$callback(new TransferStats($this->request(), $response));
42+
}
43+
44+
/**
45+
* @test
46+
*/
47+
public function usesDefaultValueWhenResponseHasNoError(): void
48+
{
49+
$eventAdapter = $this->createMock(EventAdapter::class);
50+
$eventAdapter->expects($this->once())
51+
->method('write')
52+
->with('external_api_response_body', 1, [
53+
'host' => 'domain.com',
54+
'status' => 200,
55+
'method' => 'pointg/sessions/create',
56+
'code' => 'unknown',
57+
]);
58+
59+
$callback = $this->creator($eventAdapter)
60+
->create();
61+
62+
$response = new Response(200, [], json_encode([
63+
'data' => [
64+
'url' => 'https://game.example',
65+
],
66+
]));
67+
68+
$callback(new TransferStats($this->request(), $response));
69+
}
70+
71+
/**
72+
* @test
73+
*/
74+
public function usesDefaultStatusWhenRequestFailed(): void
75+
{
76+
$eventAdapter = $this->createMock(EventAdapter::class);
77+
$eventAdapter->expects($this->once())
78+
->method('write')
79+
->with('external_api_response_body', 1, [
80+
'host' => 'domain.com',
81+
'status' => 0,
82+
'method' => 'pointg/sessions/create',
83+
'code' => 'unknown',
84+
]);
85+
86+
$callback = $this->creator($eventAdapter)
87+
->create();
88+
89+
$callback(new TransferStats($this->request(), null));
90+
}
91+
92+
private function request(): Request
93+
{
94+
return new Request('POST', 'https://domain.com/api/v2/service', [], json_encode([
95+
'method' => 'pointg/sessions/create',
96+
]));
97+
}
98+
99+
private function creator(EventAdapter $eventAdapter): GuzzleClientOnStatsCallbackCreator
100+
{
101+
return new GuzzleClientOnStatsCallbackCreator($eventAdapter, new MessageBodyFieldsExtractor(), [
102+
'measurement' => 'external_api_response_body',
103+
'request_fields' => [
104+
'method' => 'method',
105+
],
106+
'response_fields' => [
107+
'code' => 'error.code',
108+
],
109+
]);
110+
}
111+
}

0 commit comments

Comments
 (0)