Skip to content

Added new top referrers endpoint for post analytics #23149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

cmraible
Copy link
Collaborator

@cmraible cmraible commented May 2, 2025

ref https://linear.app/ghost/issue/PROD-1542/adjust-conversion-attribution-to-reflect-free-members-and-paid-members

On the new Post Analytics page, we display a table of the top sources for a particular post, sorted by free members, paid members and MRR. This commit adds the API endpoint for this data, and wires it up to the frontend.

Copy link
Contributor

coderabbitai bot commented May 2, 2025

"""

Walkthrough

This change set introduces a new feature for tracking and displaying post-level referrer statistics throughout the application stack. On the backend, a new API endpoint /stats/posts/:id/top-referrers is conditionally registered based on a feature flag. Supporting this endpoint, new service methods and types are added to aggregate referrer metrics—such as free members, paid members, and MRR—per post, with robust input validation and date filtering. On the frontend, new API hooks and React hooks are implemented to fetch and process these statistics, replacing mock data in analytics components. Comprehensive unit and end-to-end tests are added to validate referrer-based statistics functionality.

Possibly related PRs

  • Added static Growth tab to Post Analytics #23110: Introduces the initial static Growth tab component without data fetching, which the current PR extends by adding dynamic data-fetching hooks and API types for post referrer statistics and updating the Growth component accordingly.

Suggested labels

deploy-to-staging
"""

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

apps/posts/src/hooks/usePostReferrers.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

ESLint couldn't find the plugin "eslint-plugin-react-hooks".

(The package "eslint-plugin-react-hooks" was not found when loaded as a Node module from the directory "/apps/posts".)

It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:

npm install eslint-plugin-react-hooks@latest --save-dev

The plugin "eslint-plugin-react-hooks" was referenced from the config file in "apps/posts/.eslintrc.cjs".

If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f21ab8c and 83bb760.

📒 Files selected for processing (1)
  • apps/posts/src/hooks/usePostReferrers.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/posts/src/hooks/usePostReferrers.ts
⏰ Context from checks skipped due to timeout of 90000ms (11)
  • GitHub Check: Database tests (Node 20.11.1, sqlite3)
  • GitHub Check: Regression tests (Node 20.11.1, sqlite3)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Database tests (Node 22.13.1, mysql8)
  • GitHub Check: Regression tests (Node 20.11.1, mysql8)
  • GitHub Check: Database tests (Node 20.11.1, mysql8)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Admin-X Settings tests
  • GitHub Check: Unit tests (Node 20.11.1)
  • GitHub Check: Lint
  • GitHub Check: i18n
✨ Finishing Touches
  • 📝 Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@cmraible cmraible force-pushed the chris-prod-1542-adjust-conversion-attribution-to-reflect-free-members-paid branch from bbf9196 to a58a993 Compare May 5, 2025 19:08
@cmraible cmraible force-pushed the chris-prod-1542-adjust-conversion-attribution-to-reflect-free-members-paid branch from 9e11d5f to eaa4211 Compare May 5, 2025 23:43
@cmraible cmraible marked this pull request as ready for review May 5, 2025 23:58
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🔭 Outside diff range comments (1)
apps/posts/src/views/PostAnalytics/Growth.tsx (1)

27-36: 🛠️ Refactor suggestion

Potential undefined access – guard totals

totals is used unconditionally once isLoading is false. An API error or empty payload yields undefined, causing runtime crashes. Provide a default object or nullish coalescing.

-<KpiCardValue>{formatNumber(totals.free_members)}</KpiCardValue>
+<KpiCardValue>{formatNumber(totals?.free_members ?? 0)}</KpiCardValue>
🧹 Nitpick comments (4)
apps/posts/src/views/PostAnalytics/Growth.tsx (1)

10-12: Empty interface – switch to a type alias or remove altogether

postAnalyticsProps is declared but contains no members, triggering Biome’s noEmptyInterface rule and adding dead weight to the bundle.

-interface postAnalyticsProps {}
+type PostAnalyticsProps = Record<string, never>; // or remove entirely if unused
ghost/core/core/server/services/stats/PostsStatsService.js (1)

316-344: Date parsing relies on new Date() – prone to TZ and format drift

new Date('YYYY-MM-DD') is implementation-dependent (UTC vs local). A safer approach is to:

  • Use Date.parse only with ISO strings containing timezone, or
  • Validate with a library (Luxon / Day.js) and convert to UTC explicitly.
-const fromDate = new Date(options.date_from);
+const {DateTime} = require('luxon');
+const fromDate = DateTime.fromISO(options.date_from, {zone: 'utc'});
+if (fromDate.isValid) {
+    query.where(dateColumn, '>=', fromDate.toSQLDate());
+}

This avoids silent off-by-one-day errors.

ghost/core/test/unit/server/services/stats/posts.test.js (2)

121-138: Add indexes on referrer_source in test schema for realistic query plans

Real queries aggregate by referrer_source; without an index, the tests may pass but mask performance issues in production. A small change keeps the schema aligned:

-    table.string('referrer_source');
+    table.string('referrer_source').index();

Same applies to members_subscription_created_events.


287-289: Variable names (member_future) are misleading

member_future is timestamped 30 days ago, older than member_past (10 days ago).
Consider renaming to convey temporal intent (e.g. member_older) to avoid confusion for future maintainers.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1ab13a0 and 1697141.

📒 Files selected for processing (8)
  • apps/admin-x-framework/src/api/stats.ts (3 hunks)
  • apps/posts/src/hooks/usePostReferrers.ts (1 hunks)
  • apps/posts/src/views/PostAnalytics/Growth.tsx (3 hunks)
  • ghost/core/core/server/api/endpoints/stats.js (1 hunks)
  • ghost/core/core/server/services/stats/PostsStatsService.js (5 hunks)
  • ghost/core/core/server/services/stats/StatsService.js (1 hunks)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js (2 hunks)
  • ghost/core/test/unit/server/services/stats/posts.test.js (11 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
apps/admin-x-framework/src/api/stats.ts (1)
apps/posts/src/hooks/usePostReferrers.ts (1)
  • usePostReferrers (24-39)
apps/posts/src/hooks/usePostReferrers.ts (1)
apps/admin-x-framework/src/api/stats.ts (2)
  • PostReferrerStatItem (51-56)
  • usePostReferrers (84-87)
🪛 Biome (1.9.4)
apps/posts/src/views/PostAnalytics/Growth.tsx

[error] 9-10: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)

⏰ Context from checks skipped due to timeout of 90000ms (8)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Database tests (Node 20.11.1, sqlite3)
  • GitHub Check: Database tests (Node 22.13.1, mysql8)
  • GitHub Check: Regression tests (Node 20.11.1, mysql8)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Database tests (Node 20.11.1, mysql8)
  • GitHub Check: Unit tests (Node 20.11.1)
  • GitHub Check: Admin-X Settings tests
🔇 Additional comments (10)
ghost/core/core/server/web/api/endpoints/admin/routes.js (2)

6-6: Good addition of the labs import.

The labs import is correctly added to enable feature flagging for the new traffic analytics routes.


158-162: Well-structured feature flag implementation for new traffic analytics routes.

The new routes for advanced analytics features are properly guarded behind the trafficAnalytics feature flag, which allows for controlled rollout. The new endpoint /stats/referrers/posts/:id/alpha follows the same authentication pattern as other similar endpoints.

The "alpha" suffix in the route path clearly indicates this is an experimental feature, which is a good practice for API versioning.

ghost/core/core/server/services/stats/StatsService.js (1)

66-72: New method for retrieving post referrer statistics.

The implementation follows the service's existing patterns - delegating to a specialized service (posts) and returning the result. This is consistent with other methods like getTopPosts.

Note that unlike getPostReferrers() at line 59, this new method returns the raw result instead of wrapping it in a {data, meta} structure. This slight inconsistency appears intentional based on how the data is used downstream.

apps/posts/src/hooks/usePostReferrers.ts (3)

1-3: Appropriate imports for the React hook.

Good practice to separate the API hook import (renamed to avoid confusion) and the React hooks needed for memoization.


4-22: Well-implemented helper function for calculating totals.

The calculateTotals function:

  • Handles the edge case of empty data arrays properly
  • Safely handles possible null/undefined values using the || 0 fallback
  • Returns a consistently structured object whether data exists or not

This makes the hook more robust against API inconsistencies or empty states.


24-39: Clean implementation of the custom hook with proper memoization.

The hook follows React best practices:

  • Uses useMemo to avoid unnecessary recalculations of derived data
  • Properly handles the case when API data is not yet available with the || [] fallback
  • Returns a clean, well-structured object with loading state and both raw and processed data

This implementation makes it easy for components to consume the hook and handle various states (loading, error, empty data).

apps/admin-x-framework/src/api/stats.ts (3)

51-61: Well-structured type definitions for post referrer statistics.

The types are clearly defined with descriptive field names:

  • PostReferrerStatItem captures the essential metrics per referrer source
  • PostReferrersResponseType follows the established pattern for API responses with stats and metadata

These types will provide good TypeScript support for components consuming this data.


68-68: Consistent data type constant declaration.

The constant follows the naming pattern used for other data types in this file.


84-87: API hook implementation follows established patterns.

The usePostReferrers hook:

  • Uses the createQueryWithId factory function consistent with other ID-based queries
  • Properly maps the ID parameter to the correct API path
  • Points to the alpha version of the endpoint, matching the route registration

This maintains consistency with the project's API interaction patterns.

ghost/core/test/unit/server/services/stats/posts.test.js (1)

46-56: Column name hard-codes new behaviour – consider test-only guard

The helper now unconditionally writes referrer_source. In production code this column is behind a feature flag; if the flag is toggled off, migrations may be absent and these tests will fail.
Add a capability check (e.g. if (await db.schema.hasColumn('members_created_events', 'referrer_source'))) or ensure the test suite always migrates the latest schema.

Comment on lines +69 to 80
{postReferrers?.map(row => (
<TableRow key={row.source}>
<TableCell>
<a className='inline-flex items-center gap-2 font-medium' href={`https://${source.title}`} rel="noreferrer" target='_blank'>
<a className='inline-flex items-center gap-2 font-medium' href={`https://${row.source}`} rel="noreferrer" target='_blank'>
<img
className="size-4"
src={`https://www.faviconextractor.com/favicon/${source.title || 'direct'}?larger=true`}
src={`https://www.faviconextractor.com/favicon/${row.source || 'direct'}?larger=true`}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.src = STATS_DEFAULT_SOURCE_ICON_URL;
}} />
<span>{source.title || 'Direct'}</span>
<span>{row.source || 'Direct'}</span>
</a>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Invalid URL & favicon fetch when row.source is falsey

href={https://${row.source}} and the favicon call both misbehave on an empty value (e.g., “Direct” traffic is typically stored as null). Either:

  1. Filter out null sources in the query (backend), or
  2. Handle them gracefully in the UI:
-<a ... href={`https://${row.source}`} ...>
+<a
+  ... 
+  href={row.source ? `https://${row.source}` : undefined}
+  rel="noopener noreferrer"
+  target={row.source ? '_blank' : undefined}
+>

Also add noopener to the rel attribute for security.

Comment on lines 171 to 200
postReferrersAlpha: {
headers: {
cacheInvalidate: false
},
options: [
'order',
'limit',
'date_from',
'date_to',
'timezone'
],
data: [
'id'
],
permissions: {
docName: 'posts',
method: 'browse'
},
cache: statsService.cache,
generateCacheKeyData(frame) {
return {
method: 'getReferrersForPost',
data: {
id: frame.data.id
}
};
},
async query(frame) {
return await statsService.api.getReferrersForPost(frame.data.id, frame.options);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Missing payload validation for id, limit, and order

postReferrersAlpha directly forwards frame.data.id and frame.options to the service layer without confirming:

  • id exists and is a valid UUID
  • limit is a positive integer within sane bounds
  • order matches the whitelist (free_members, paid_members, mrr)

An invalid request reaches the DB layer before being rejected, polluting logs and wasting CPU cycles.

Consider adding validation: { ... } or an explicit check in query() similar to other controllers.


🏁 Script executed:

#!/usr/bin/env bash
set -e

# Locate the stats endpoints file
echo "Locating stats.js..."
fd stats.js

# Inspect postReferrersAlpha definition
echo "Inspecting postReferrersAlpha snippet..."
rg -n "postReferrersAlpha" -A5 -B5 ghost/core/core/server/api/endpoints/stats.js

# Check for any existing validation in stats.js
echo "Searching for 'validation:' in stats.js..."
rg -n "validation:" ghost/core/core/server/api/endpoints/stats.js || echo "No validation property found in stats.js"

# Check how other endpoints declare validation
echo "Searching for 'validation:' across all endpoints..."
rg -n "validation:" ghost/core/core/server/api/endpoints

Length of output: 10778


Add input validation to postReferrersAlpha endpoint

The postReferrersAlpha handler in ghost/core/core/server/api/endpoints/stats.js (lines 171–200) currently forwards frame.data.id and frame.options straight into the service layer. We should validate:

  • data.id exists and is a valid UUID
  • options.limit is an integer (e.g. ≥ 1, ≤ 100)
  • options.order is one of the allowed values (free_members, paid_members, mrr)

This prevents malformed requests hitting the DB and reduces error noise.

Suggested diff (insert before cache: statsService.cache,):

 postReferrersAlpha: {
     headers: {
         cacheInvalidate: false
     },
-    options: [
+    options: [
         'order',
         'limit',
         'date_from',
         'date_to',
         'timezone'
     ],
     data: [
         'id'
     ],
+    validation: {
+        data: {
+            id: { required: true, type: 'uuid' }
+        },
+        options: {
+            limit: { required: false, type: 'integer', min: 1, max: 100 },
+            order: { required: false, type: 'string', values: ['free_members', 'paid_members', 'mrr'] }
+        }
+    },
     permissions: {
         docName: 'posts',
         method: 'browse'
     },
     cache: statsService.cache,
     …
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
postReferrersAlpha: {
headers: {
cacheInvalidate: false
},
options: [
'order',
'limit',
'date_from',
'date_to',
'timezone'
],
data: [
'id'
],
permissions: {
docName: 'posts',
method: 'browse'
},
cache: statsService.cache,
generateCacheKeyData(frame) {
return {
method: 'getReferrersForPost',
data: {
id: frame.data.id
}
};
},
async query(frame) {
return await statsService.api.getReferrersForPost(frame.data.id, frame.options);
}
postReferrersAlpha: {
headers: {
cacheInvalidate: false
},
options: [
'order',
'limit',
'date_from',
'date_to',
'timezone'
],
data: [
'id'
],
validation: {
data: {
id: { required: true, type: 'uuid' }
},
options: {
limit: { required: false, type: 'integer', min: 1, max: 100 },
order: { required: false, type: 'string', values: ['free_members', 'paid_members', 'mrr'] }
}
},
permissions: {
docName: 'posts',
method: 'browse'
},
cache: statsService.cache,
generateCacheKeyData(frame) {
return {
method: 'getReferrersForPost',
data: {
id: frame.data.id
}
};
},
async query(frame) {
return await statsService.api.getReferrersForPost(frame.data.id, frame.options);
}
}

Comment on lines +151 to +154
const results = await query
.orderBy(orderField, orderDirection)
.limit(limit);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

WHERE NOT NULL drops ‘Direct’ traffic inadvertently

The final query excludes rows where referrer_source is NULL, yet the UI expects a “Direct” bucket. Either:

  1. Keep the rows and map NULL → 'direct':
- .whereNotNull('ar.source');
+ // Keep NULL rows; label later
  1. Or coalesce in-query:
-select 'ar.source',
+select coalesce(ar.source, 'direct') as source,

Removing the filter restores parity between API and frontend.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +290 to 303
_buildMrrReferrersSubquery(postId, options) {
const knex = this.knex;
let subquery = knex('members_subscription_created_events as msce')
.select('msce.referrer_source as source')
.sum('mpse.mrr_delta as mrr')
.join('members_paid_subscription_events as mpse', function () {
this.on('mpse.subscription_id', '=', 'msce.subscription_id');
// Ensure we join on member_id as well for accuracy if subscription_id isn't unique across members? (Safeguard)
this.andOn('mpse.member_id', '=', 'msce.member_id');
})
.where('msce.attribution_id', postId)
.where('msce.attribution_type', 'post')
.groupBy('msce.referrer_source');

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Possible double counting in MRR subquery

Grouping only by msce.referrer_source while joining members_paid_subscription_events can produce duplicate rows if multiple mpse rows share the same subscription/referrer. Add msce.subscription_id (or a surrogate unique key) to the GROUP BY, or wrap the join in a CTE that deduplicates per subscription before summing.


#!/bin/bash

Locate the PostsStatsService file

FILE=$(fd --exact-name PostsStatsService.js)
echo "Found file: $FILE"

Print lines around the MRR referrers subquery (approx lines 260–350)

sed -n '260,350p' "$FILE"

Show definition of the helper and its usage

rg -n "_buildMrrReferrersSubquery" -C3 "$FILE"
rg -n "mrr_referrers" -C5 "$FILE"


Ensure accurate MRR calculation in _buildMrrReferrersSubquery

The current implementation joins all members_paid_subscription_events for a subscription, so any subscription with multiple events (renewals, upgrades, churn) will have its mrr_delta counted multiple times when summed by referrer_source. You can address this in one of two ways:

  1. Filter to only the initial subscription‐created event:
let subquery = knex('members_subscription_created_events as msce')
  .select('msce.referrer_source as source')
  .sum('mpse.mrr_delta as mrr')
  .join('members_paid_subscription_events as mpse', function () {
    this.on('mpse.subscription_id', '=', 'msce.subscription_id')
        .andOn('mpse.member_id', '=', 'msce.member_id');
  })
  .where('msce.attribution_id', postId)
  .where('msce.attribution_type', 'post')
  .andWhere('mpse.event', '=', 'subscription_created')    // ← only count the initial event
  .groupBy('msce.referrer_source');
  1. Dedupe in a CTE (or subquery) so you only ever join a single mpse row per subscription:
WITH initial_mrr AS (
  SELECT subscription_id, mrr_delta
  FROM members_paid_subscription_events
  WHERE event = 'subscription_created'
)
SELECT
  msce.referrer_source AS source,
  SUM(im.mrr_delta)    AS mrr
FROM members_subscription_created_events AS msce
JOIN initial_mrr AS im
  ON im.subscription_id = msce.subscription_id
WHERE msce.attribution_id   = :postId
  AND msce.attribution_type = 'post'
GROUP BY msce.referrer_source;

If you prefer to keep the single‐query style, at minimum add msce.subscription_id to the GROUP BY to avoid unintentionally aggregating duplicate rows:

.groupBy('msce.referrer_source', 'msce.subscription_id')

[file: ghost/core/core/server/services/stats/PostsStatsService.js
Lines ~290–303]

Comment on lines +83 to 87
async function _createFreeSignup(postId, referrerSource,memberId = null) {
memberIdCounter += 1;
const finalMemberId = memberId || `member_${memberIdCounter}`;
await _createFreeSignupEvent(postId, finalMemberId);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

_createFreeSignup does not forward referrerSource, causing NULL values

_createFreeSignupEvent now expects a referrerSource, but the wrapper omits that argument.
Tests that rely on _createFreeSignup to populate the new column will silently insert NULL, which could hide regressions and skew referrer statistics if this helper is reused elsewhere.

 async function _createFreeSignup(postId, referrerSource,memberId = null) {
     memberIdCounter += 1;
     const finalMemberId = memberId || `member_${memberIdCounter}`;
-    await _createFreeSignupEvent(postId, finalMemberId);
+    await _createFreeSignupEvent(postId, finalMemberId, referrerSource);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function _createFreeSignup(postId, referrerSource,memberId = null) {
memberIdCounter += 1;
const finalMemberId = memberId || `member_${memberIdCounter}`;
await _createFreeSignupEvent(postId, finalMemberId);
}
async function _createFreeSignup(postId, referrerSource,memberId = null) {
memberIdCounter += 1;
const finalMemberId = memberId || `member_${memberIdCounter}`;
await _createFreeSignupEvent(postId, finalMemberId, referrerSource);
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/posts/src/views/PostAnalytics/Growth.tsx (1)

73-81: Invalid URL & favicon fetch when row.source is falsey

href={https://${row.source}} and the favicon call both misbehave on an empty value (e.g., "Direct" traffic is typically stored as null). Either:

  1. Filter out null sources in the query (backend), or
  2. Handle them gracefully in the UI:
-<a className='inline-flex items-center gap-2 font-medium' href={`https://${row.source}`} rel="noreferrer" target='_blank'>
+<a 
+  className='inline-flex items-center gap-2 font-medium'
+  href={row.source ? `https://${row.source}` : undefined}
+  rel="noopener noreferrer"
+  target={row.source ? '_blank' : undefined}
+>

Also add noopener to the rel attribute for security.

🧹 Nitpick comments (1)
apps/posts/src/views/PostAnalytics/Growth.tsx (1)

10-11: Replace empty interface with a type alias or remove if unused.

The empty interface postAnalyticsProps doesn't add any value and is equivalent to {}. If you don't need to define props for this component, you can simplify it.

-interface postAnalyticsProps {}
-
-const Growth: React.FC<postAnalyticsProps> = () => {
+const Growth: React.FC = () => {

Alternatively, if you plan to add props later, use a type alias for better clarity:

-interface postAnalyticsProps {}
+type PostAnalyticsProps = {
+  // future props will go here
+};
+
+const Growth: React.FC<PostAnalyticsProps> = () => {

Also, consider following TypeScript naming conventions using PascalCase for interfaces and type aliases.

🧰 Tools
🪛 Biome (1.9.4)

[error] 10-11: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1697141 and 5474b6f.

📒 Files selected for processing (1)
  • apps/posts/src/views/PostAnalytics/Growth.tsx (3 hunks)
🧰 Additional context used
🪛 Biome (1.9.4)
apps/posts/src/views/PostAnalytics/Growth.tsx

[error] 10-11: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)

⏰ Context from checks skipped due to timeout of 90000ms (11)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Regression tests (Node 20.11.1, sqlite3)
  • GitHub Check: Unit tests (Node 20.11.1)
  • GitHub Check: Regression tests (Node 20.11.1, mysql8)
  • GitHub Check: Database tests (Node 20.11.1, sqlite3)
  • GitHub Check: Database tests (Node 22.13.1, mysql8)
  • GitHub Check: Database tests (Node 20.11.1, mysql8)
  • GitHub Check: Admin-X Settings tests
  • GitHub Check: i18n
  • GitHub Check: Lint
🔇 Additional comments (2)
apps/posts/src/views/PostAnalytics/Growth.tsx (2)

7-8: Good implementation of dynamic post ID with hooks.

The code now properly gets the post ID from URL parameters instead of using a hardcoded value, which addresses the previous review issue. This makes the component reusable across different posts and testable in isolation.

Also applies to: 15-16


36-36: Properly integrated dynamic analytics data in KPI cards.

The KPI cards now correctly display dynamic data from the API instead of hardcoded values, making the analytics dashboard show real data for each post.

Also applies to: 43-43, 50-50

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/admin/stats.test.js (1)

249-264: Good basic test coverage, consider enhancing data validation

The test successfully verifies that the new /stats/posts/:id/top-referrers endpoint exists and returns a properly formatted response with a stats array. This aligns well with the PR objective of adding a new attribution endpoint.

Consider enhancing this test to validate the actual structure of the returned data, such as verifying expected fields (source, free_members, paid_members, etc.) and expected values based on fixture data. Additionally, testing filter parameters (date ranges) and error handling scenarios (invalid post ID) would strengthen the test coverage.

You could extend the test to validate the structure with something like:

.expect(({body}) => {
    assert.ok(body.stats, 'Response should contain a stats property');
    assert.ok(Array.isArray(body.stats), 'body.stats should be an array');
    
    // Validate data structure if any referrers exist
    if (body.stats.length > 0) {
        const referrer = body.stats[0];
        assert.ok('source' in referrer, 'Referrer should contain a source property');
        assert.ok('free_members' in referrer, 'Referrer should contain a free_members property');
        assert.ok('paid_members' in referrer, 'Referrer should contain a paid_members property');
    }
});
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5474b6f and e8f700f.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • apps/admin-x-framework/src/api/stats.ts (3 hunks)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js (2 hunks)
  • ghost/core/test/e2e-api/admin/stats.test.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • apps/admin-x-framework/src/api/stats.ts
⏰ Context from checks skipped due to timeout of 90000ms (11)
  • GitHub Check: Database tests (Node 20.11.1, sqlite3)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Regression tests (Node 20.11.1, sqlite3)
  • GitHub Check: Database tests (Node 22.13.1, mysql8)
  • GitHub Check: Regression tests (Node 20.11.1, mysql8)
  • GitHub Check: Database tests (Node 20.11.1, mysql8)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Admin-X Settings tests
  • GitHub Check: Unit tests (Node 20.11.1)
  • GitHub Check: Lint
  • GitHub Check: i18n

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/posts/src/views/PostAnalytics/Growth.tsx (1)

73-73: ⚠️ Potential issue

Invalid URL construction when row.source is falsey

The href={https://${row.source}} will create invalid URLs for direct traffic (where source is null/empty) or sources that already include the protocol.

-<a className='inline-flex items-center gap-2 font-medium' href={`https://${row.source}`} rel="noreferrer" target='_blank'>
+<a 
+  className='inline-flex items-center gap-2 font-medium' 
+  href={row.source ? `https://${row.source}` : undefined} 
+  rel="noopener noreferrer" 
+  target={row.source ? '_blank' : undefined}
+>

Note: Added noopener to rel for security when opening external links.

🧹 Nitpick comments (2)
apps/posts/src/views/PostAnalytics/Growth.tsx (2)

11-11: Replace empty interface with more specific type or remove entirely

The empty interface postAnalyticsProps doesn't provide any value and is flagged by static analysis.

-interface postAnalyticsProps {}
+type PostAnalyticsProps = Record<string, never>;

Or simply:

-interface postAnalyticsProps {}
-const Growth: React.FC<postAnalyticsProps> = () => {
+const Growth: React.FC = () => {
🧰 Tools
🪛 Biome (1.9.4)

[error] 10-11: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)


28-29: Add better loading state

The current loading state just shows "Loading" text. Consider using a proper loading skeleton or spinner component for a better user experience.

-{isLoading ? 'Loading' :
+{isLoading ? <LoadingPlaceholder /> :

Where LoadingPlaceholder could be a component that displays skeleton UI elements matching the layout of the loaded data.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e8f700f and de69358.

📒 Files selected for processing (1)
  • apps/posts/src/views/PostAnalytics/Growth.tsx (3 hunks)
🧰 Additional context used
🪛 Biome (1.9.4)
apps/posts/src/views/PostAnalytics/Growth.tsx

[error] 10-11: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)

🔇 Additional comments (3)
apps/posts/src/views/PostAnalytics/Growth.tsx (3)

7-8: Good implementation of dynamic data fetching

The component now properly retrieves data based on the post ID from URL parameters instead of using hardcoded values. This makes the component reusable across different posts and testable in isolation.

Also applies to: 15-16


36-36: Dynamic KPI card values correctly implemented

KPI cards now display actual metrics from the API response, with proper formatting applied.

Also applies to: 43-43, 50-50


70-71: Table population is correctly implemented with fallbacks

Good job implementing fallbacks for direct traffic with row.source || 'direct' for the favicon URL and row.source || 'Direct' for display text. The metrics are correctly formatted with appropriate prefixes.

Also applies to: 76-76, 80-80, 83-85

@cmraible cmraible changed the title Added updated attribution endpoint and hooked it up to Post Analytics… Added new top referrers endpoint for post analytics May 6, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
apps/posts/src/views/PostAnalytics/Growth.tsx (2)

76-76: Improve favicon handling

While you've added a fallback for direct traffic and have an error handler, consider implementing a more robust solution as suggested in the previous review. The current implementation still makes an external request that might fail or timeout.

Are there any reliable self-hosted options for favicon extraction?

73-73: ⚠️ Potential issue

Invalid URL construction when row.source is falsey

The URL construction href={https://${row.source}} will fail when row.source is falsey (null/undefined), which commonly happens for direct traffic. This issue was pointed out in a previous review but hasn't been properly addressed.

-<a className='inline-flex items-center gap-2 font-medium' href={`https://${row.source}`} rel="noreferrer" target='_blank'>
+<a className='inline-flex items-center gap-2 font-medium' 
+   href={row.source ? `https://${row.source}` : undefined} 
+   rel="noreferrer noopener" 
+   target={row.source ? '_blank' : undefined}>
🧹 Nitpick comments (2)
apps/posts/src/views/PostAnalytics/Growth.tsx (2)

50-50: Consider using dynamic data for KPI values

While you've correctly implemented the formatting for the MRR value, you're still using a hardcoded value (180). Consider using data from the API response to display actual MRR metrics.

-<KpiCardValue>+${formatNumber(180)}</KpiCardValue>
+<KpiCardValue>+${formatNumber(totals?.mrr || 0)}</KpiCardValue>

11-11: Empty interface could be simplified

The postAnalyticsProps interface is empty which is equivalent to {}. Consider using a type alias instead or removing it if no props are needed.

-interface postAnalyticsProps {}
+type PostAnalyticsProps = Record<string, never>;

Or simply:

-interface postAnalyticsProps {}
-const Growth: React.FC<postAnalyticsProps> = () => {
+const Growth: React.FC = () => {
🧰 Tools
🪛 Biome (1.9.4)

[error] 10-11: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9c50a45 and f21ab8c.

📒 Files selected for processing (2)
  • apps/posts/src/hooks/usePostReferrers.ts (1 hunks)
  • apps/posts/src/views/PostAnalytics/Growth.tsx (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/posts/src/hooks/usePostReferrers.ts
🧰 Additional context used
🪛 Biome (1.9.4)
apps/posts/src/views/PostAnalytics/Growth.tsx

[error] 10-11: An empty interface is equivalent to {}.

Safe fix: Use a type alias instead.

(lint/suspicious/noEmptyInterface)

🔇 Additional comments (5)
apps/posts/src/views/PostAnalytics/Growth.tsx (5)

7-8: Good implementation of dynamic data fetching

Nice work importing the necessary hooks to fetch post analytics data dynamically. This properly implements the data fetching for the top referrers endpoint.


15-16: Properly fixed hardcoded post ID

Great job replacing the hardcoded post ID with the useParams() hook. This addresses the previous review comment and makes the component properly handle dynamic post IDs.


70-71: Good implementation of dynamic data mapping

Nice work replacing mock data with actual post referrers data from the API.


80-80: Good handling of null source

Nice work adding a fallback display text for direct traffic when source is null or undefined.


83-85: Well-formatted metrics display

Good job using the formatNumber utility for consistent formatting of metrics in the table.

@cmraible cmraible merged commit 9704c9e into main May 6, 2025
26 checks passed
@cmraible cmraible deleted the chris-prod-1542-adjust-conversion-attribution-to-reflect-free-members-paid branch May 6, 2025 02:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant