Skip to content

Commit 8e10e91

Browse files
authored
Merge pull request #2563 from acelaya-forks/extended-visits-cli-output
Extend and normalize output from visits console commands
2 parents 0d964f0 + 900de9e commit 8e10e91

16 files changed

+100
-195
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
1919

2020
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
2121

22+
* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently.
23+
24+
Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not.
25+
2226
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
2327
* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.
2428

module/CLI/config/dependencies.config.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
],
108108
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
109109
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
110-
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
110+
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
111111

112112
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
113113
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
@@ -119,11 +119,11 @@
119119
Command\Tag\ListTagsCommand::class => [TagService::class],
120120
Command\Tag\RenameTagCommand::class => [TagService::class],
121121
Command\Tag\DeleteTagsCommand::class => [TagService::class],
122-
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
122+
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class],
123123

124124
Command\Domain\ListDomainsCommand::class => [DomainService::class],
125125
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
126-
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
126+
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class],
127127

128128
Command\RedirectRule\ManageRedirectRulesCommand::class => [
129129
ShortUrl\ShortUrlResolver::class,

module/CLI/src/Command/Domain/GetDomainVisitsCommand.php

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
88
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
9-
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
10-
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
119
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
1210
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
1311
use Symfony\Component\Console\Attribute\Argument;
@@ -22,10 +20,8 @@ class GetDomainVisitsCommand extends Command
2220
{
2321
public const string NAME = 'domain:visits';
2422

25-
public function __construct(
26-
private readonly VisitsStatsHelperInterface $visitsHelper,
27-
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
28-
) {
23+
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
24+
{
2925
parent::__construct();
3026
}
3127

@@ -36,17 +32,8 @@ public function __invoke(
3632
#[MapInput] VisitsListInput $input,
3733
): int {
3834
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange()));
39-
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
35+
VisitsCommandUtils::renderOutput($io, $input, $paginator);
4036

4137
return self::SUCCESS;
4238
}
43-
44-
/**
45-
* @return array<string, string>
46-
*/
47-
protected function mapExtraFields(Visit $visit): array
48-
{
49-
$shortUrl = $visit->shortUrl;
50-
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
51-
}
5239
}

module/CLI/src/Command/Tag/GetTagVisitsCommand.php

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
88
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
99
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
10-
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
11-
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
1210
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
1311
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
1412
use Symfony\Component\Console\Attribute\Argument;
@@ -24,10 +22,8 @@ class GetTagVisitsCommand extends Command
2422
{
2523
public const string NAME = 'tag:visits';
2624

27-
public function __construct(
28-
private readonly VisitsStatsHelperInterface $visitsHelper,
29-
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
30-
) {
25+
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
26+
{
3127
parent::__construct();
3228
}
3329

@@ -47,17 +43,8 @@ public function __invoke(
4743
domain: $domain,
4844
));
4945

50-
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
46+
VisitsCommandUtils::renderOutput($io, $input, $paginator);
5147

5248
return self::SUCCESS;
5349
}
54-
55-
/**
56-
* @return array<string, string>
57-
*/
58-
private function mapExtraFields(Visit $visit): array
59-
{
60-
$shortUrl = $visit->shortUrl;
61-
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
62-
}
6350
}

module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
88
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
9-
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
10-
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
119
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
1210
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
1311
use Symfony\Component\Console\Attribute\AsCommand;
@@ -21,10 +19,8 @@ class GetNonOrphanVisitsCommand extends Command
2119
{
2220
public const string NAME = 'visit:non-orphan';
2321

24-
public function __construct(
25-
private readonly VisitsStatsHelperInterface $visitsHelper,
26-
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
27-
) {
22+
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
23+
{
2824
parent::__construct();
2925
}
3026

@@ -42,17 +38,8 @@ public function __invoke(
4238
dateRange: $input->dateRange(),
4339
domain: $domain,
4440
));
45-
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
41+
VisitsCommandUtils::renderOutput($io, $input, $paginator);
4642

4743
return self::SUCCESS;
4844
}
49-
50-
/**
51-
* @return array<string, string>
52-
*/
53-
private function mapExtraFields(Visit $visit): array
54-
{
55-
$shortUrl = $visit->shortUrl;
56-
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
57-
}
5845
}

module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
88
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
9-
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
109
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
1110
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
1211
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
@@ -42,16 +41,8 @@ public function __invoke(
4241
domain: $domain,
4342
type: $type,
4443
));
45-
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
44+
VisitsCommandUtils::renderOutput($io, $input, $paginator);
4645

4746
return self::SUCCESS;
4847
}
49-
50-
/**
51-
* @return array<string, string>
52-
*/
53-
private function mapExtraFields(Visit $visit): array
54-
{
55-
return ['type' => $visit->type->value];
56-
}
5748
}

module/CLI/src/Command/Visit/VisitsCommandUtils.php

Lines changed: 33 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,12 @@
1313
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
1414
use Symfony\Component\Console\Output\OutputInterface;
1515

16-
use function array_keys;
1716
use function array_map;
18-
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
19-
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
2017

2118
class VisitsCommandUtils
2219
{
2320
/**
2421
* @param Paginator<Visit> $paginator
25-
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
2622
*/
2723
public static function renderOutput(
2824
OutputInterface $output,
@@ -36,25 +32,21 @@ public static function renderOutput(
3632
}
3733

3834
match ($inputData->format) {
39-
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields),
40-
default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields),
35+
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator),
36+
default => self::renderHumanFriendlyOutput($output, $paginator),
4137
};
4238
}
4339

4440
/**
4541
* @param Paginator<Visit> $paginator
46-
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
4742
*/
48-
private static function renderCSVOutput(
49-
OutputInterface $output,
50-
Paginator $paginator,
51-
callable|null $mapExtraFields,
52-
): void {
43+
private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void
44+
{
5345
$page = 1;
5446
do {
5547
$paginator->setCurrentPage($page);
5648

57-
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
49+
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
5850
$csv = Writer::fromString();
5951
if ($page === 1) {
6052
$csv->insertOne($headers);
@@ -69,19 +61,15 @@ private static function renderCSVOutput(
6961

7062
/**
7163
* @param Paginator<Visit> $paginator
72-
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
7364
*/
74-
private static function renderHumanFriendlyOutput(
75-
OutputInterface $output,
76-
Paginator $paginator,
77-
callable|null $mapExtraFields,
78-
): void {
65+
private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void
66+
{
7967
$page = 1;
8068
do {
8169
$paginator->setCurrentPage($page);
8270
$page++;
8371

84-
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
72+
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
8573
ShlinkTable::default($output)->render(
8674
$headers,
8775
$rows,
@@ -92,35 +80,38 @@ private static function renderHumanFriendlyOutput(
9280

9381
/**
9482
* @param Paginator<Visit> $paginator
95-
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
9683
*/
97-
private static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields): array
84+
private static function resolveRowsAndHeaders(Paginator $paginator): array
9885
{
99-
$extraKeys = null;
100-
$mapExtraFields ??= static fn (Visit $_) => [];
101-
102-
$rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) {
103-
$extraFields = $mapExtraFields($visit);
104-
$extraKeys ??= array_keys($extraFields);
86+
$headers = [
87+
'Date',
88+
'Potential bot',
89+
'User agent',
90+
'Referer',
91+
'Country',
92+
'Region',
93+
'City',
94+
'Visited URL',
95+
'Redirect URL',
96+
'Type',
97+
];
98+
$rows = array_map(function (Visit $visit) {
99+
$visitLocation = $visit->visitLocation;
105100

106-
$rowData = [
107-
'referer' => $visit->referer,
101+
return [
108102
'date' => $visit->date->toAtomString(),
103+
'potentialBot' => $visit->potentialBot ? 'Potential bot' : '',
109104
'userAgent' => $visit->userAgent,
110-
'potentialBot' => $visit->potentialBot,
111-
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
112-
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
113-
...$extraFields,
105+
'referer' => $visit->referer,
106+
'country' => $visitLocation->countryName ?? 'Unknown',
107+
'region' => $visitLocation->regionName ?? 'Unknown',
108+
'city' => $visitLocation->cityName ?? 'Unknown',
109+
'visitedUrl' => $visit->visitedUrl ?? 'Unknown',
110+
'redirectUrl' => $visit->redirectUrl ?? 'Unknown',
111+
'type' => $visit->type->value,
114112
];
115-
116-
// Filter out unknown keys
117-
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
118113
}, [...$paginator->getCurrentPageResults()]);
119-
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys ?? []);
120114

121-
return [
122-
$rows,
123-
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
124-
];
115+
return [$rows, $headers];
125116
}
126117
}

module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
1212
use Shlinkio\Shlink\Common\Paginator\Paginator;
1313
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
14-
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
1514
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
1615
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
1716
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
17+
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
1818
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
1919
use Shlinkio\Shlink\IpGeolocation\Model\Location;
2020
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
@@ -24,16 +24,11 @@ class GetDomainVisitsCommandTest extends TestCase
2424
{
2525
private CommandTester $commandTester;
2626
private MockObject & VisitsStatsHelperInterface $visitsHelper;
27-
private MockObject & ShortUrlStringifierInterface $stringifier;
2827

2928
protected function setUp(): void
3029
{
3130
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
32-
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
33-
34-
$this->commandTester = CliTestUtils::testerForCommand(
35-
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
36-
);
31+
$this->commandTester = CliTestUtils::testerForCommand(new GetDomainVisitsCommand($this->visitsHelper));
3732
}
3833

3934
#[Test]
@@ -48,22 +43,22 @@ public function outputIsProperlyGenerated(): void
4843
$domain,
4944
$this->anything(),
5045
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
51-
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
52-
'the_short_url',
53-
);
5446

5547
$this->commandTester->execute(['domain' => $domain]);
5648
$output = $this->commandTester->getDisplay();
49+
$type = VisitType::VALID_SHORT_URL->value;
5750

5851
self::assertEquals(
52+
// phpcs:disable Generic.Files.LineLength
5953
<<<OUTPUT
60-
+---------+---------------------------+------------+---------+--------+---------------+
61-
| Referer | Date | User agent | Country | City | Short Url |
62-
+---------+---------------------------+------------+---------+--------+---------------+
63-
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
64-
+---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+
54+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
55+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
56+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
57+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
58+
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
6559
6660
OUTPUT,
61+
// phpcs:enable
6762
$output,
6863
);
6964
}

0 commit comments

Comments
 (0)