Skip to content

feat: Google Ads connector#585

Open
Blaumaus wants to merge 4 commits into
mainfrom
feature/google-ads-connector
Open

feat: Google Ads connector#585
Blaumaus wants to merge 4 commits into
mainfrom
feature/google-ads-connector

Conversation

@Blaumaus

@Blaumaus Blaumaus commented Jul 7, 2026

Copy link
Copy Markdown
Member

Changes

#503

If applicable, please describe what changes were made in this pull request.

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

  • New Features
    • Added a Google Ads integration for connecting an account, selecting a customer, and syncing campaign metrics (including periodic background sync).
    • Introduced a new Ads dashboard tab with spend/clicks/session trends, campaign breakdowns, and attributed revenue metrics (ROAS/CPA-style).
    • Added UTM campaign matching to enrich session/profile and traffic-source campaign reporting.
  • Documentation
    • Added Google Ads integration and Ads analytics dashboard documentation, including UTM tagging and revenue attribution requirements.
  • Bug Fixes
    • Improved the integration setup and sync experience with clearer connected/not-connected states and better handling of revoked access.

@Blaumaus Blaumaus self-assigned this Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a Cloud-only Google Ads integration with OAuth connection, encrypted token storage, campaign synchronization, analytics APIs, dashboard views, campaign attribution, database migrations, frontend settings, and documentation.

Changes

Google Ads Integration

Layer / File(s) Summary
Configuration, storage, and shared contracts
backend/.env.example, backend/apps/cloud/src/ads/interfaces/*, backend/apps/cloud/src/project/entity/project.entity.ts, backend/apps/cloud/src/common/utils.ts, backend/migrations/*
Adds Google Ads configuration, persisted project fields, the ad_metrics ClickHouse table, encryption-key support, DTOs, and shared Ads contracts.
OAuth, token management, API synchronization, and analytics
backend/apps/cloud/src/ads/ads.service.ts, backend/apps/cloud/src/ads/adapters/*
Implements OAuth state and callback handling, encrypted token refresh, authenticated Google Ads searches, account discovery, metric synchronization, currency conversion, ClickHouse aggregation, charts, campaign maps, and UTM matching.
Backend endpoints and scheduling
backend/apps/cloud/src/ads/*.controller.ts, backend/apps/cloud/src/ads/ads.module.ts, backend/apps/cloud/src/task-manager/*, backend/apps/cloud/src/app.module.ts
Adds authenticated connection and analytics endpoints, module registration, account backfill, and six-hour scheduled synchronization.
Session and profile attribution
backend/apps/cloud/src/analytics/analytics.service.ts, web/app/lib/models/Project.ts
Adds campaign resolution from UTM values to session and profile detail responses and updates frontend response types.
Frontend API, settings, callback, and dashboard
web/app/api/*, web/app/hooks/*, web/app/routes/*, web/app/pages/Project/Settings/*, web/app/pages/Project/View/*, web/app/pages/Project/tabs/Ads/*, web/app/lib/constants/*
Adds Ads server calls, proxy actions, OAuth callback processing, connection/account controls, project-tab refresh wiring, metric cards, campaign tables, charts, and connection states.
Cross-tab campaign enrichment
web/app/pages/Project/tabs/Profiles/*, web/app/pages/Project/tabs/Sessions/*, web/app/pages/Project/tabs/Traffic/*
Displays matched campaigns in profile and session details and adds Ads spend/CPC data to traffic campaign rows.
Documentation and locale strings
docs/content/docs/*, docs/components/IntegrationsGrid.tsx, docs/lib/source.ts, web/public/locales/en.json
Adds Google Ads setup and dashboard documentation, navigation entries, integration listings, UTM guidance, and English labels.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ProjectSettings
  participant AdsController
  participant AdsService
  participant GoogleAdsAdapter
  participant Google
  User->>ProjectSettings: start Google Ads connection
  ProjectSettings->>AdsController: request connect URL
  AdsController->>AdsService: generateConnectURL
  AdsService-->>ProjectSettings: OAuth URL
  ProjectSettings->>Google: authorize
  Google-->>AdsController: OAuth callback
  AdsController->>AdsService: handleOAuthCallback
  User->>AdsController: select account
  AdsController->>GoogleAdsAdapter: list accounts and currency
  AdsController->>AdsService: persist account and start backfill
Loading
sequenceDiagram
  participant AdsView
  participant AdsAnalyticsController
  participant AdsService
  participant ClickHouse
  AdsView->>AdsAnalyticsController: request dashboard data
  AdsAnalyticsController->>AdsService: fetch stats and chart
  AdsService->>ClickHouse: query metrics, events, and revenue
  ClickHouse-->>AdsService: aggregated data
  AdsService-->>AdsView: dashboard payload
Loading

Possibly related PRs

  • Swetrix/swetrix#509: Both changes extend the deriveKey() purpose union for a new encrypted token type.
  • Swetrix/swetrix#573: Both changes modify project tab loading and rendering paths in ViewProject.tsx.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The template is mostly followed, but the Changes section is effectively empty and does not summarize the PR. Add a brief changes summary describing the Google Ads connector, affected areas, migrations, and docs updates.
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding a Google Ads connector.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/google-ads-connector

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

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/content/docs/integrations/google-ads.mdx (1)

1-92: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Fix formatting to pass CI.

Pipeline reports oxfmt --check formatting issues in this file. Run the formatter locally and commit the result.

npx oxfmt docs/content/docs/integrations/google-ads.mdx
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/integrations/google-ads.mdx` around lines 1 - 92, The
Google Ads docs page needs formatting fixes to satisfy oxfmt --check. Run the
formatter on the Google Ads MDX content and commit the resulting formatting-only
changes, keeping the existing content intact; use the doc content in the
google-ads.mdx file as the target for the cleanup.

Source: Pipeline failures

🧹 Nitpick comments (7)
web/app/pages/Project/tabs/Traffic/TrafficView.tsx (1)

492-510: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Ads campaign map effect re-fetches on unrelated URL param changes.

Depending on the whole searchParams object (instead of just the from/to values actually used) causes this effect to re-run — and hit the Ads backend — whenever any unrelated search param changes (filters, compare toggles, etc.) while the "ca" sub-tab happens to be active.

⚡ Suggested fix
+  const adsFrom = searchParams.get('from') || undefined
+  const adsTo = searchParams.get('to') || undefined
+
   useEffect(() => {
     if (!id || isSelfhosted || panelsActiveTabs.source !== 'ca') {
       return
     }

     fetchCampaignMap(id, {
       period,
-      from: searchParams.get('from') || undefined,
-      to: searchParams.get('to') || undefined,
+      from: adsFrom,
+      to: adsTo,
       timezone,
     })
   }, [
     id,
     panelsActiveTabs.source,
     period,
     timezone,
     fetchCampaignMap,
-    searchParams,
+    adsFrom,
+    adsTo,
   ])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/pages/Project/tabs/Traffic/TrafficView.tsx` around lines 492 - 510,
The campaign map effect in TrafficView re-runs on unrelated URL changes because
it depends on the whole searchParams object instead of only the values it
actually reads. Update the useEffect dependencies around fetchCampaignMap to
track just the derived from/to values (or memoized equivalents) along with id,
panelsActiveTabs.source, period, timezone, and fetchCampaignMap, so the Ads
backend is only hit when those relevant inputs change.
web/app/pages/Project/Settings/ProjectSettings.tsx (2)

1530-1534: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Sync error banner discards the actual error message.

adsSyncError is populated with the real error text from the backend, but the banner only renders a generic static string and never interpolates adsSyncError itself, hiding useful diagnostic info from users trying to fix a broken sync.

 {adsSyncError ? (
   <div className='rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'>
-    {t('project.settings.ads.syncError')}
+    {t('project.settings.ads.syncError', { error: adsSyncError })}
   </div>
 ) : null}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/pages/Project/Settings/ProjectSettings.tsx` around lines 1530 - 1534,
The sync error banner in ProjectSettings is ignoring the real backend error
stored in adsSyncError and only showing a generic translation key. Update the
render block that checks adsSyncError to display the actual adsSyncError value
(optionally alongside the localized label) so users can see the concrete failure
reason. Keep the change localized to the banner JSX in ProjectSettings and
preserve the existing conditional display logic.

656-677: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate Google OAuth URL validation logic.

The accounts.google.com safe-URL check is duplicated verbatim between the GSC and Ads fetcher effects (and will likely be copy-pasted again for future providers). Consider extracting a shared getSafeGoogleAuthUrl(url: string) helper.

♻️ Suggested extraction
const getSafeGoogleAuthUrl = (url: string): string | null => {
  try {
    const parsed = new URL(url)
    if (parsed.protocol !== 'https:') return null
    if (parsed.username || parsed.password) return null
    if (parsed.hostname !== 'accounts.google.com') return null
    return parsed.toString()
  } catch {
    return null
  }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/pages/Project/Settings/ProjectSettings.tsx` around lines 656 - 677,
The Google OAuth URL validation is duplicated in the ProjectSettings effects, so
extract the shared hostname/protocol checks into a reusable getSafeGoogleAuthUrl
helper and use it from the ads-connect flow and the matching GSC path. Keep the
helper in a nearby shared location within ProjectSettings.tsx (or a local
utilities module) and replace the inline URL parsing blocks with calls to that
helper so future provider flows reuse the same logic.
backend/migrations/mysql/2026_07_07_google_ads.sql (1)

1-11: 🚀 Performance & Scalability | 🔵 Trivial

Consider an index to support the cron polling query.

The scheduled sync job (task-manager.service.ts) filters projects by googleAdsRefreshTokenEnc IS NOT NULL AND googleAdsCustomerId IS NOT NULL AND googleAdsSyncError IS NULL every 6 hours. Without a supporting index, this becomes a full table scan on project as the table grows. Consider adding a composite/partial index covering these columns (e.g., googleAdsCustomerId, googleAdsSyncError).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/migrations/mysql/2026_07_07_google_ads.sql` around lines 1 - 11, Add
an index to the new Google Ads columns in this migration so the cron polling
query in task-manager.service.ts does not scan the full project table. Update
the ALTER TABLE for project to include a composite index that supports filtering
by googleAdsCustomerId, googleAdsRefreshTokenEnc, and googleAdsSyncError (or the
closest practical index for the MySQL version used), so the scheduled sync job
can efficiently find eligible projects.
backend/apps/cloud/src/ads/ads.service.ts (1)

496-588: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider narrowing the event-type scan in campaign session/revenue attribution queries.

Neither getSessionsPerCampaignValue nor getRevenuePerCampaignValue filters events.type, unlike most other analytics queries in this file/service. Since so/me/ca attribution fields are session-scoped (identical across a session's rows), restricting to type IN ('pageview', 'custom_event') would reduce scanned rows without changing the uniqExact(psid)/attribution results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/ads/ads.service.ts` around lines 496 - 588, The
campaign attribution queries in getSessionsPerCampaignValue and
getRevenuePerCampaignValue are scanning all event types even though only
session-scoped attribution rows are needed. Add an events.type filter (matching
the other analytics queries, e.g. pageview/custom_event) to both SQL statements
in AdsService so the ClickHouse scan is narrower while preserving the
uniqExact(psid) and revenue attribution results.
backend/apps/cloud/src/analytics/analytics.service.ts (1)

7123-7175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate campaign-matching logic with AdsService.findCampaignByUtmValue.

This private method reimplements the exact same ad_metrics DISTINCT query and normalizeCampaignKey matching found in AdsService.findCampaignByUtmValue (backend/apps/cloud/src/ads/ads.service.ts, lines 894-938). AnalyticsService doesn't inject AdsService, so the two implementations will need to be kept in sync manually going forward.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/analytics/analytics.service.ts` around lines 7123 -
7175, The `findAdCampaignByUtm` method is duplicating the same `ad_metrics`
lookup and `normalizeCampaignKey` matching already implemented in
`AdsService.findCampaignByUtmValue`. Refactor `AnalyticsService` to reuse that
existing logic instead of maintaining a separate copy, either by injecting
`AdsService` or by extracting a shared helper used by both methods. Keep the
return shape in `findAdCampaignByUtm` consistent by mapping from the shared
result if needed.
backend/apps/cloud/src/ads/ads.controller.ts (1)

104-106: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant runtime check.

SelectAdsAccountDto.customerId is already validated via @IsNotEmpty() and @Matches(/^\d{1,20}$/), so a request missing customerId should already be rejected by the global ValidationPipe before reaching this handler, making this check effectively dead code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/ads/ads.controller.ts` around lines 104 - 106, The
`AdsController` handler is duplicating validation that
`SelectAdsAccountDto.customerId` already enforces through class-validator and
the global `ValidationPipe`. Remove the explicit `if (!body?.customerId)` guard
from the controller method that processes the request body, and rely on the DTO
validation in `SelectAdsAccountDto` to reject missing or invalid `customerId`
values before the handler runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts`:
- Around line 242-243: The currency normalization in google-ads.adapter’s
account conversion flow silently defaults to USD when googleAdsCurrency or
revenueCurrency is missing, which can misstate values for non-USD accounts.
Update the logic around the originalCurrency and targetCurrency handling to
detect unknown/missing account currency, avoid assuming USD in that case, and
surface a clear warning or error path so the google ads conversion step does not
proceed with incorrect currency assumptions.
- Around line 86-126: Add request timeouts to the Google Ads fetch calls and
consider retry/backoff for transient failures. In `GoogleAdsAdapter`, update
both `search()` and `listAccessibleAccounts()` to use an `AbortController` (or
equivalent timeout wrapper) so `fetch()` cannot hang indefinitely when
`syncCampaignMetrics` or the account lookup runs inside an HTTP request. Also
handle 429/5xx responses with a small retry strategy before throwing, while
preserving the existing error parsing and pagination behavior in `search()`.
- Around line 242-267: The per-row calls to currencyService.convert() inside the
results loop are doing repeated async conversion work for fixed currencies. In
the sync logic in google-ads.adapter.ts, resolve the conversion once before
iterating over results using originalCurrency and targetCurrency, then apply
that rate locally to each row for cost and conversionsValue; keep the
same-currency behavior unchanged since CurrencyService already no-ops there.

In `@backend/apps/cloud/src/ads/ads-analytics.controller.ts`:
- Around line 39-49: The access check in getViewableProject is missing the
x-password header, so password-protected projects cannot be validated through
the Ads analytics endpoints. Update getViewableProject in the Ads analytics
controller to accept the request headers and pass headers['x-password'] into
projectService.allowedToView alongside project and userId, matching the other
project-view paths used by /log/ads, /log/ads/campaigns, and
/log/ads/campaign-map.

In `@backend/apps/cloud/src/ads/ads.controller.ts`:
- Around line 146-181: The sync endpoint in AdsController currently awaits
googleAdsAdapter.syncCampaignMetrics directly on the request path without any
timeout, so a slow upstream can hang the request indefinitely. Update the sync()
flow to call syncCampaignMetrics through a timeout/abort mechanism (using the
existing adsService/googleAdsAdapter integration points) and make sure the
timeout is handled cleanly before proceeding to updateLastSyncAt. Keep the fix
localized around sync() and syncCampaignMetrics so the request fails fast
instead of waiting forever.

In `@backend/apps/cloud/src/ads/ads.service.ts`:
- Around line 196-207: The Google userinfo lookup in ads.service.ts can hang the
OAuth callback because the fetch has no abort path. Update the fetch call inside
the accountEmail try block to pass an AbortSignal.timeout(...) signal so the
request fails fast if Google is slow, and keep the existing error handling in
place; use the fetch call around the userinfo endpoint and the accountEmail
assignment as the main location to change.
- Around line 661-723: The comparison ranges in getAdsStats overlap because
previousFrom uses groupFrom as the previous window’s upper boundary, which
causes the boundary day to be counted in both current and previous metrics.
Update getAdsStats to compute a non-overlapping previousTo first, then derive
previousFrom from that so the current and previous calls to getCampaignRows use
distinct ranges. Keep the change localized around the periodDays calculation and
the Promise.all inputs.

In `@backend/apps/cloud/src/ads/interfaces/ads.interface.ts`:
- Around line 64-73: AdsCampaignSummary is exported but currently unused, which
triggers knip in CI. Either remove the unused export from ads.interface.ts if it
is dead, or wire AdsCampaignSummary into the relevant ads.service.ts
campaign-summary/campaign-map flow by using it as the explicit type for the
returned summary object so the export is actually consumed.

In `@backend/apps/cloud/src/project/entity/project.entity.ts`:
- Around line 174-206: Add a composite index on the Google Ads sync-eligibility
fields used by syncAdsData so the cron doesn’t scan the full project table each
run. Update ProjectEntity to define an index over googleAdsRefreshTokenEnc,
googleAdsCustomerId, and googleAdsSyncError (or the exact filter set used by the
sync query), using the existing entity decorators so the database can support
the lookup efficiently.

In `@backend/apps/cloud/src/task-manager/task-manager.service.ts`:
- Around line 1523-1553: The syncAdsData loop in task-manager.service.ts is
launching syncCampaignMetrics for all projects at once, which can overwhelm
Google Ads quotas. Update the project iteration to use a bounded concurrency
pattern like the existing mapLimit-based loops in verifyPendingProxyDomains and
recheckLiveProxyDomains, and keep the current per-project try/catch,
markSyncError, and logger.error behavior inside the limited worker.

In `@docs/content/docs/analytics-dashboard/revenue-tracking.mdx`:
- Around line 341-345: The “Ad campaign ROI” paragraph contains a stale
cross-reference to “shown above” for swetrix_session_id that does not exist in
the nearby Stripe/Paddle examples. Update the copy in revenue-tracking.mdx so it
no longer points readers to a nonexistent example, and instead clearly state
that sessionId on the API or swetrix_session_id in metadata should be provided
to trace purchases; keep the wording aligned with the existing Google Ads and
Ads dashboard section.

In `@web/app/api/api.server.ts`:
- Around line 2989-3019: Update getAdsCampaignMapServer to pass an explicit
timeoutMs of 60000 in its serverFetch options so it matches
getAdsDashboardServer and getAdsCampaignsServer. Keep the existing query param
and header handling in place, and add the timeout alongside headers in the
serverFetch call.

In `@web/app/hooks/useAnalyticsProxy.ts`:
- Around line 1187-1189: The campaign-map fetch in useAnalyticsProxy is
swallowing failures by returning null from the catch block without any error
reporting. Update the fetch/error handling in useAnalyticsProxy so it captures
the thrown error, stores a readable error state like the other proxy hooks (for
example alongside the existing useAdsDashboardProxy/useGSCDashboardProxy
pattern), and exposes that state to consumers instead of only returning null.
Keep the catch/finally flow intact, but ensure the hook can surface the failure
through a returned error value or equivalent state.
- Around line 1164-1197: useAdsCampaignMapProxy currently updates map and
currency without guarding against out-of-order responses, so an older
fetchCampaignMap call can overwrite newer state. Add the same stale-response
protection pattern used by useAdsDashboardProxy and useGSCDashboardProxy in this
file: introduce a requestIdRef (or equivalent), increment it for each
fetchCampaignMap invocation, and only apply setMap/setCurrency when the response
matches the latest request before clearing isLoading.

In `@web/app/pages/Project/Settings/ProjectSettings.tsx`:
- Around line 706-712: Gate the entire Google Ads settings section in
ProjectSettings so it is hidden for self-hosted installs, matching the other
Cloud-only integrations. Update the Ads-related render/visibility logic around
the ads section and the ads-status bootstrap fetch so it only runs when
!isSelfhosted, and make sure the error/reset path in the ads-status handling
also clears adsAvailable so the section does not fall through to the connect
prompt. Use the existing isSelfhosted checks and the Ads-related state handlers
in ProjectSettings as the main anchors for the fix.

In `@web/app/pages/Project/tabs/Ads/AdsView.tsx`:
- Around line 450-503: The MetricCard trend indicators in AdsView are reversed
for several metrics. Update the goodChangeDirection props so the spend card uses
'down', while the clicks, adSessions, and attributedRevenue cards use 'up'; keep
the existing valueMapper and change calculations intact. Use the MetricCard
usages in AdsView to locate and swap only these flags.

In `@web/app/routes/ads-connected.tsx`:
- Around line 87-155: AdsHashHandler is rendering the “Invalid callback
parameters” error before the hash callback values are safely available, which
can cause SSR/hydration mismatches. Move the URL parsing out of the initial
useState render path or add a readiness flag in AdsHashHandler so state/code are
only evaluated after client-side parsing completes, and only render the error
StatusPage once parsing has finished and the params are truly missing.
- Around line 42-60: Restrict the fallback in ads-connected flow so
`processAdsToken(code, state)` is only retried for failures that are definitely
before the authorization code is redeemed; update the `serverFetch` handling in
the ads-connected route to stop treating generic server errors as safe
client-side retries, or have the backend return an explicit “already exchanged”
signal. Also adjust the hash-redirect branch in the same route so the server
does not render “Invalid callback parameters” before the client can read the
fragment, preventing the hydration swap and incorrect intermediate state.

In `@web/public/locales/en.json`:
- Around line 2270-2282: The Google Ads connect CTA copy in the ads localization
entry overstates what connection alone enables by implying ROI is immediately
available; update the text in the ads connect message to qualify that attributed
metrics depend on Revenue Tracking and session IDs, while keeping the change
localized to the relevant string key in the ads section.
- Around line 2902-2918: The ads localization string for attribution currently
only mentions the payment-provider metadata flow, so update attributionNote in
the ads section to describe both supported revenue attribution paths: passing
the Swetrix session ID to a payment provider and sending sessionId directly in
the revenue API request body. Reword the copy so it is API-centric and still
references utm_campaign matching for campaigns.

---

Outside diff comments:
In `@docs/content/docs/integrations/google-ads.mdx`:
- Around line 1-92: The Google Ads docs page needs formatting fixes to satisfy
oxfmt --check. Run the formatter on the Google Ads MDX content and commit the
resulting formatting-only changes, keeping the existing content intact; use the
doc content in the google-ads.mdx file as the target for the cleanup.

---

Nitpick comments:
In `@backend/apps/cloud/src/ads/ads.controller.ts`:
- Around line 104-106: The `AdsController` handler is duplicating validation
that `SelectAdsAccountDto.customerId` already enforces through class-validator
and the global `ValidationPipe`. Remove the explicit `if (!body?.customerId)`
guard from the controller method that processes the request body, and rely on
the DTO validation in `SelectAdsAccountDto` to reject missing or invalid
`customerId` values before the handler runs.

In `@backend/apps/cloud/src/ads/ads.service.ts`:
- Around line 496-588: The campaign attribution queries in
getSessionsPerCampaignValue and getRevenuePerCampaignValue are scanning all
event types even though only session-scoped attribution rows are needed. Add an
events.type filter (matching the other analytics queries, e.g.
pageview/custom_event) to both SQL statements in AdsService so the ClickHouse
scan is narrower while preserving the uniqExact(psid) and revenue attribution
results.

In `@backend/apps/cloud/src/analytics/analytics.service.ts`:
- Around line 7123-7175: The `findAdCampaignByUtm` method is duplicating the
same `ad_metrics` lookup and `normalizeCampaignKey` matching already implemented
in `AdsService.findCampaignByUtmValue`. Refactor `AnalyticsService` to reuse
that existing logic instead of maintaining a separate copy, either by injecting
`AdsService` or by extracting a shared helper used by both methods. Keep the
return shape in `findAdCampaignByUtm` consistent by mapping from the shared
result if needed.

In `@backend/migrations/mysql/2026_07_07_google_ads.sql`:
- Around line 1-11: Add an index to the new Google Ads columns in this migration
so the cron polling query in task-manager.service.ts does not scan the full
project table. Update the ALTER TABLE for project to include a composite index
that supports filtering by googleAdsCustomerId, googleAdsRefreshTokenEnc, and
googleAdsSyncError (or the closest practical index for the MySQL version used),
so the scheduled sync job can efficiently find eligible projects.

In `@web/app/pages/Project/Settings/ProjectSettings.tsx`:
- Around line 1530-1534: The sync error banner in ProjectSettings is ignoring
the real backend error stored in adsSyncError and only showing a generic
translation key. Update the render block that checks adsSyncError to display the
actual adsSyncError value (optionally alongside the localized label) so users
can see the concrete failure reason. Keep the change localized to the banner JSX
in ProjectSettings and preserve the existing conditional display logic.
- Around line 656-677: The Google OAuth URL validation is duplicated in the
ProjectSettings effects, so extract the shared hostname/protocol checks into a
reusable getSafeGoogleAuthUrl helper and use it from the ads-connect flow and
the matching GSC path. Keep the helper in a nearby shared location within
ProjectSettings.tsx (or a local utilities module) and replace the inline URL
parsing blocks with calls to that helper so future provider flows reuse the same
logic.

In `@web/app/pages/Project/tabs/Traffic/TrafficView.tsx`:
- Around line 492-510: The campaign map effect in TrafficView re-runs on
unrelated URL changes because it depends on the whole searchParams object
instead of only the values it actually reads. Update the useEffect dependencies
around fetchCampaignMap to track just the derived from/to values (or memoized
equivalents) along with id, panelsActiveTabs.source, period, timezone, and
fetchCampaignMap, so the Ads backend is only hit when those relevant inputs
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f7e8f8c7-d7b1-4c79-8c78-6746f22b8726

📥 Commits

Reviewing files that changed from the base of the PR and between 69be205 and 46158eb.

📒 Files selected for processing (46)
  • backend/.env.example
  • backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts
  • backend/apps/cloud/src/ads/ads-analytics.controller.ts
  • backend/apps/cloud/src/ads/ads.controller.ts
  • backend/apps/cloud/src/ads/ads.module.ts
  • backend/apps/cloud/src/ads/ads.service.ts
  • backend/apps/cloud/src/ads/dto/get-ads.dto.ts
  • backend/apps/cloud/src/ads/dto/select-account.dto.ts
  • backend/apps/cloud/src/ads/interfaces/ads.interface.ts
  • backend/apps/cloud/src/analytics/analytics.service.ts
  • backend/apps/cloud/src/app.module.ts
  • backend/apps/cloud/src/common/utils.ts
  • backend/apps/cloud/src/project/entity/project.entity.ts
  • backend/apps/cloud/src/task-manager/task-manager.module.ts
  • backend/apps/cloud/src/task-manager/task-manager.service.ts
  • backend/migrations/clickhouse/2026_07_07_ad_metrics.js
  • backend/migrations/clickhouse/initialise_database.js
  • backend/migrations/mysql/2026_07_07_google_ads.sql
  • docs/components/IntegrationsGrid.tsx
  • docs/content/docs/analytics-dashboard/ads.mdx
  • docs/content/docs/analytics-dashboard/meta.json
  • docs/content/docs/analytics-dashboard/revenue-tracking.mdx
  • docs/content/docs/integration-guides.mdx
  • docs/content/docs/integrations/google-ads.mdx
  • docs/content/docs/integrations/meta.json
  • docs/content/docs/introduction.mdx
  • docs/content/docs/traffic-sources.mdx
  • docs/lib/source.ts
  • web/app/api/api.server.ts
  • web/app/hooks/useAnalyticsProxy.ts
  • web/app/hooks/useAuthProxy.ts
  • web/app/lib/constants/index.ts
  • web/app/lib/models/Project.ts
  • web/app/pages/Project/Settings/ProjectSettings.tsx
  • web/app/pages/Project/View/ViewProject.tsx
  • web/app/pages/Project/tabs/Ads/AdsView.tsx
  • web/app/pages/Project/tabs/Ads/ads-chart-options.ts
  • web/app/pages/Project/tabs/Profiles/ProfileDetails.tsx
  • web/app/pages/Project/tabs/Sessions/SessionDetailView.tsx
  • web/app/pages/Project/tabs/Traffic/TrafficView.tsx
  • web/app/routes/ads-connected.tsx
  • web/app/routes/api.analytics.ts
  • web/app/routes/api.auth.ts
  • web/app/routes/projects.settings.$id.tsx
  • web/app/utils/routes.ts
  • web/public/locales/en.json

Comment thread backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts
Comment thread backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts Outdated
Comment thread backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts Outdated
Comment thread backend/apps/cloud/src/ads/ads-analytics.controller.ts Outdated
Comment thread backend/apps/cloud/src/ads/ads.controller.ts
Comment on lines +450 to +503
<MetricCard
label={t('project.ads.spend')}
value={stats?.cost ?? 0}
change={
stats ? _round(stats.cost - stats.previous.cost, 2) : undefined
}
goodChangeDirection='up'
valueMapper={(value, type) =>
`${type === 'badge' && value > 0 ? '+' : ''}${formatMoney(value)}`
}
/>
<MetricCard
label={t('project.ads.clicks')}
value={stats?.clicks ?? 0}
change={stats ? stats.clicks - stats.previous.clicks : undefined}
goodChangeDirection='down'
valueMapper={(value, type) =>
`${type === 'badge' && value > 0 ? '+' : ''}${nFormatter(value, 1)}`
}
/>
<MetricCard
label={t('project.ads.cpc')}
value={stats?.cpc ?? 0}
valueMapper={(value) => formatMoney(value)}
/>
<MetricCard
label={t('project.ads.ctr')}
value={stats?.ctr ?? 0}
valueMapper={(value) => `${value}%`}
/>
<MetricCard
label={t('project.ads.adSessions')}
value={stats?.sessions ?? 0}
change={
stats ? stats.sessions - stats.previous.sessions : undefined
}
goodChangeDirection='down'
valueMapper={(value, type) =>
`${type === 'badge' && value > 0 ? '+' : ''}${nFormatter(value, 1)}`
}
/>
<MetricCard
label={t('project.ads.attributedRevenue')}
value={stats?.revenue ?? 0}
change={
stats
? _round(stats.revenue - stats.previous.revenue, 2)
: undefined
}
goodChangeDirection='down'
valueMapper={(value, type) =>
`${type === 'badge' && value > 0 ? '+' : ''}${formatMoney(value)}`
}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check MetricCard's goodChangeDirection semantics and existing usage patterns elsewhere
rg -n -A15 'goodChangeDirection' web/app/pages/Project/tabs/Traffic/MetricCards.tsx
rg -n -B3 -A3 "goodChangeDirection=" web/app/pages/Project/tabs/Traffic/TrafficView.tsx

Repository: Swetrix/swetrix

Length of output: 8184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the ads view and any nearby helpers/types that explain the metric semantics
ast-grep outline web/app/pages/Project/tabs/Ads/AdsView.tsx --view expanded
printf '\n---\n'
sed -n '430,520p' web/app/pages/Project/tabs/Ads/AdsView.tsx | cat -n

printf '\n=== Similar metric usage in project tabs ===\n'
rg -n -A3 -B3 "goodChangeDirection=" web/app/pages/Project/tabs -g '*.tsx'

Repository: Swetrix/swetrix

Length of output: 18240


Swap these goodChangeDirection flags
spend should be 'down', while clicks, adSessions, and attributedRevenue should be 'up'. As written, the cards highlight the opposite trend as good.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/pages/Project/tabs/Ads/AdsView.tsx` around lines 450 - 503, The
MetricCard trend indicators in AdsView are reversed for several metrics. Update
the goodChangeDirection props so the spend card uses 'down', while the clicks,
adSessions, and attributedRevenue cards use 'up'; keep the existing valueMapper
and change calculations intact. Use the MetricCard usages in AdsView to locate
and swap only these flags.

Comment thread web/app/routes/ads-connected.tsx
Comment thread web/app/routes/ads-connected.tsx
Comment thread web/public/locales/en.json
Comment thread web/public/locales/en.json Outdated
Blaumaus added 3 commits July 17, 2026 02:44
# Conflicts:
#	web/app/hooks/useAnalyticsProxy.ts
#	web/app/pages/Project/View/ViewProject.tsx
#	web/app/pages/Project/tabs/Traffic/TrafficView.tsx
#	web/app/routes/api.analytics.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/apps/cloud/src/ads/ads.service.ts`:
- Around line 671-684: Update the period and previous-window calculations around
getCampaignRows to avoid local-time parsing of the UTC timestamp strings
groupFrom and groupTo. Use native UTC Date arithmetic or the configured
dayjs.utc parsing/formatting so periodDays and the previousFrom/previousTo
boundaries remain aligned to exact UTC day offsets across DST transitions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43f31de3-13be-4612-9891-590a98329c36

📥 Commits

Reviewing files that changed from the base of the PR and between 43dcb46 and 8cd6f9a.

📒 Files selected for processing (16)
  • backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts
  • backend/apps/cloud/src/ads/ads-analytics.controller.ts
  • backend/apps/cloud/src/ads/ads.controller.ts
  • backend/apps/cloud/src/ads/ads.service.ts
  • backend/apps/cloud/src/ads/interfaces/ads.interface.ts
  • backend/apps/cloud/src/project/entity/project.entity.ts
  • backend/apps/cloud/src/task-manager/task-manager.service.ts
  • backend/migrations/mysql/2026_07_07_google_ads.sql
  • docs/content/docs/analytics-dashboard/revenue-tracking.mdx
  • docs/content/docs/integrations/google-ads.mdx
  • web/app/api/api.server.ts
  • web/app/hooks/useAnalyticsProxy.ts
  • web/app/pages/Project/Settings/ProjectSettings.tsx
  • web/app/pages/Project/tabs/Traffic/TrafficView.tsx
  • web/app/routes/ads-connected.tsx
  • web/public/locales/en.json
💤 Files with no reviewable changes (2)
  • backend/apps/cloud/src/ads/interfaces/ads.interface.ts
  • backend/apps/cloud/src/ads/ads.controller.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • docs/content/docs/integrations/google-ads.mdx
  • backend/migrations/mysql/2026_07_07_google_ads.sql
  • backend/apps/cloud/src/task-manager/task-manager.service.ts
  • docs/content/docs/analytics-dashboard/revenue-tracking.mdx
  • web/app/routes/ads-connected.tsx
  • web/app/api/api.server.ts
  • backend/apps/cloud/src/project/entity/project.entity.ts
  • backend/apps/cloud/src/ads/ads-analytics.controller.ts
  • web/public/locales/en.json
  • backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts
  • web/app/pages/Project/tabs/Traffic/TrafficView.tsx
  • web/app/pages/Project/Settings/ProjectSettings.tsx

Comment on lines +671 to +684
const periodDays = dayjs(groupTo).diff(dayjs(groupFrom), 'day') || 1

// End the previous window just before the current one starts - ad_metrics
// is daily-grain, so sharing the boundary would count that whole day twice
const previousTo = dayjs(groupFrom)
.subtract(1, 'second')
.format('YYYY-MM-DD HH:mm:ss')
const previousFrom = dayjs(previousTo)
.subtract(periodDays, 'day')
.format('YYYY-MM-DD HH:mm:ss')

const [current, previous] = await Promise.all([
this.getCampaignRows(pid, groupFrom, groupTo),
this.getCampaignRows(pid, previousFrom, previousTo),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid local time parsing for UTC strings.

Parsing UTC string timestamps (like 'YYYY-MM-DD HH:mm:ss') with dayjs(groupFrom) treats them as local server time. If the code runs in an environment where the local timezone observes Daylight Saving Time (e.g., a developer's local machine), diff and subtract across DST boundaries can yield incorrect hour shifts or 0 days.

Use native UTC Date math, or explicitly parse and format using dayjs.utc (if configured), to ensure boundaries remain strictly aligned to exact day offsets without local time interference.

🛡️ Proposed native Date math alternative
-    const periodDays = dayjs(groupTo).diff(dayjs(groupFrom), 'day') || 1
-
-    // End the previous window just before the current one starts - ad_metrics
-    // is daily-grain, so sharing the boundary would count that whole day twice
-    const previousTo = dayjs(groupFrom)
-      .subtract(1, 'second')
-      .format('YYYY-MM-DD HH:mm:ss')
-    const previousFrom = dayjs(previousTo)
-      .subtract(periodDays, 'day')
-      .format('YYYY-MM-DD HH:mm:ss')
-
-    const [current, previous] = await Promise.all([
-      this.getCampaignRows(pid, groupFrom, groupTo),
-      this.getCampaignRows(pid, previousFrom, previousTo),
-    ])
+    const timeFrom = new Date(groupFrom + 'Z').getTime()
+    const timeTo = new Date(groupTo + 'Z').getTime()
+    const periodDays = Math.round((timeTo - timeFrom) / 86400000) || 1
+
+    // End the previous window just before the current one starts - ad_metrics
+    // is daily-grain, so sharing the boundary would count that whole day twice
+    const prevToTime = timeFrom - 1000
+    const previousTo = new Date(prevToTime).toISOString().slice(0, 19).replace('T', ' ')
+    const prevFromTime = prevToTime - periodDays * 86400000
+    const previousFrom = new Date(prevFromTime).toISOString().slice(0, 19).replace('T', ' ')
+
+    const [current, previous] = await Promise.all([
+      this.getCampaignRows(pid, groupFrom, groupTo),
+      this.getCampaignRows(pid, previousFrom, previousTo),
+    ])
📝 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
const periodDays = dayjs(groupTo).diff(dayjs(groupFrom), 'day') || 1
// End the previous window just before the current one starts - ad_metrics
// is daily-grain, so sharing the boundary would count that whole day twice
const previousTo = dayjs(groupFrom)
.subtract(1, 'second')
.format('YYYY-MM-DD HH:mm:ss')
const previousFrom = dayjs(previousTo)
.subtract(periodDays, 'day')
.format('YYYY-MM-DD HH:mm:ss')
const [current, previous] = await Promise.all([
this.getCampaignRows(pid, groupFrom, groupTo),
this.getCampaignRows(pid, previousFrom, previousTo),
const timeFrom = new Date(groupFrom + 'Z').getTime()
const timeTo = new Date(groupTo + 'Z').getTime()
const periodDays = Math.round((timeTo - timeFrom) / 86400000) || 1
// End the previous window just before the current one starts - ad_metrics
// is daily-grain, so sharing the boundary would count that whole day twice
const prevToTime = timeFrom - 1000
const previousTo = new Date(prevToTime).toISOString().slice(0, 19).replace('T', ' ')
const prevFromTime = prevToTime - periodDays * 86400000
const previousFrom = new Date(prevFromTime).toISOString().slice(0, 19).replace('T', ' ')
const [current, previous] = await Promise.all([
this.getCampaignRows(pid, groupFrom, groupTo),
this.getCampaignRows(pid, previousFrom, previousTo),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/ads/ads.service.ts` around lines 671 - 684, Update the
period and previous-window calculations around getCampaignRows to avoid
local-time parsing of the UTC timestamp strings groupFrom and groupTo. Use
native UTC Date arithmetic or the configured dayjs.utc parsing/formatting so
periodDays and the previousFrom/previousTo boundaries remain aligned to exact
UTC day offsets across DST transitions.

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