From 702ee677ee03b3e901bae3b42421b09e0d1972bf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 20 Aug 2026 16:07:35 -0300 Subject: [PATCH] Read X post metrics from the timeline that already returned them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analytics fetched the account's timeline for post ids, then turned around and looked the same ids up again through GET /2/tweets purely to read the public_metrics the first request could have returned. The timeline call asked for start_time, end_time and max_results — never tweet.fields. Both endpoints bill per Post returned, so the second pass claimed the same resources a second time, took a second round-trip, and spent a second slice of the same rate limit. For an account with 250 posts in range that is 6 requests where 3 will do. The saving is in round-trips and rate limit rather than dollars: X deduplicates a resource within a 24-hour UTC window, so the second read of an id already read that day is not charged again. But the docs call that a soft guarantee that "may result in resources not being deduplicated" — this stops leaning on it for 250 resources per analytics load. Behaviour is unchanged: same totals, same 5-page ceiling, same empty result when the account posted nothing in range. The page cap is now a named constant, since it bounds what one load can cost as much as how long it takes. Adds the first tests for XAnalytics::getMetrics, covering the totals, the pagination, and that the metrics arrive on the timeline request. --- app/Services/Social/XAnalytics.php | 108 +++++++++++++---------------- tests/Feature/XAnalyticsTest.php | 68 ++++++++++++++++++ 2 files changed, 117 insertions(+), 59 deletions(-) create mode 100644 tests/Feature/XAnalyticsTest.php diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index 618a87eff..6833a0882 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -16,6 +16,12 @@ class XAnalytics { use HasSocialHttpClient; + /** + * Each page is billed per Post returned, so this bounds what one analytics + * load can cost as much as it bounds how long it takes. + */ + private const MAX_TIMELINE_PAGES = 5; + private string $baseUrl; private string $accessToken; @@ -52,27 +58,54 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si $this->accessToken = $account->access_token; - // Fetch recent tweets in the period - $tweetIds = $this->fetchTweetIds($account, $since, $until); + [$totals, $tweetCount] = $this->fetchTimelineMetrics($account, $since, $until); - if (empty($tweetIds)) { + if ($tweetCount === 0) { return []; } - // Fetch public_metrics for those tweets - return $this->fetchTweetMetrics($tweetIds); + return [ + ['label' => __('analytics.metrics.impressions'), 'value' => $totals['impression_count']], + ['label' => __('analytics.metrics.likes'), 'value' => $totals['like_count']], + ['label' => __('analytics.metrics.retweets'), 'value' => $totals['retweet_count']], + ['label' => __('analytics.metrics.replies'), 'value' => $totals['reply_count']], + ['label' => __('analytics.metrics.quotes'), 'value' => $totals['quote_count']], + ['label' => __('analytics.metrics.bookmarks'), 'value' => $totals['bookmark_count']], + ]; } - private function fetchTweetIds(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array + /** + * Walk the account's timeline, summing each Post's public_metrics as the + * pages come back. + * + * The metrics are requested from the timeline itself rather than looked up + * afterwards from /2/tweets. Both endpoints bill per Post returned, so + * re-reading the same ids only bought a second round-trip and a second + * claim on the same rate limit — the ids were already in hand, and their + * metrics come along for free on the request that fetched them. + * + * @return array{0: array, 1: int} totals, and how many Posts fed them + */ + private function fetchTimelineMetrics(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { - $ids = []; + $totals = [ + 'impression_count' => 0, + 'like_count' => 0, + 'retweet_count' => 0, + 'reply_count' => 0, + 'quote_count' => 0, + 'bookmark_count' => 0, + ]; + + $tweetCount = 0; $paginationToken = null; - for ($i = 0; $i < 5; $i++) { + for ($page = 0; $page < self::MAX_TIMELINE_PAGES; $page++) { $params = [ 'start_time' => $since->toIso8601ZuluString(), 'end_time' => $until->toIso8601ZuluString(), 'max_results' => 100, + 'tweet.fields' => 'public_metrics', ]; if ($paginationToken) { @@ -90,10 +123,14 @@ private function fetchTweetIds(SocialAccount $account, CarbonInterface $since, C } $data = $response->json(); - $tweets = data_get($data, 'data', []); - foreach ($tweets as $tweet) { - $ids[] = data_get($tweet, 'id'); + foreach (data_get($data, 'data', []) as $tweet) { + $tweetCount++; + $metrics = data_get($tweet, 'public_metrics', []); + + foreach (array_keys($totals) as $metric) { + $totals[$metric] += (int) data_get($metrics, $metric, 0); + } } $paginationToken = data_get($data, 'meta.next_token'); @@ -103,54 +140,7 @@ private function fetchTweetIds(SocialAccount $account, CarbonInterface $since, C } } - return $ids; - } - - private function fetchTweetMetrics(array $tweetIds): array - { - $totals = [ - 'impression_count' => 0, - 'like_count' => 0, - 'retweet_count' => 0, - 'reply_count' => 0, - 'quote_count' => 0, - 'bookmark_count' => 0, - ]; - - // X API allows max 100 IDs per request - foreach (array_chunk($tweetIds, 100) as $chunk) { - $response = $this->getHttpClient() - ->get("{$this->baseUrl}/tweets", [ - 'ids' => implode(',', $chunk), - 'tweet.fields' => 'public_metrics', - ]); - - if ($response->failed()) { - Log::warning('X tweets metrics fetch failed', [ - 'body' => $this->redactResponseBody($response->body()), - ]); - - continue; - } - - $tweets = data_get($response->json(), 'data', []); - - foreach ($tweets as $tweet) { - $metrics = data_get($tweet, 'public_metrics', []); - foreach ($totals as $key => &$total) { - $total += data_get($metrics, $key, 0); - } - } - } - - return [ - ['label' => __('analytics.metrics.impressions'), 'value' => $totals['impression_count']], - ['label' => __('analytics.metrics.likes'), 'value' => $totals['like_count']], - ['label' => __('analytics.metrics.retweets'), 'value' => $totals['retweet_count']], - ['label' => __('analytics.metrics.replies'), 'value' => $totals['reply_count']], - ['label' => __('analytics.metrics.quotes'), 'value' => $totals['quote_count']], - ['label' => __('analytics.metrics.bookmarks'), 'value' => $totals['bookmark_count']], - ]; + return [$totals, $tweetCount]; } public function fetchPostMetrics(PostPlatform $postPlatform): array diff --git a/tests/Feature/XAnalyticsTest.php b/tests/Feature/XAnalyticsTest.php new file mode 100644 index 000000000..9258ffb57 --- /dev/null +++ b/tests/Feature/XAnalyticsTest.php @@ -0,0 +1,68 @@ +account = SocialAccount::factory()->x()->create([ + 'workspace_id' => Workspace::factory()->create()->id, + 'platform_user_id' => '4242', + 'token_expires_at' => now()->addHours(2), + ]); + $this->api = config('trypost.platforms.x.api'); +}); + +test('metrics come from the timeline itself instead of a second lookup of the same posts', function () { + Http::fake([ + $this->api.'/users/4242/tweets*' => Http::response([ + 'data' => [ + ['id' => '1', 'public_metrics' => ['impression_count' => 100, 'like_count' => 10, 'retweet_count' => 1, 'reply_count' => 2, 'quote_count' => 3, 'bookmark_count' => 4]], + ['id' => '2', 'public_metrics' => ['impression_count' => 200, 'like_count' => 20, 'retweet_count' => 2, 'reply_count' => 4, 'quote_count' => 6, 'bookmark_count' => 8]], + ], + 'meta' => [], + ], 200), + ]); + + $metrics = app(XAnalytics::class)->getMetrics($this->account); + + // Every post returned is a billed Post read. Re-reading the same ids from + // /2/tweets buys nothing the timeline could not have returned. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/2/tweets?') + || preg_match('#/tweets\?ids=#', $request->url()) === 1); + + expect(collect($metrics)->firstWhere('label', __('analytics.metrics.impressions'))['value'])->toBe(300); + expect(collect($metrics)->firstWhere('label', __('analytics.metrics.likes'))['value'])->toBe(30); +}); + +test('the timeline request asks for public_metrics', function () { + Http::fake([ + $this->api.'/users/4242/tweets*' => Http::response(['data' => [], 'meta' => []], 200), + ]); + + app(XAnalytics::class)->getMetrics($this->account); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'tweet.fields=public_metrics')); +}); + +test('metrics accumulate across paginated timeline pages', function () { + Http::fake([ + $this->api.'/users/4242/tweets*' => Http::sequence() + ->push([ + 'data' => [['id' => '1', 'public_metrics' => ['impression_count' => 100, 'like_count' => 1, 'retweet_count' => 0, 'reply_count' => 0, 'quote_count' => 0, 'bookmark_count' => 0]]], + 'meta' => ['next_token' => 'page2'], + ], 200) + ->push([ + 'data' => [['id' => '2', 'public_metrics' => ['impression_count' => 50, 'like_count' => 2, 'retweet_count' => 0, 'reply_count' => 0, 'quote_count' => 0, 'bookmark_count' => 0]]], + 'meta' => [], + ], 200), + ]); + + $metrics = app(XAnalytics::class)->getMetrics($this->account); + + expect(collect($metrics)->firstWhere('label', __('analytics.metrics.impressions'))['value'])->toBe(150); + expect(collect($metrics)->firstWhere('label', __('analytics.metrics.likes'))['value'])->toBe(3); +});