metatron-open-seo/scripts/backlinks-cost-profile.ts
Ben Senescu ad3b732f60 hosted: add product analytics (#83)
* track core product analytics flows

Track auth, search, export, audit, and credit-consumption events with canonical route IDs so PostHog funnels and usage dashboards stay low-noise and privacy-safe.

* fix: keep auth actions usable after session loss

* refactor: simplify analytics and auth helpers

- Replace isRecord/getActiveOrganizationId type guards with simple cast
- Refactor getAnalyticsRouteContext from if/return chain to route tables
- Replace toVerificationIssueType switch with zod enum
- Merge duplicate credits_consume events into single event per API call
- Merge two PostHogBootstrap useEffects into one

* refactor: add projectId to middleware context to reduce boilerplate

The requireProjectContext middleware now includes projectId directly,
eliminating repeated manual construction of BillingCustomerContext
objects across all server function handlers.

* remove unused BILLING_* env var fallbacks from cost profile script

* remove before_send event enrichment to preserve native PostHog URL tracking

The before_send hook was stripping $pathname, $current_url, $referrer and
other URL properties, which breaks PostHog web analytics dashboards, paths
analysis, session replay, and attribution. The route_id/route_group injection
it provided is unnecessary since PostHog already captures $pathname natively.

* remove route mapping layer, pass raw redirect paths to analytics events

The route ID registry (STATIC_ROUTES, PROJECT_ROUTES, getAnalyticsRouteContext,
getRedirectRouteId) duplicated what PostHog already captures via $pathname.
Replace redirect_route_id with redirect_to containing the raw path, and remove
~80 lines of route mapping infrastructure.

* clean up analytics events: drop redundant submit events and derived properties

- Remove search_submit events for keywords, domain overview, and backlinks
  (the search_complete events capture the meaningful outcome data)
- Remove target_type from backlinks events (derived 1:1 from search_scope)
- Remove result_limit from keyword research (requested limit, not useful
  alongside actual result_count)
- Remove export_format from data:export events (always "csv")

* refactor: inline wrappers, colocate helpers, deduplicate getActiveOrganizationId

- Inline toVerificationIssueType into verify-email.tsx (single-use wrapper)
- Move mapDataforseoPathToCreditFeature into dataforseoClient.ts (only consumer)
- Extract shared getActiveOrganizationId into lib/auth-session.ts (was
  duplicated in __root.tsx and middleware/ensure-user/hosted.ts)
- Rename shared/analytics.ts → shared/internal-user.ts (only email helpers
  remain after removing route mapping, verification, and dataforseo helpers)

* remove internal user tracking and email domain properties

Drop is_internal_user super property, email_domain person property, and all
supporting code (shared/internal-user.ts, getEmailDomain, isInternalUserEmail).
Simplifies initPostHog and identifyAnalyticsUser signatures.

* remove backlinks:search_complete effect-based tracking

The reactive useEffect + useRef dedup pattern added ~30 lines of plumbing
inside a data hook for a single analytics event. Not worth the complexity.

* simplify: replace manual type guards with zod, deduplicate posthog and sign-out helpers

- Replace hand-rolled typeof checks in getActiveOrganizationId and
  isAuthenticatedServerFunctionContext with zod safeParse
- Extract withPostHogClient helper to deduplicate client posthog wrapper
- Move apiKey guard into getServerPostHogClient factory
- Extract signOutAndRedirect to avoid duplicated sign-out logic
- Drop derivable has_results from analytics events
- Remove unnecessary path normalization in mapDataforseoPathToCreditFeature

* fix: strip email from pageview URLs, restore sign-out guard, harden server posthog, fix path mapper

- Sanitize $current_url on pageviews to remove email query param (PII)
- Restore onSuccess for sign-out redirect to avoid bounce-back on failure
- Swallow shutdown() errors so PostHog outages can't fail billed work
- Rewrite mapDataforseoPathToCreditFeature to match real API path structure
  (path[1] = module, path[3] = endpoint) instead of scanning all segments

* simplify: remove redundant refs in verify-email, infer middleware context type

- Remove unnecessary useRef guards in verify-email effects (deps already prevent re-firing)
- Use z.ZodType<EnsuredUserContext> annotation to infer return type instead of casting
- Add comment explaining one-shot PostHog client on Workers

* fix: reset PostHog identity on sign-out before redirect

* fix: require POSTHOG_HOST env var instead of defaulting to us.i.posthog.com

* fix: annotate url as unknown to satisfy no-unsafe-assignment

* format
2026-04-08 14:09:02 -04:00

197 lines
5.1 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import process from "node:process";
import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import type {
BacklinksLookupInput,
BacklinksTargetScope,
} from "@/types/schemas/backlinks";
loadLocalEnv();
const args = parseArgs(process.argv.slice(2));
const inMemoryCache = new Map<string, string>();
const service = createBacklinksService({
async get(key) {
const raw = inMemoryCache.get(key);
return raw ? parseCachedValue(raw) : null;
},
async set(key, data) {
inMemoryCache.set(key, JSON.stringify(data));
},
});
await main();
async function main() {
if (process.env.CI === "true" && args.allowCi !== "true") {
printUsageAndExit(
"Refusing to run live billing checks in CI without --allowCi=true.",
);
}
if (args.confirmLive !== "true") {
printUsageAndExit(
"This command makes live, billable DataForSEO requests. Re-run with --confirmLive=true.",
);
}
const input = buildInput(args);
const billingCustomer = buildBillingCustomer(args);
const repeat = parsePositiveInteger(args.repeat, 1);
const includeTabs = parseBoolean(args.includeTabs, true);
const runs = [];
for (let index = 0; index < repeat; index += 1) {
const overview = await service.profileOverview(input, billingCustomer);
const domains = includeTabs
? await service.profileReferringDomains(input, billingCustomer)
: null;
const pages = includeTabs
? await service.profileTopPages(input, billingCustomer)
: null;
runs.push({
run: index + 1,
overview: {
backlinksRows: overview.overview.backlinks.length,
trendRows: overview.overview.trends.length,
newLostRows: overview.overview.newLostTrends.length,
},
domainsTab: domains
? {
rows: domains.rows.length,
}
: null,
pagesTab: pages
? {
rows: pages.rows.length,
}
: null,
});
}
console.log(
JSON.stringify(
{
input,
repeat,
includeTabs,
runs,
},
null,
2,
),
);
}
function buildInput(cliArgs: Record<string, string>): BacklinksLookupInput {
const target = cliArgs.target;
const scope = parseScope(cliArgs.scope);
if (!target) {
printUsageAndExit("Missing target.");
}
if (!process.env.DATAFORSEO_API_KEY) {
printUsageAndExit("Missing DATAFORSEO_API_KEY.");
}
return {
target,
scope,
};
}
function buildBillingCustomer(
cliArgs: Record<string, string>,
): BillingCustomerContext {
return {
organizationId: cliArgs.organizationId ?? "local",
userId: cliArgs.userId ?? "local-user",
userEmail: cliArgs.userEmail ?? "local@example.com",
};
}
function parseArgs(argv: string[]) {
const parsed: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) continue;
const withoutPrefix = token.slice(2);
const separatorIndex = withoutPrefix.indexOf("=");
if (separatorIndex >= 0) {
parsed[withoutPrefix.slice(0, separatorIndex)] = withoutPrefix.slice(
separatorIndex + 1,
);
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
parsed[withoutPrefix] = "true";
continue;
}
parsed[withoutPrefix] = next;
index += 1;
}
return parsed;
}
function parseBoolean(value: string | undefined, fallback: boolean) {
if (value == null) return fallback;
return value === "true";
}
function parseScope(
value: string | undefined,
): BacklinksTargetScope | undefined {
if (!value) return undefined;
if (value === "domain" || value === "page") return value;
printUsageAndExit(`Invalid scope: ${value}. Expected domain or page.`);
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function loadLocalEnv() {
for (const path of [".env.local", ".env"]) {
if (!existsSync(path)) continue;
const content = readFileSync(path, "utf8");
for (const line of content.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 0) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const rawValue = trimmed.slice(separatorIndex + 1).trim();
if (!key || process.env[key]) continue;
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
}
}
}
function printUsageAndExit(message: string): never {
console.error(message);
console.error(
"Usage: pnpm billing:backlinks --target=example.com --confirmLive=true [--scope=domain|page] [--repeat=1] [--includeTabs=true|false] [--allowCi=true]",
);
process.exit(1);
}
function parseCachedValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}