Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions app/Services/Social/InstagramAnalytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
});
}

/**
* @return array<int, array{label: string, value: int}>|array{unsupported: true, reason: string}
*/
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
Expand All @@ -50,39 +53,68 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array

$this->accessToken = $account->access_token;

// Per-post metric set differs by content type. Reels/Stories expose
// different fields than feed posts. Pick the right set per type.
$metrics = match ($postPlatform->content_type) {
ContentType::InstagramReel => 'reach,likes,comments,shares,saved,plays',
ContentType::InstagramStory => 'reach,impressions,replies',
default => 'reach,likes,comments,shares,saved,total_interactions',
};
$metrics = $this->postMetricsFor($postPlatform);

$response = $this->socialHttp()
->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/insights", [
'metric' => $metrics,
'metric' => implode(',', array_keys($metrics)),
'access_token' => $this->accessToken,
]);

if ($response->failed()) {
Log::warning('Instagram post metrics fetch failed', [
'content_type' => $postPlatform->content_type?->value,
'body' => $this->redactResponseBody($response->body()),
]);

return ['unsupported' => true, 'reason' => 'api_error'];
}

$data = data_get($response->json(), 'data', []);

return collect($data)
->map(fn (array $item) => [
'label' => ucfirst(str_replace('_', ' ', data_get($item, 'name', ''))),
return collect(data_get($response->json(), 'data', []))
->filter(fn (array $item): bool => isset($metrics[data_get($item, 'name')]))
->map(fn (array $item): array => [
'label' => __($metrics[data_get($item, 'name')]),
'value' => (int) data_get($item, 'values.0.value', 0),
])
->values()
->all();
}

/**
* Meta retired `plays` (Reels) and `impressions` (Stories created after
* 2024-07-02) from Instagram media insights. Either name fails the whole
* request, so the card comes back empty. `views` is the replacement on the
* same `/insights` edge for Reels, Stories, and feed.
*
* @return array<string, string>
*/
private function postMetricsFor(PostPlatform $postPlatform): array
{
return match ($postPlatform->content_type) {
ContentType::InstagramReel => [
'reach' => 'analytics.metrics.reach',
'likes' => 'analytics.metrics.likes',
'comments' => 'analytics.metrics.comments',
'shares' => 'analytics.metrics.shares',
'saved' => 'analytics.metrics.saves',
'views' => 'analytics.metrics.views',
],
ContentType::InstagramStory => [
'reach' => 'analytics.metrics.reach',
'views' => 'analytics.metrics.views',
'replies' => 'analytics.metrics.replies',
],
default => [
'reach' => 'analytics.metrics.reach',
'likes' => 'analytics.metrics.likes',
'comments' => 'analytics.metrics.comments',
'shares' => 'analytics.metrics.shares',
'saved' => 'analytics.metrics.saves',
'total_interactions' => 'analytics.metrics.interactions',
],
};
}

private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
Expand Down
179 changes: 179 additions & 0 deletions tests/Feature/Services/Social/InstagramAnalyticsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\InstagramAnalytics;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->account = SocialAccount::factory()->instagram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'ig_123',
]);
$this->graph = config('trypost.platforms.instagram.graph_api');
});

/**
* @param array<int, array{name: string, value: mixed}> $metrics
* @return array<string, mixed>
*/
function instagramInsightsResponse(array $metrics): array
{
return [
'data' => array_map(fn (array $metric): array => [
'name' => $metric['name'],
'values' => [['value' => $metric['value']]],
], $metrics),
];
}

function instagramPostPlatform(ContentType $contentType, ?string $platformPostId = 'media_123'): PostPlatform
{
return PostPlatform::factory()->create([
'post_id' => test()->post->id,
'social_account_id' => test()->account->id,
'platform' => Platform::Instagram,
'content_type' => $contentType,
'platform_post_id' => $platformPostId,
]);
}

test('instagram analytics reads feed post metrics from the media insights edge', function () {
Http::fake([
"{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([
['name' => 'reach', 'value' => 90],
['name' => 'likes', 'value' => 7],
['name' => 'comments', 'value' => 2],
['name' => 'shares', 'value' => 1],
['name' => 'saved', 'value' => 4],
['name' => 'total_interactions', 'value' => 14],
])),
]);

$metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramFeed));

expect($metrics)->toBe([
['label' => __('analytics.metrics.reach'), 'value' => 90],
['label' => __('analytics.metrics.likes'), 'value' => 7],
['label' => __('analytics.metrics.comments'), 'value' => 2],
['label' => __('analytics.metrics.shares'), 'value' => 1],
['label' => __('analytics.metrics.saves'), 'value' => 4],
['label' => __('analytics.metrics.interactions'), 'value' => 14],
]);

Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/media_123/insights")
&& $request['metric'] === 'reach,likes,comments,shares,saved,total_interactions');
});

test('instagram analytics reads reel metrics with views instead of retired plays', function () {
Http::fake([
"{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([
['name' => 'reach', 'value' => 40],
['name' => 'likes', 'value' => 5],
['name' => 'comments', 'value' => 1],
['name' => 'shares', 'value' => 2],
['name' => 'saved', 'value' => 3],
['name' => 'views', 'value' => 120],
])),
]);

$metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramReel));

expect($metrics)->toBe([
['label' => __('analytics.metrics.reach'), 'value' => 40],
['label' => __('analytics.metrics.likes'), 'value' => 5],
['label' => __('analytics.metrics.comments'), 'value' => 1],
['label' => __('analytics.metrics.shares'), 'value' => 2],
['label' => __('analytics.metrics.saves'), 'value' => 3],
['label' => __('analytics.metrics.views'), 'value' => 120],
]);

Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/media_123/insights")
&& $request['metric'] === 'reach,likes,comments,shares,saved,views');
});

test('instagram analytics reads story metrics with views instead of retired impressions', function () {
Http::fake([
"{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([
['name' => 'reach', 'value' => 35],
['name' => 'views', 'value' => 50],
['name' => 'replies', 'value' => 2],
])),
]);

$metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramStory));

expect($metrics)->toBe([
['label' => __('analytics.metrics.reach'), 'value' => 35],
['label' => __('analytics.metrics.views'), 'value' => 50],
['label' => __('analytics.metrics.replies'), 'value' => 2],
]);

Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/media_123/insights")
&& $request['metric'] === 'reach,views,replies');
});

test('instagram analytics does not request the retired plays or impressions metrics', function (ContentType $contentType) {
Http::fake();

(new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform($contentType));

Http::assertNotSent(fn ($request) => str_contains($request['metric'] ?? '', 'plays')
|| str_contains($request['metric'] ?? '', 'impressions'));
})->with([
'feed' => ContentType::InstagramFeed,
'reel' => ContentType::InstagramReel,
'story' => ContentType::InstagramStory,
]);

test('instagram analytics ignores metrics it did not ask for', function () {
Http::fake([
"{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([
['name' => 'reach', 'value' => 12],
['name' => 'plays', 'value' => 99],
['name' => 'likes', 'value' => 1],
])),
]);

$metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramFeed));

expect($metrics)->toBe([
['label' => __('analytics.metrics.reach'), 'value' => 12],
['label' => __('analytics.metrics.likes'), 'value' => 1],
]);
});

test('instagram analytics reports an api rejection as unsupported', function () {
Http::fake([
"{$this->graph}/media_123/insights*" => Http::response([
'error' => ['message' => '(#100) plays is not a valid metric', 'code' => 100],
], 400),
]);

$metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramReel));

expect($metrics)->toBe(['unsupported' => true, 'reason' => 'api_error']);
});

test('instagram analytics reports a missing platform post id as unsupported', function () {
Http::fake();

$metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramFeed, null));

expect($metrics)->toBe(['unsupported' => true, 'reason' => 'missing_post_id']);

Http::assertNothingSent();
});
Loading