Skip to content

feat: add first-party Tinybird analytics#2003

Open
richiemcilroy wants to merge 19 commits into
mainfrom
codex/first-party-analytics
Open

feat: add first-party Tinybird analytics#2003
richiemcilroy wants to merge 19 commits into
mainfrom
codex/first-party-analytics

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

  • Add a strict, bounded first-party product analytics path for web, desktop, and mobile checkout attribution.
  • Replace PostHog completely with Tinybird-backed ingestion for signup, checkout, paid purchase, trial, organization expansion, and core recording events.
  • Remove PostHog clients, dependencies, bootstrap and feature-flag plumbing, environment variables, and CI/release secrets.
  • Add Tinybird Docker infrastructure, safe CI deployment, retry-safe aggregates, and read-only agent queries.

Validation

  • pnpm analytics:test — 152 tests passed
  • pnpm --filter @cap/web test --run __tests__/unit/mobile-checkout.test.ts — 7 tests passed
  • pnpm typecheck
  • cargo fmt --all
  • cargo check -p cap-desktop with the repository FFmpeg 7 toolchain
  • cargo test -p cap-desktop product_analytics --lib — 9 tests passed
  • Scoped Biome checks, diff checks, and a repository-wide PostHog residue scan passed

Greptile Summary

This PR adds first-party Tinybird product analytics across web, desktop, and deployment tooling. The main changes are:

  • New /api/events ingestion path for bounded client analytics.
  • Product event normalization and shared event taxonomy.
  • Web and desktop instrumentation for signup, checkout, purchases, trials, organizations, and recording events.
  • Tinybird local, test, query, and deployment tooling.
  • CI workflow for validating and deploying analytics resources.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • The previously risky analytics ingestion paths now have concrete request, token, actor, and event-shape checks.
  • The Tinybird deploy path now verifies the active workspace id before running deploy commands.

Important Files Changed

Filename Overview
apps/web/lib/analytics/request.ts Adds request validation, anonymous browser event allowlisting, server-only event rejection, and rate-limit key handling for product analytics ingestion.
apps/web/app/api/events/route.ts Adds the product analytics capture route with browser-token, actor, rate-limit, normalization, and append checks.
scripts/analytics/tooling.js Adds Tinybird project validation, local tooling, cloud deploy plans, and exact workspace identity verification before deployment.
.github/workflows/analytics.yml Adds CI validation and guarded production deployment for Tinybird analytics resources.
packages/analytics/src/index.ts Defines shared product analytics limits, event names, normalization, and row creation behavior.

Reviews (9): Last reviewed commit: "fix: parse Tinybird workspace identity" | Re-trigger Greptile

Context used:

  • Context used - CLAUDE.md (source)
  • Context used - AGENTS.md (source)

@richiemcilroy
richiemcilroy marked this pull request as ready for review July 12, 2026 15:56
Comment thread apps/web/app/utils/product-analytics.ts Fixed
Comment thread scripts/analytics/tooling.js Fixed
Comment thread scripts/analytics/tooling.js Fixed
Comment thread apps/web/lib/analytics/request.ts Outdated
Comment thread apps/web/lib/analytics/request.ts
Comment thread scripts/analytics/tooling.js

@superagent-security superagent-security 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.

Superagent found 1 security concern(s).

Comment thread .github/workflows/analytics.yml Outdated
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 12, 2026
Comment thread apps/web/lib/analytics/request.ts Outdated
Comment thread scripts/analytics/tooling.js Outdated
@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 12, 2026
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/web/lib/analytics/request.ts Outdated
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/web/lib/analytics/request.ts
Comment thread apps/web/lib/analytics/request.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/web/lib/analytics/request.ts Outdated
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 12, 2026
Comment thread apps/web/lib/analytics/request.ts
Comment thread apps/web/lib/analytics/request.ts
@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 12, 2026
Comment thread apps/web/lib/analytics/request.ts Outdated
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread scripts/analytics/tooling.js Outdated
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/web/lib/analytics/request.ts Outdated
Comment thread scripts/analytics/tooling.js
Comment thread apps/web/app/api/events/route.ts
Comment thread scripts/analytics/tooling.js Outdated
Comment thread scripts/analytics/tooling.js Outdated

@superagent-security superagent-security 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.

Superagent found 1 security concern(s).

Comment thread scripts/analytics/tooling.js Outdated
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 12, 2026
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 12, 2026
Comment on lines +92 to +100
const browserClaims =
hasExpectedBrowserAnalyticsMetadata(
requestMetadata,
allowedOrigins,
) &&
readProductAnalyticsBrowserTokenClaims(
readProductAnalyticsBrowserToken(headers.cookie),
serverEnv().NEXTAUTH_SECRET,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor robustness nit: if token claim parsing throws (bad secret, malformed cookie, etc) this will currently 500. I'd rather treat that as “no browser claims” and let it fall through to the normal BadRequest path.

Suggested change
const browserClaims =
hasExpectedBrowserAnalyticsMetadata(
requestMetadata,
allowedOrigins,
) &&
readProductAnalyticsBrowserTokenClaims(
readProductAnalyticsBrowserToken(headers.cookie),
serverEnv().NEXTAUTH_SECRET,
);
let browserClaims;
if (
hasExpectedBrowserAnalyticsMetadata(
requestMetadata,
allowedOrigins,
)
) {
try {
browserClaims = readProductAnalyticsBrowserTokenClaims(
readProductAnalyticsBrowserToken(headers.cookie),
serverEnv().NEXTAUTH_SECRET,
);
} catch {
browserClaims = undefined;
}
}

Comment on lines +432 to +434
if (result.error || result.status !== 0) {
throw new Error("Unable to verify Tinybird workspace identity.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This failure path drops the actual stderr/stdout, which makes CI deploy debugging pretty painful. I’d include stderr (trimmed) in the thrown error message.

Suggested change
if (result.error || result.status !== 0) {
throw new Error("Unable to verify Tinybird workspace identity.");
}
if (result.error || result.status !== 0) {
const stderr = (result.stderr ?? "").toString().trim();
throw new Error(
stderr
? `Unable to verify Tinybird workspace identity: ${stderr}`
: "Unable to verify Tinybird workspace identity.",
);
}

@richiemcilroy
richiemcilroy force-pushed the codex/first-party-analytics branch from a9b199c to fee7c3a Compare July 12, 2026 22:48
{
eventId,
eventName: event.eventName,
occurredAt: event.occurredAt ?? new Date().toISOString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small robustness thing: occurredAt is passed through as-is, so a bad caller value will land as a bad timestamp in Tinybird. Might be worth normalizing/validating and falling back to now.

Suggested change
occurredAt: event.occurredAt ?? new Date().toISOString(),
occurredAt:
event.occurredAt && Number.isFinite(Date.parse(event.occurredAt))
? new Date(event.occurredAt).toISOString()
: new Date().toISOString(),

Comment thread apps/web/lib/analytics/server.ts Outdated
Comment on lines +49 to +55
const client = new PostHog(key, { host });
client.capture({
distinctId: event.distinctId,
event: event.eventName,
properties: event.properties,
});
await client.shutdown();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor cleanup: if client.capture throws, shutdown() won’t run. I’d wrap shutdown in a finally.

Suggested change
const client = new PostHog(key, { host });
client.capture({
distinctId: event.distinctId,
event: event.eventName,
properties: event.properties,
});
await client.shutdown();
const client = new PostHog(key, { host });
try {
client.capture({
distinctId: event.distinctId,
event: event.eventName,
properties: event.properties,
});
} finally {
await client.shutdown();
}

…lytics

# Conflicts:
#	apps/web/app/api/desktop/[...route]/root.ts
#	apps/web/app/api/settings/billing/guest-checkout/route.ts
#	apps/web/app/api/webhooks/stripe/route.ts
@cursor

cursor Bot commented Jul 24, 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.

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 24, 2026
subscription,
inviteQuota,
user: dbUser,
isFirstPurchase:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor robustness thing: isFirstPurchase coming from session.metadata is easy to lose / skew if metadata changes, and it’s already derivable from the pre-update user row.

Suggested change
isFirstPurchase:
isFirstPurchase: !dbUser.stripeSubscriptionId,

subscription,
inviteQuota,
...(dbUser ? { user: dbUser } : {}),
isFirstPurchase:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same idea here: if dbUser is present, it’s a better source of truth than checkout-session metadata.

Suggested change
isFirstPurchase:
isFirstPurchase: dbUser
? !dbUser.stripeSubscriptionId
: session.metadata?.analyticsIsFirstPurchase === "true",

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 24, 2026
#recordDrop(count: number) {
if (count === 0) return;
this.#dropped += count;
this.#onDrop?.(this.#dropped);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

onDrop takes a count, but this passes the running total. If you want the incremental drop count, pass count here and let callers read queue.dropped for the total.

Suggested change
this.#onDrop?.(this.#dropped);
this.#onDrop?.(count);

};

const encodeLocalToken = (userId, tokenId) => {
const payload = `{"u": "${userId}", "id": "${tokenId}", "host": 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.

Small robustness/readability thing: since this is JSON anyway, using JSON.stringify avoids having to worry about escaping/quoting in the template literal.

Suggested change
const payload = `{"u": "${userId}", "id": "${tokenId}", "host": null}`;
const payload = JSON.stringify({ u: userId, id: tokenId, host: null });

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.

2 participants