feat: add Semrush-backed URL Inspector stats endpoint#2861
Conversation
Introduces GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/stats, giving the url-inspector-sr dashboard's KPI cards (unique cited URLs, total citations, unique prompts cited, total prompts) a Semrush data source instead of the legacy HLX fetch they use today, matching the response shape of the existing Aurora/Postgres reference endpoint. Reuses the existing Stats-per-URL and PROMPTS elements (no new element UUIDs). See docs/elements/url-inspector-stats-plan.md for the design, including two known approximation gaps: totalPromptsCited overcounts vs. a true distinct count (Semrush exposes no distinct-prompts element), and totalPrompts is not date-scoped (the Prompts element has no date filter). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… trends - Dates are now optional with a 28-day default (defaultStatsDateRange()), matching every other *stats* endpoint in the repo (getStats, both Aurora stats endpoints) instead of the required-dates convention the table endpoints (listOwnedUrls/listCitedDomains/listDomainUrls) use. - MAX_RANGE_DAYS tightened from 366 to 56, so `stats` always covers the same window as `weeklyTrends` (capped to the most recent 8 weeks regardless) instead of silently allowing a wider `stats` range than the trends can show. - Plan doc updated to match; corrected an overclaim that controller tests were added (they weren't — only an unused stub). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
The element mapping/gaps/decisions it documented already live in the getUrlInspectorStats JSDoc (elements.js, elements-service.js) and the PR description — a separate plan doc wasn't asked for. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Remove two dangling references to the deleted plan doc (JSDoc in elements.js and elements-service.js). - Guard the aggregate (all-regions) branch against an empty project list: mirrors getStats's existing Decision 4.1 guard — an empty projectIds must fail explicit (404) rather than silently fall through to an unscoped Semrush call, which could return another brand's data if this brand hasn't been provisioned its own sub-workspace yet. - Correct the now-stale elements-service.js comment about weeklyTrends truncation — the controller's 56-day cap (from the prior fix) means stats/weeklyTrends always cover the same window in practice now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Hey @aniham,
Verdict: Request changes - correctness bugs in untested controller logic and missing project conventions.
Complexity: HIGH - large diff, API surface change.
Changes: Adds a Semrush-backed GET endpoint for URL Inspector stats KPI cards with per-week sparkline trends (11 files).
Note: CI checks are currently pending - resolve before merge.
Must fix before merge
-
[Critical] Date defaulting silently discards caller's explicit partial date -
src/controllers/elements.jsIf a caller passes only
startDate(or onlyendDate), both are overwritten with the default 28-day window. The caller's explicit value is silently discarded. Compare withgetStatswhich defaults each independently. Fix:if (!hasText(startDate)) startDate = defaultRange.startDate; if (!hasText(endDate)) endDate = defaultRange.endDate; -
[Important]
projectsandprojectIdsderived from different sources can diverge -src/controllers/elements.jsprojectscomes fromgetOwnedUrlProjects(filters via Markets element), whileprojectIdscomes frombrandSemrushProjects.map(p => p.semrushProjectId)(DB rows). If a project exists in DB but not in Markets (or vice versa), citation KPIs and totalPrompts will be scoped to different project sets. Fix: deriveprojectIdsfrom the resolvedprojectsarray:projectIds = projects.map(p => p.projectId).filter(hasText); -
[Important]
totalPromptsrepeated in every weeklyTrends entry is semantically misleading -src/support/elements/elements-service.jsThe PROMPTS element has no date filter, so this value is the all-time total. Consumers reading
weeklyTrends[i].totalPromptswill naturally interpret it as "prompts in that week." Once this response shape becomes a contract, fixing it is a breaking change. Options: (a) omittotalPromptsfrom weekly entries entirely (keep only in top-levelstats), or (b) rename tototalPromptsAllTimewithin trend objects. -
[Important] Missing OpenAPI spec - CLAUDE.md "OpenAPI First" is must-follow pattern for new endpoints; no
docs/openapi/paths/*.yamladded. -
[Important] No controller-level test coverage - stubs added at
test/controllers/elements.test.jsbut zero test scenarios exercise the ~112 lines of controller branching logic (6 early returns, 2 defaulting paths, region scoping). The siblinggetStatscontroller has 20+ dedicated test cases covering the same patterns.
Non-blocking (2): minor issues and suggestions
- nit: Controller pre-merges
model || platforminto one field before passing to service, diverging fromgetStatswhich passes them separately; serviceplatformparam is always undefined -src/controllers/elements.js - suggestion:
regionaccepts no aliases (regionCode/region_code) unlike siblinggetStatsendpoint - consider adding for API consistency -src/controllers/elements.js
Out of scope, worth tracking: Fan-out cost (up to 9xN Semrush calls per page load for N projects) is author-acknowledged. Consider a project-count cap when moving past POC.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 5m 31s | Cost: $8.57 | Commit: 32f0fd34cbd6728ca27158d597d6311796fd95fd
If this code review was useful, please react with 👍. Otherwise, react with 👎.
Was flagged as missing in the PR description/self-review but never verified against CI — codecov/patch failed at 65.84% (97 uncovered lines, the entire controller function). Adds 19 cases mirroring getStats's existing coverage: default aggregate view, the empty-projects 404 guard, date validation/defaulting/56-day cap, region resolution vs. aggregate fan-out, siteId cross-check, and error propagation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Introduces GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/stats, giving the url-inspector-sr dashboard's KPI cards (unique cited URLs, total citations, unique prompts cited, total prompts) a Semrush data source instead of the legacy HLX fetch they use today, matching the response shape of the existing Aurora/Postgres reference endpoint. Reuses the existing Stats-per-URL and PROMPTS elements (no new element UUIDs). See docs/elements/url-inspector-stats-plan.md for the design, including two known approximation gaps: totalPromptsCited overcounts vs. a true distinct count (Semrush exposes no distinct-prompts element), and totalPrompts is not date-scoped (the Prompts element has no date filter). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… trends - Dates are now optional with a 28-day default (defaultStatsDateRange()), matching every other *stats* endpoint in the repo (getStats, both Aurora stats endpoints) instead of the required-dates convention the table endpoints (listOwnedUrls/listCitedDomains/listDomainUrls) use. - MAX_RANGE_DAYS tightened from 366 to 56, so `stats` always covers the same window as `weeklyTrends` (capped to the most recent 8 weeks regardless) instead of silently allowing a wider `stats` range than the trends can show. - Plan doc updated to match; corrected an overclaim that controller tests were added (they weren't — only an unused stub). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The element mapping/gaps/decisions it documented already live in the getUrlInspectorStats JSDoc (elements.js, elements-service.js) and the PR description — a separate plan doc wasn't asked for. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Remove two dangling references to the deleted plan doc (JSDoc in elements.js and elements-service.js). - Guard the aggregate (all-regions) branch against an empty project list: mirrors getStats's existing Decision 4.1 guard — an empty projectIds must fail explicit (404) rather than silently fall through to an unscoped Semrush call, which could return another brand's data if this brand hasn't been provisioned its own sub-workspace yet. - Correct the now-stale elements-service.js comment about weeklyTrends truncation — the controller's 56-day cap (from the prior fix) means stats/weeklyTrends always cover the same window in practice now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was flagged as missing in the PR description/self-review but never verified against CI — codecov/patch failed at 65.84% (97 uncovered lines, the entire controller function). Adds 19 cases mirroring getStats's existing coverage: default aggregate view, the empty-projects 404 guard, date validation/defaulting/56-day cap, region resolution vs. aggregate fan-out, siteId cross-check, and error propagation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per the automated review (MysticatBot, request-changes): - [Critical] Date defaulting now defaults startDate/endDate independently instead of overwriting both when only one was supplied, discarding the caller's explicit value. - [Important] projectIds (aggregate view) now derives from the resolved `projects` array (Markets-element-backed) instead of being separately filtered from brandSemrushProjects (DB rows) — the two could diverge, scoping the PROMPTS element to a different project set than Stats-per-URL. - [Important] `totalPrompts` (all-time, not date-scoped) is no longer repeated on every `weeklyTrends` entry, only in the top-level `stats` — it read as "prompts in that week" otherwise, which it isn't. - [Important] Added the missing OpenAPI spec (serenity-api.yaml + schemas.yaml + api.yaml registration), matching the sibling brand-presence/stats endpoint's level of documentation. Left as-is (non-blocking nits, matches the closer sibling family instead of getStats): model/platform pre-merged before the service call, and no regionCode/region_code aliases — both match listOwnedUrls/listCitedDomains/ listDomainUrls, this endpoint's actual closest siblings in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pacecat-api-service into feat/url-inspector-semrush-stats # Conflicts: # src/controllers/elements.js # src/support/elements/elements-service.js # test/controllers/elements.test.js # test/support/elements/elements-service.test.js
…Stats ci/build was failing: test/openapi-contract/serenity-api.test.js asserts every operationId in serenity-api.yaml has a fixture in this file, and the new getSerenityUrlInspectorStats operationId (added for the OpenAPI spec in the prior commit) had none. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Post-review testing on #2861 showed the combined /url-inspector/stats call (4 KPI cards in one request) often times out once the date range exceeds ~2 weeks, because 3 of the 4 cards share a per-Semrush-project Stats-per-URL fan-out (up to 8 weeks x N projects) while the 4th (totalPrompts) is a single, unscoped, always-fast call that was just waiting on the slow ones. Splits the endpoint in two so the client can fetch/parallelize independently: - GET .../url-inspector/stats keeps uniqueUrls, totalCitations, totalPromptsCited + weeklyTrends (unchanged cost/shape otherwise). - GET .../url-inspector/prompts/count is new, backed by the existing getPrompts service method (shared with listPrompts), no date range. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov flagged 2 patch-coverage misses in elements.js (lines 1210-1211) — the authorizeOrg error branch inside the new getUrlInspectorPromptsCount handler was only exercised past auth (siteId/region/empty-projects guards), never the case where authorizeOrg itself fails. Mirrors the equivalent existing test for the sibling getUrlInspectorStats endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… bar No codecov.yml existed, so Codecov defaulted patch coverage to the repo's actual project average (~99.9%) instead of the 90% bar .nycrc.json already enforces project-wide. In practice that meant any PR touching a handful of error-branch/guard lines could fail codecov/patch even in an otherwise well-tested change (see the repeated "cover X to satisfy codecov patch target" follow-up commits across this repo's history). Pins patch and project targets to 90% with a 1% noise threshold, matching .nycrc.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mrush-stats # Conflicts: # docs/index.html # test/controllers/elements.test.js
Multi-Persona Review — Changes recommendedPrior MysticatBot review (commit No blockers. 5 Major items to address or waive before merge: 1. [Major] Duplicated scaffolding across the two handlers (high confidence) 2. [Major] 3. [Major] Nested concurrency — real in-flight ceiling is 4. [Major] All-or-nothing on up to 9×N upstream calls (high confidence) 5. [Major] Stats fan-out iterates unfiltered Minor: untested missing- Question: Is folding Working well: prior fixes genuinely landed with tests, AuthZ correct and tested (authorizeOrg first, siteId ownership check, 503 on missing client), honest docstrings on the Multi-persona review (Architect · DBA · Security · Tester · Junior Dev). Review-only; no code changed. |
- Extract shared resolveUrlInspectorScope() so getUrlInspectorStats and getUrlInspectorPromptsCount no longer duplicate auth/siteId-ownership/ region-resolution logic (was drifting between the two copies). - Pass model/platform as separate fields consistently across both endpoints instead of pre-merging on one. - Flatten the (week x project) Stats-per-URL fan-out under a single concurrency bound instead of nesting two bounds (was multiplying to well past the documented ceiling). - Settle-and-skip per scope instead of all-or-nothing: a single failing Stats-per-URL call no longer fails the whole request. stats/weeklyTrends now carry a `partial` flag when some scopes failed. - Filter the fan-out to scopes with a resolved projectId, closing a path to an unscoped cross-brand Semrush call. - Bump the Elements transport per-call timeout 15s -> 30s (verified against a real Semrush-provisioned brand) and cap weeklyTrends weeks adaptively per scope count so the fan-out fits one concurrency-bounded round-trip, running the aggregate/weekly fetches concurrently rather than sequentially -- keeps this endpoint within the ~29-30s hard integration timeout enforced upstream of the Lambda. - Document the permanent totalPrompts trend gap (Semrush's PROMPTS element has no date dimension) in the controller docstring and OpenAPI schema. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mrush-stats # Conflicts: # docs/index.html
calvarezg
left a comment
There was a problem hiding this comment.
Well howdy @aniham — this PR's been through the rodeo and came out with its boots still shined. 🤠
All five Major findings roped, hog-tied, and covered with tests: shared resolveUrlInspectorScope helper, model/platform no longer wearing each other's hats, the fan-out concurrency flattened to one honest bound, per-scope failures settling to a partial flag instead of stampeding the whole request, and the unfiltered-projects edge fenced off. Bonus points for chasing the real 30s Semrush timeout and documenting the gateway ceiling like a proper trail boss.
Everything's bigger in Texas, and so's this changeset — nicely done. Approving. 🌵
Yeehaw and ship it. 🐎
…mrush-stats # Conflicts: # docs/index.html
# [1.681.0](v1.680.1...v1.681.0) (2026-07-22) ### Features * add Semrush-backed URL Inspector stats endpoint ([#2861](#2861)) ([8448f7f](8448f7f)), closes [adobe/project-elmo-ui#2479](https://github.com/adobe/project-elmo-ui/issues/2479)
|
🎉 This PR is included in version 1.681.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
url-inspector-srdashboard's 4 KPI cards, replacing their legacy HLX fetch, matching the response shape of the existing Aurora/Postgres reference endpoint (docs/llmo-brandalf-apis/url-inspector-stats-api.md):GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/stats— 3 of the 4 cards (unique cited URLs, total citations, unique prompts cited) + weekly sparklines, backed by the SemrushStats-per-URLelement.GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/prompts/count— the 4th card (total prompts), backed by the existinggetPromptsservice method (shared withlistPrompts), no date range.Stats-per-URLper Semrush project per trend week (up to 8 weeks x N projects) to compute 3 of the 4 cards, and repeatedly timed out once the requested date range exceeded ~2 weeks.totalPromptsis a single, unscoped, always-fast Semrush call that was just waiting on the slow ones — splitting it into its own endpoint lets the client fetch it independently/in parallel instead of blocking on the fan-out.Stats-per-URLresponse — splitting them further would triple the Semrush call volume for no latency benefit, since a client wanting all 3 needs the same underlying rows either way.totalPromptsCitedsums a per-URL count (Semrush exposes no distinct prompts-cited element, so a prompt citing multiple owned URLs is overcounted).totalPromptshas no weekly trend, unlike the Aurora/Postgres reference endpoint (which returns it viarpc_url_inspector_total_prompts, a time-windowed count of distinct active prompts that ran each week). The Semrush PROMPTS element backing/url-inspector/prompts/counthas no date parameter at all — it's a static, currently-configured prompt roster, not an execution log — so there is no upstream data to bucket by week for this metric. This is whytotalPromptswas split out of/url-inspector/statsinto its own endpoint in the first place (an earlier version repeated the all-time total in everyweeklyTrendsentry, which the first review round correctly flagged as misleading); a dedicated, trend-less endpoint was chosen over fabricating a per-week series. Documented in the controller docstring (getUrlInspectorPromptsCount) and theSerenityUrlInspectorPromptsCountOpenAPI schema. The consuming UI (adobe/project-elmo-ui#2479) already accounts for this: the "Total Prompts" KPI card renders as a static number with no sparkline, unlike its 3 siblings.Test plan
npx mocha test/controllers/elements.test.js test/support/elements/elements-service.test.js test/support/elements/definitions/url-inspector-stats.test.js test/openapi-contract/serenity-api.test.js test/routes/index.test.js test/routes/facs-capabilities.test.js test/routes/required-capabilities.test.js— all passnpm run docs:lint/npm run docs:build— spec valid, docs regeneratednpx eslinton all touched files — clean🤖 Generated with Claude Code