Skip to content

feat(analytics): X + LinkedIn analytics on ClickHouse - #645

Closed
mezotv wants to merge 4 commits into
mainfrom
feat/social-analytics
Closed

feat(analytics): X + LinkedIn analytics on ClickHouse#645
mezotv wants to merge 4 commits into
mainfrom
feat/social-analytics

Conversation

@mezotv

@mezotv mezotv commented Aug 2, 2026

Copy link
Copy Markdown
Member

Ships the analytics core: X and LinkedIn metrics ingested into ClickHouse via Tinybird, plus the dashboard surface that reads them.

  • New @notra/analytics package: Tinybird datasources (accounts, account stats, posts, post stats, post sources) and pipes (overview, engagement timeseries, top posts, follower growth, posting performance, Notra adoption) with a typed ingest/query client that no-ops when TINYBIRD_TOKEN is unset.
  • Workflow social-analytics-sync + its API route and scripts/register-analytics-schedule.ts to snapshot account dimensions and pull X post metrics on a schedule.
  • Publish path records every Notra-published post into social_post_sources so adoption markers work.
  • New /[slug]/analytics page: summary stats, per-account engagement/impressions/publishing-volume/follower charts with shared hover, posting performance and top posts.
  • dither-kit chart primitives added to @notra/ui (cartesian/polar roots, area, bar, sparkline, axes, legend, tooltip, dither painting).
  • No database migrations in this layer.

Stack

  1. feat(analytics): X + LinkedIn analytics on ClickHouse #645 feat(analytics): X + LinkedIn analytics on ClickHouse 👈
  2. feat(analytics): account leaderboard with tracked affiliates #646 feat(analytics): account leaderboard with tracked affiliates
  3. feat(experiments): social A/B tests #647 feat(experiments): social A/B tests
  4. feat(geo): AI visibility tracking #648 feat(geo): AI visibility tracking
  5. feat(agents): analytics + GEO tools for eve and Iris #649 feat(agents): analytics + GEO tools for eve and Iris

Summary by cubic

Adds social analytics: ingests X and LinkedIn metrics into ClickHouse via Tinybird and introduces a new /analytics dashboard with followers, engagement, posting performance, and top posts. Adds Upstash Redis caching over Tinybird queries, an hourly sync workflow, fixes chart tooltips to show the hovered day heading, and sets a pointer cursor on the account filter.

  • New Features

    • @notra/analytics package: Tinybird datasources (accounts, account stats, posts, post stats, post sources) and endpoints (overview, engagement timeseries, follower growth, posting performance, top posts, Notra adoption) with a typed client that no-ops when TINYBIRD_TOKEN is unset; Upstash Redis query cache with 6-hour TTL and version bumps on ingest to invalidate.
    • Hourly sync workflow: QStash schedule, API route, workflow steps to snapshot account dimensions and pull X metrics; scripts/register-analytics-schedule.ts to register the schedule.
    • Dashboard: new /[slug]/analytics page with summary stats, per-account charts, follower growth, posting performance, and top posts; account filter and shared hover; loading skeletons; nav item and command palette entry for quick access.
    • Post publishing: records every Notra-published post into social_post_sources to power adoption markers.
    • UI: dither-kit chart primitives added to @notra/ui (area/line/bar charts, axes, legend, tooltip, sparkline, dither painting); pointer cursor on account filter.
    • App wiring: orpc analytics router + React Query hooks for overview, timeseries, top posts, follower growth, posting performance, and adoption.
  • Migration

    • Set env vars: TINYBIRD_TOKEN (required), TINYBIRD_BASE_URL (defaults provided), TWITTER_BEARER_TOKEN (enables X sync), QSTASH_TOKEN (for scheduling).
    • Optional caching: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN to enable the query cache.
    • Register the hourly sync: run apps/dashboard/scripts/register-analytics-schedule.ts.
    • No database migrations in this layer.

Written for commit 3992f49. Summary will update on new commits.

Review in cubic

Summary by Comp AI

No blocking issues found.

Written for commit 3992f49. New commits will trigger a re-review. Generated by Comp AI.

@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@comp-ai-code-review

comp-ai-code-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

Comp AI code review complete — no issues found.

Commit 3992f49 · Posted by Comp AI Code Reviews.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
notra Ready Ready Preview Aug 2, 2026 9:12pm
4 Skipped Deployments
Project Deployment Actions Updated (UTC)
notra-agent Skipped Skipped Aug 2, 2026 9:12pm
notra-console Skipped Skipped Aug 2, 2026 9:12pm
notra-onboarding-agent Skipped Skipped Aug 2, 2026 9:12pm
notra-web Skipped Skipped Aug 2, 2026 9:12pm

Request Review

@capy-ai

capy-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

React Doctor found 23 new issues in 7 files · 5 errors & 18 warnings · score 49 / 100 (Critical) · 0 fixed · vs main

Errors

18 warnings

src/app/(dashboard)/[slug]/analytics/page-client.tsx

  • ⚠️ L62 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L67 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L79 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L87 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L92 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L94 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L99 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L111 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L122 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L133 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L152 Manual memoization in compiler-managed code react-compiler-no-manual-memoization

src/components/analytics/summary-stats.tsx

  • ⚠️ L25 Manual memoization in compiler-managed code react-compiler-no-manual-memoization

src/components/dither-kit/tooltip.tsx

  • ⚠️ L3 Full Framer Motion import use-lazy-motion
  • ⚠️ L50 Spread copy before sort() js-tosorted-immutable

src/components/dither-kit/x-axis.tsx

  • ⚠️ L31 Array index used as a key no-array-index-as-key

src/lib/analytics/twitter-sync.ts

  • ⚠️ L44 await inside a loop async-await-in-loop
  • ⚠️ L116 await inside a loop async-await-in-loop

src/workflows/social-analytics-sync.ts

  • ⚠️ L33 Sequential independent awaits server-sequential-independent-await

Reviewed by React Doctor for commit 3992f49. See inline comments for fixes.

export async function socialAnalyticsSyncWorkflow(
payload: SocialAnalyticsSyncPayload
): Promise<SocialAnalyticsSyncResult> {
"use workflow";
export async function listSyncableAccounts(
organizationId?: string
): Promise<SyncableSocialAccount[]> {
"use step";
export async function snapshotAccountDimensions(
accounts: SyncableSocialAccount[]
): Promise<number> {
"use step";
export async function syncTwitterAnalytics(
accounts: SyncableSocialAccount[]
): Promise<{ accountStats: number; posts: number }> {
"use step";
@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Tinybird-backed social analytics package, an hourly ingestion workflow, publish-source attribution, a typed dashboard API, and a new analytics dashboard with shared chart primitives.

  • Defines ClickHouse datasources and query pipes for account, post, engagement, follower, performance, and adoption metrics.
  • Adds scheduled X account/post collection and records posts published through Notra.
  • Adds authenticated analytics queries, dashboard cards and filters, and reusable dither-kit chart components.

Confidence Score: 4/5

The analytics ingestion and publish-attribution failures should be fixed before merging because they can silently report completed work while permanently omitting or inconsistently classifying posts.

Non-OK X responses are converted into partial successful syncs, delayed publish results bypass analytics recording, and sequential post/source writes can leave adoption data inconsistent with the general post dataset.

Files Needing Attention: apps/dashboard/src/lib/analytics/twitter-sync.ts, apps/dashboard/src/lib/social-connect/publish.ts, apps/dashboard/src/lib/analytics/record-post.ts

Important Files Changed

Filename Overview
apps/dashboard/src/lib/analytics/twitter-sync.ts Adds X user and timeline collection, but silently treats non-OK responses as partial successful synchronization.
apps/dashboard/src/lib/social-connect/publish.ts Adds post analytics recording, but only when the bounded provider-result poll immediately yields a platform ID.
apps/dashboard/src/lib/analytics/record-post.ts Writes post and source attribution separately, allowing adoption data to diverge after a partial failure.
packages/analytics/src/tinybird/endpoints.ts Defines organization-scoped Tinybird aggregation endpoints with latest-snapshot handling and time-windowed analytics.
apps/dashboard/src/lib/orpc/routers/analytics.ts Adds membership-checked analytics procedures and maps Tinybird rows into dashboard response types.
apps/dashboard/src/workflows/social-analytics-sync.ts Orchestrates global or organization-scoped account snapshots and X metrics ingestion.
apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx Adds the organization analytics dashboard, account filtering, summary metrics, and linked trend views.
packages/ui/src/components/dither-kit/cartesian-root.tsx Adds the shared chart root infrastructure used by the analytics dashboard.

Sequence Diagram

sequenceDiagram
  participant Q as QStash
  participant W as Analytics workflow
  participant X as X API
  participant T as Tinybird
  participant D as Dashboard
  Q->>W: Hourly signed trigger
  W->>X: Fetch users and timelines
  W->>T: Ingest account/post snapshots
  D->>T: Query organization analytics
  T-->>D: Overview and chart datasets
Loading

Reviews (1): Last reviewed commit: "feat(analytics): add X and LinkedIn anal..." | Re-trigger Greptile

Comment on lines +44 to +46
const response = await twitterAppFetch(
`https://api.x.com/2/users/by?${params.toString()}`
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 API errors become successful syncs

When X returns a rate-limit or transient error response, the batch lookup silently skips up to 100 accounts and timeline pagination silently drops all remaining pages. The workflow then reports completion without retrying, leaving those accounts or posts absent from the analytics dataset.

Comment on lines +135 to +147
if (platformPostId) {
yield* Effect.promise(() =>
recordPublishedSocialPost({
organizationId: params.organizationId,
accountId: params.accountId,
provider: account.provider,
providerAccountId: account.providerAccountId,
platformPostId,
url: postUrl,
content: params.content,
})
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Delayed results bypass analytics recording

When the provider accepts a post but does not return its platform ID within the five polling attempts, this guard skips recordPublishedSocialPost. The published post never enters either Tinybird datasource, so adoption and subsequent post analytics permanently omit it.

Knowledge Base Used: Dashboard Social Account Connect & Publish

Comment on lines +29 to +38
]);
await ingestSocialPostSources([
{
organization_id: input.organizationId,
provider: input.provider,
provider_account_id: input.providerAccountId,
platform_post_id: input.platformPostId,
source: "notra",
captured_at: now,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Partial writes lose source attribution

When the post ingest succeeds but the subsequent source ingest fails, the catch only logs the error and returns. General post analytics then include the post while adoption analytics undercount Notra posts and can report the wrong first Notra publication, with no retry path to restore the missing source row.

Knowledge Base Used: Dashboard Social Account Connect & Publish

@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 2, 2026 11:21 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-web August 2, 2026 11:21 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 2, 2026 11:21 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-console August 2, 2026 11:21 Inactive

@comp-ai-code-review comp-ai-code-review 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.

No blocking issues found across the changed files.

1 other commit review still in progress for this PR — findings may follow.

Commit 1262b8d · Posted by Comp AI Code Reviews.

@comp-ai-code-review comp-ai-code-review 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.

2 issues found across 2 files.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="apps/dashboard/src/components/analytics/top-posts-card.tsx">
<issue n="1" at="apps/dashboard/src/components/analytics/top-posts-card.tsx:62-67" severity="MEDIUM">Unvalidated external URL rendered as anchor href enables javascript: scheme XSS — In PostRow, `post.url` is rendered directly as the `href` of an `<a target="_blank">` element with no scheme validation: `<a href={post.url} rel="noopener noreferrer" target="_blank">`. React does not strip `javascript:` URLs from `href` attributes (it only logs a dev-mode warning), so a value like `javascript:alert(document.cookie)` would execute arbitrary script in the victim's session when the link is clicked. Tracing the data flow: `post.url` originates from `TopPostItem.url`, returned by the `analytics.topPosts` oRPC handler (apps/dashboard/src/lib/orpc/routers/analytics.ts) as `row.url` straight from Tinybird with no sanitization. The value is ingested via `recordPublishedSocialPost` (apps/dashboard/src/lib/analytics/record-post.ts) whose `url` field is set in `publishSocialPost` (apps/dashboard/src/lib/social-connect/publish.ts:128) from `postResult?.platform_data?.url` — i.e. an EXTERNAL social-connect service response that crosses a trust boundary into the analytics store and back out to the client. Unlike other URL fields in this codebase that are explicitly validated (e.g. `add-identity-dialog.tsx:53` requires `startsWith("https://")`, `format-tweet-content.tsx:54` rejects non-http(s) hrefs), this ingestion/storage/retrieval path performs no `http(s)://` scheme check. Practical exploitability is limited because under normal operation the URL is platform-derived (the constructed path is always `https://x.com/...` and `platform_data.url` is expected to be a platform canonical URL), so a normal org member cannot directly inject the value. However, it is a genuine trust-boundary / defense-in-depth gap: any compromise or unexpected response from the social-connect service, or any future code path that writes a less-trusted value into the `social_posts.url` column, would turn into stored XSS in the analytics dashboard with no backstop. Note: the other URL-rendering surface in these files, `account.profileImageUrl` / `post.profileImageUrl` rendered as `<img src>`, is NOT exploitable because browsers do not execute `javascript:` URLs in image `src` attributes. Fix: Validate the URL scheme before rendering. The simplest fix is to sanitize at the render site in PostRow: only render the `<a>` when `post.url` starts with `http://` or `https://`, otherwise render a non-interactive element or strip the href (mirroring the existing pattern in format-tweet-content.tsx). For defense in depth, also normalize/validate the scheme at ingestion time in record-post.ts / publish.ts so malicious values never enter the analytics store. Do not rely on React to strip `javascript:` URLs — it does not.</issue>
</file>

<file name="packages/analytics/src/utils/datetime.ts">
<issue n="2" at="packages/analytics/src/utils/datetime.ts:7-9" severity="MEDIUM">toClickHouseDateTime silently emits malformed datetime for Invalid Date input — toClickHouseDateTime(date) does not validate that the supplied Date is valid. When given an Invalid Date (e.g. `new Date(undefined)` or `new Date(malformedString)`), the Date's getUTC* methods return NaN, and `String(NaN).padStart(2,'0')` yields the literal string 'NaN'. The function then returns a malformed string like 'NaN-NaN-NaN NaN:NaN:NaN'. This is reachable in practice: in apps/dashboard/src/lib/analytics/rows.ts (buildTweetPostRow, line ~65) the posted_at value is computed as `toClickHouseDateTime(tweet.created_at ? new Date(tweet.created_at) : capturedAt)`, where tweet.created_at is external Twitter-API data. If created_at is a non-empty but unparseable string, new Date() returns an Invalid Date and the resulting garbage datetime string is ingested into Tinybird/ClickHouse as a row field value, corrupting data quality for that record (the ingest row's posted_at/captured_at columns receive an invalid datetime). There is no security impact (output is constrained to digits/dashes/colons/NaN, so no SQL or injection vector), but it is a silent data-quality defect with a real caller path. Fix: Guard against Invalid Date before formatting. For example, check `Number.isNaN(date.getTime())` and throw, return null, or fall back to a sensible value. At the call site in rows.ts, validate `new Date(tweet.created_at)` before passing it in, falling back to capturedAt when the parsed date is invalid.</issue>
</file>

Commit f56bf77 · Posted by Comp AI Code Reviews.

Comment on lines +62 to +67
</p>
<p className="text-sm leading-snug">{previewContent(post.content)}</p>
<p className="text-muted-foreground text-xs tabular-nums">
{formatMetric(post.likes)} likes · {formatMetric(post.replies)}{" "}
replies · {formatMetric(post.reposts)} reposts
{post.impressions !== null &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Unvalidated external URL rendered as anchor href enables javascript: scheme XSS

In PostRow, post.url is rendered directly as the href of an <a target="_blank"> element with no scheme validation: <a href={post.url} rel="noopener noreferrer" target="_blank">. React does not strip javascript: URLs from href attributes (it only logs a dev-mode warning), so a value like javascript:alert(document.cookie) would execute arbitrary script in the victim's session when the link is clicked. Tracing the data flow: post.url originates from TopPostItem.url, returned by the analytics.topPosts oRPC handler (apps/dashboard/src/lib/orpc/routers/analytics.ts) as row.url straight from Tinybird with no sanitization. The value is ingested via recordPublishedSocialPost (apps/dashboard/src/lib/analytics/record-post.ts) whose url field is set in publishSocialPost (apps/dashboard/src/lib/social-connect/publish.ts:128) from postResult?.platform_data?.url — i.e. an EXTERNAL social-connect service response that crosses a trust boundary into the analytics store and back out to the client. Unlike other URL fields in this codebase that are explicitly validated (e.g. add-identity-dialog.tsx:53 requires startsWith("https://"), format-tweet-content.tsx:54 rejects non-http(s) hrefs), this ingestion/storage/retrieval path performs no http(s):// scheme check. Practical exploitability is limited because under normal operation the URL is platform-derived (the constructed path is always https://x.com/... and platform_data.url is expected to be a platform canonical URL), so a normal org member cannot directly inject the value. However, it is a genuine trust-boundary / defense-in-depth gap: any compromise or unexpected response from the social-connect service, or any future code path that writes a less-trusted value into the social_posts.url column, would turn into stored XSS in the analytics dashboard with no backstop. Note: the other URL-rendering surface in these files, account.profileImageUrl / post.profileImageUrl rendered as <img src>, is NOT exploitable because browsers do not execute javascript: URLs in image src attributes.

Suggestion: Validate the URL scheme before rendering. The simplest fix is to sanitize at the render site in PostRow: only render the <a> when post.url starts with http:// or https://, otherwise render a non-interactive element or strip the href (mirroring the existing pattern in format-tweet-content.tsx). For defense in depth, also normalize/validate the scheme at ingestion time in record-post.ts / publish.ts so malicious values never enter the analytics store. Do not rely on React to strip javascript: URLs — it does not.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/dashboard/src/components/analytics/top-posts-card.tsx:62-67" severity="MEDIUM">Unvalidated external URL rendered as anchor href enables javascript: scheme XSS — In PostRow, `post.url` is rendered directly as the `href` of an `<a target="_blank">` element with no scheme validation: `<a href={post.url} rel="noopener noreferrer" target="_blank">`. React does not strip `javascript:` URLs from `href` attributes (it only logs a dev-mode warning), so a value like `javascript:alert(document.cookie)` would execute arbitrary script in the victim's session when the link is clicked. Tracing the data flow: `post.url` originates from `TopPostItem.url`, returned by the `analytics.topPosts` oRPC handler (apps/dashboard/src/lib/orpc/routers/analytics.ts) as `row.url` straight from Tinybird with no sanitization. The value is ingested via `recordPublishedSocialPost` (apps/dashboard/src/lib/analytics/record-post.ts) whose `url` field is set in `publishSocialPost` (apps/dashboard/src/lib/social-connect/publish.ts:128) from `postResult?.platform_data?.url` — i.e. an EXTERNAL social-connect service response that crosses a trust boundary into the analytics store and back out to the client. Unlike other URL fields in this codebase that are explicitly validated (e.g. `add-identity-dialog.tsx:53` requires `startsWith("https://")`, `format-tweet-content.tsx:54` rejects non-http(s) hrefs), this ingestion/storage/retrieval path performs no `http(s)://` scheme check. Practical exploitability is limited because under normal operation the URL is platform-derived (the constructed path is always `https://x.com/...` and `platform_data.url` is expected to be a platform canonical URL), so a normal org member cannot directly inject the value. However, it is a genuine trust-boundary / defense-in-depth gap: any compromise or unexpected response from the social-connect service, or any future code path that writes a less-trusted value into the `social_posts.url` column, would turn into stored XSS in the analytics dashboard with no backstop. Note: the other URL-rendering surface in these files, `account.profileImageUrl` / `post.profileImageUrl` rendered as `<img src>`, is NOT exploitable because browsers do not execute `javascript:` URLs in image `src` attributes. Fix: Validate the URL scheme before rendering. The simplest fix is to sanitize at the render site in PostRow: only render the `<a>` when `post.url` starts with `http://` or `https://`, otherwise render a non-interactive element or strip the href (mirroring the existing pattern in format-tweet-content.tsx). For defense in depth, also normalize/validate the scheme at ingestion time in record-post.ts / publish.ts so malicious values never enter the analytics store. Do not rely on React to strip `javascript:` URLs — it does not.</issue>

Commit f56bf77.

Comment on lines +7 to +9
export function toClickHouseDateTime(date: Date): string {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(
date.getUTCDate()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: toClickHouseDateTime silently emits malformed datetime for Invalid Date input

toClickHouseDateTime(date) does not validate that the supplied Date is valid. When given an Invalid Date (e.g. new Date(undefined) or new Date(malformedString)), the Date's getUTC* methods return NaN, and String(NaN).padStart(2,'0') yields the literal string 'NaN'. The function then returns a malformed string like 'NaN-NaN-NaN NaN:NaN:NaN'. This is reachable in practice: in apps/dashboard/src/lib/analytics/rows.ts (buildTweetPostRow, line ~65) the posted_at value is computed as toClickHouseDateTime(tweet.created_at ? new Date(tweet.created_at) : capturedAt), where tweet.created_at is external Twitter-API data. If created_at is a non-empty but unparseable string, new Date() returns an Invalid Date and the resulting garbage datetime string is ingested into Tinybird/ClickHouse as a row field value, corrupting data quality for that record (the ingest row's posted_at/captured_at columns receive an invalid datetime). There is no security impact (output is constrained to digits/dashes/colons/NaN, so no SQL or injection vector), but it is a silent data-quality defect with a real caller path.

Suggestion: Guard against Invalid Date before formatting. For example, check Number.isNaN(date.getTime()) and throw, return null, or fall back to a sensible value. At the call site in rows.ts, validate new Date(tweet.created_at) before passing it in, falling back to capturedAt when the parsed date is invalid.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="packages/analytics/src/utils/datetime.ts:7-9" severity="MEDIUM">toClickHouseDateTime silently emits malformed datetime for Invalid Date input — toClickHouseDateTime(date) does not validate that the supplied Date is valid. When given an Invalid Date (e.g. `new Date(undefined)` or `new Date(malformedString)`), the Date's getUTC* methods return NaN, and `String(NaN).padStart(2,'0')` yields the literal string 'NaN'. The function then returns a malformed string like 'NaN-NaN-NaN NaN:NaN:NaN'. This is reachable in practice: in apps/dashboard/src/lib/analytics/rows.ts (buildTweetPostRow, line ~65) the posted_at value is computed as `toClickHouseDateTime(tweet.created_at ? new Date(tweet.created_at) : capturedAt)`, where tweet.created_at is external Twitter-API data. If created_at is a non-empty but unparseable string, new Date() returns an Invalid Date and the resulting garbage datetime string is ingested into Tinybird/ClickHouse as a row field value, corrupting data quality for that record (the ingest row's posted_at/captured_at columns receive an invalid datetime). There is no security impact (output is constrained to digits/dashes/colons/NaN, so no SQL or injection vector), but it is a silent data-quality defect with a real caller path. Fix: Guard against Invalid Date before formatting. For example, check `Number.isNaN(date.getTime())` and throw, return null, or fall back to a sensible value. At the call site in rows.ts, validate `new Date(tweet.created_at)` before passing it in, falling back to capturedAt when the parsed date is invalid.</issue>

Commit f56bf77.

@comp-ai-code-review comp-ai-code-review 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.

1 issue found across 1 file.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="packages/analytics/src/cache/query-cache.ts">
<issue n="1" at="packages/analytics/src/cache/query-cache.ts:89-93" severity="MEDIUM">Silent cache-version bump failure can serve stale analytics for up to 6 hours — In bumpAnalyticsVersions (L79-94), the Redis pipeline that increments per-organization cache versions is wrapped with Effect.ignore, so any Redis error (network blip, auth failure, timeout) is silently swallowed and the function still resolves successfully. This is called from ingestRows (client.ts L89) AFTER a successful Tinybird ingest: `const result = await ingest(client, rows); await bumpAnalyticsVersions(scope, organizationIds); return result;`. If the ingest succeeds but the version bump fails, the cache version for the affected organization(s) is NOT incremented. The query cache (cachedQuery, L48-76) keys results by the per-org version and caches them with a 6-hour TTL (QUERY_CACHE_TTL_SECONDS = 21600). Consequently, new data written to Tinybird will not be visible to query endpoints until either (a) the version is bumped by a subsequent successful ingest, or (b) the stale cache entries expire (up to 6 hours). This is a user-facing data-staleness bug, not a security issue — the data is still org-scoped and no cross-tenant leakage occurs. It only manifests when Redis is transiently unavailable during an ingest. Fix: Either log the version-bump failure so it is observable (e.g., tap the error before Effect.ignore and emit a warning/metric), or retry the version bump with backoff. If stale reads after ingest are unacceptable, consider failing the ingest or writing the new data under the current version so a subsequent bump invalidates it. At minimum, surface the swallowed error to monitoring so operators know cache invalidation is failing.</issue>
</file>

Commit 741fce5 · Posted by Comp AI Code Reviews.

Comment on lines +89 to +93
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.incr(key);
}
return pipeline.exec();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Silent cache-version bump failure can serve stale analytics for up to 6 hours

In bumpAnalyticsVersions (L79-94), the Redis pipeline that increments per-organization cache versions is wrapped with Effect.ignore, so any Redis error (network blip, auth failure, timeout) is silently swallowed and the function still resolves successfully. This is called from ingestRows (client.ts L89) AFTER a successful Tinybird ingest: const result = await ingest(client, rows); await bumpAnalyticsVersions(scope, organizationIds); return result;. If the ingest succeeds but the version bump fails, the cache version for the affected organization(s) is NOT incremented. The query cache (cachedQuery, L48-76) keys results by the per-org version and caches them with a 6-hour TTL (QUERY_CACHE_TTL_SECONDS = 21600). Consequently, new data written to Tinybird will not be visible to query endpoints until either (a) the version is bumped by a subsequent successful ingest, or (b) the stale cache entries expire (up to 6 hours). This is a user-facing data-staleness bug, not a security issue — the data is still org-scoped and no cross-tenant leakage occurs. It only manifests when Redis is transiently unavailable during an ingest.

Suggestion: Either log the version-bump failure so it is observable (e.g., tap the error before Effect.ignore and emit a warning/metric), or retry the version bump with backoff. If stale reads after ingest are unacceptable, consider failing the ingest or writing the new data under the current version so a subsequent bump invalidates it. At minimum, surface the swallowed error to monitoring so operators know cache invalidation is failing.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="packages/analytics/src/cache/query-cache.ts:89-93" severity="MEDIUM">Silent cache-version bump failure can serve stale analytics for up to 6 hours — In bumpAnalyticsVersions (L79-94), the Redis pipeline that increments per-organization cache versions is wrapped with Effect.ignore, so any Redis error (network blip, auth failure, timeout) is silently swallowed and the function still resolves successfully. This is called from ingestRows (client.ts L89) AFTER a successful Tinybird ingest: `const result = await ingest(client, rows); await bumpAnalyticsVersions(scope, organizationIds); return result;`. If the ingest succeeds but the version bump fails, the cache version for the affected organization(s) is NOT incremented. The query cache (cachedQuery, L48-76) keys results by the per-org version and caches them with a 6-hour TTL (QUERY_CACHE_TTL_SECONDS = 21600). Consequently, new data written to Tinybird will not be visible to query endpoints until either (a) the version is bumped by a subsequent successful ingest, or (b) the stale cache entries expire (up to 6 hours). This is a user-facing data-staleness bug, not a security issue — the data is still org-scoped and no cross-tenant leakage occurs. It only manifests when Redis is transiently unavailable during an ingest. Fix: Either log the version-bump failure so it is observable (e.g., tap the error before Effect.ignore and emit a warning/metric), or retry the version bump with backoff. If stale reads after ingest are unacceptable, consider failing the ingest or writing the new data under the current version so a subsequent bump invalidates it. At minimum, surface the swallowed error to monitoring so operators know cache invalidation is failing.</issue>

Commit 741fce5.

@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 2, 2026 21:10 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-web August 2, 2026 21:10 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 2, 2026 21:10 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-console August 2, 2026 21:10 Inactive
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(new Set());

const accounts = useMemo(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

[overview?.accounts]
);

const accountConfig = useMemo(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

return config;
}, [accounts]);

const allKeys = useMemo(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

[accounts]
);

const visibleKeys = useMemo(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

[allKeys, hiddenKeys]
);

const selectedKeys = useMemo(() => new Set(visibleKeys), [visibleKeys]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

opacity: 0,
x: "-50%",
y: "-115%",
top: chart.tooltipTop,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-layout-property-animation (error)

This stutters because animating "top" makes the browser redo page layout every frame, so animate transform or scale instead, or use the layout prop

Fix → Use transform: translateX() or scale() instead. They animate smoothly without making the browser redo layout or repaint

Docs

x: "-50%",
y: "-115%",
top: chart.tooltipTop,
left: chart.tooltipLeft,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-layout-property-animation (error)

This stutters because animating "left" makes the browser redo page layout every frame, so animate transform or scale instead, or use the layout prop

Fix → Use transform: translateX() or scale() instead. They animate smoothly without making the browser redo layout or repaint

Docs

opacity: 1,
x: "-50%",
y: "-115%",
top: chart.tooltipTop,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-layout-property-animation (error)

This stutters because animating "top" makes the browser redo page layout every frame, so animate transform or scale instead, or use the layout prop

Fix → Use transform: translateX() or scale() instead. They animate smoothly without making the browser redo layout or repaint

Docs

x: "-50%",
y: "-115%",
top: chart.tooltipTop,
left: chart.tooltipLeft,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-layout-property-animation (error)

This stutters because animating "left" makes the browser redo page layout every frame, so animate transform or scale instead, or use the layout prop

Fix → Use transform: translateX() or scale() instead. They animate smoothly without making the browser redo layout or repaint

Docs

return (
<text
// biome-ignore lint/suspicious/noArrayIndexKey: index is the stable x position
key={i}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "i".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

@comp-ai-code-review comp-ai-code-review 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.

No blocking issues found across the changed files.

Commit 3992f49 · Posted by Comp AI Code Reviews.

@mezotv

mezotv commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Superseded by the split: #656 (analytics) and #657 (GEO, stacked on #656).

@mezotv mezotv closed this Aug 5, 2026
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