more analytics
This commit is contained in:
parent
26dbed15b9
commit
cbc52cbac0
@ -48,7 +48,7 @@ import type {
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
} from "@shared/types";
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
import { bucketQueryLength, trackProductEvent } from "@/lib/analytics";
|
||||
import { showDemoBlockedToast, showDemoSimulatedToast } from "@/lib/demo-toast";
|
||||
|
||||
const API_BASE = "/api";
|
||||
@ -1393,10 +1393,10 @@ export async function searchVisaSponsors(input: {
|
||||
minScore?: number;
|
||||
}): Promise<VisaSponsorSearchResponse> {
|
||||
if (input.query?.trim()) {
|
||||
trackEvent("visa_sponsor_search", {
|
||||
query: input.query.trim(),
|
||||
trackProductEvent("visa_sponsor_search", {
|
||||
query_length_bucket: bucketQueryLength(input.query.trim()),
|
||||
limit: input.limit,
|
||||
minScore: input.minScore,
|
||||
min_score: input.minScore,
|
||||
});
|
||||
}
|
||||
return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", {
|
||||
|
||||
@ -39,6 +39,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import {
|
||||
cn,
|
||||
copyTextToClipboard,
|
||||
@ -132,6 +133,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
try {
|
||||
// Revert to ready status
|
||||
await api.updateJob(jobId, { status: "ready" });
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "move_to_ready",
|
||||
result: "success",
|
||||
from_status: "applied",
|
||||
to_status: "ready",
|
||||
});
|
||||
toast.success("Reverted to Ready");
|
||||
|
||||
if (recentlyApplied?.timeoutId) {
|
||||
@ -140,6 +147,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
setRecentlyApplied(null);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "move_to_ready",
|
||||
result: "error",
|
||||
from_status: "applied",
|
||||
to_status: "ready",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to undo";
|
||||
toast.error(message);
|
||||
@ -155,6 +168,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
try {
|
||||
setIsMarkingApplied(true);
|
||||
await markAsAppliedMutation.mutateAsync(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "mark_applied",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
to_status: "applied",
|
||||
});
|
||||
|
||||
// Store for undo
|
||||
const timeoutId = setTimeout(() => {
|
||||
@ -181,6 +200,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
duration: 6000,
|
||||
});
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "mark_applied",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
to_status: "applied",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to mark as applied";
|
||||
toast.error(message);
|
||||
@ -195,9 +220,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
try {
|
||||
setIsRegenerating(true);
|
||||
await api.generateJobPdf(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "generate_pdf",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
});
|
||||
toast.success("PDF regenerated");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "generate_pdf",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||
toast.error(message);
|
||||
@ -216,10 +251,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
|
||||
try {
|
||||
await skipJobMutation.mutateAsync(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
toast.message("Job skipped");
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
const message = error instanceof Error ? error.message : "Failed to skip";
|
||||
toast.error(message);
|
||||
}
|
||||
@ -244,10 +291,20 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
try {
|
||||
setIsRegenerating(true);
|
||||
await api.generateJobPdf(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "generate_pdf",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
});
|
||||
toast.success("PDF regenerated");
|
||||
await onJobUpdated();
|
||||
setMode("ready");
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "generate_pdf",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||
toast.error(message);
|
||||
@ -293,8 +350,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
job={job}
|
||||
className="pb-4 border-b border-border/40"
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
try {
|
||||
await api.checkSponsor(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "check_sponsor",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
});
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "check_sponsor",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import type { Job } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
||||
import { DecideMode } from "./DecideMode";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
@ -60,10 +61,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
try {
|
||||
setIsSkipping(true);
|
||||
await skipJobMutation.mutateAsync(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
toast.message("Job skipped");
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
@ -77,6 +90,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
try {
|
||||
setIsFinalizing(true);
|
||||
await api.processJob(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "process_job",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
to_status: "ready",
|
||||
});
|
||||
|
||||
toast.success("Job moved to Ready", {
|
||||
description: "Your tailored PDF has been generated.",
|
||||
@ -85,6 +104,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "process_job",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
to_status: "ready",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to finalize job";
|
||||
toast.error(message);
|
||||
@ -115,8 +140,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
isRescoring={isRescoring}
|
||||
onEditDetails={() => setIsEditDetailsOpen(true)}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
try {
|
||||
await api.checkSponsor(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "check_sponsor",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
});
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "check_sponsor",
|
||||
result: "error",
|
||||
from_status: job.status,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
|
||||
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||
const [isRescoring, setIsRescoring] = useState(false);
|
||||
@ -13,9 +14,17 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||
try {
|
||||
setIsRescoring(true);
|
||||
await rescoreMutation.mutateAsync(jobId);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "rescore",
|
||||
result: "success",
|
||||
});
|
||||
toast.success("Match recalculated");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "rescore",
|
||||
result: "error",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
||||
@ -42,6 +42,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { bucketClicks, bucketCount, trackProductEvent } from "@/lib/analytics";
|
||||
import { copyTextToClipboard } from "@/lib/utils";
|
||||
|
||||
const chartConfig = {
|
||||
@ -229,9 +230,16 @@ export const TracerLinksPage: React.FC = () => {
|
||||
return { humanClicks, totalClicks, lastActivityAt };
|
||||
}, [drilldownMode, jobDrilldown]);
|
||||
|
||||
const handleCopyDestination = async (destinationUrl: string) => {
|
||||
const handleCopyDestination = async (
|
||||
destinationUrl: string,
|
||||
isActiveLink: boolean,
|
||||
) => {
|
||||
try {
|
||||
await copyTextToClipboard(destinationUrl);
|
||||
trackProductEvent("tracer_destination_copied", {
|
||||
drilldown_mode: drilldownMode,
|
||||
is_active_link: isActiveLink,
|
||||
});
|
||||
toast.success("Link copied");
|
||||
} catch {
|
||||
toast.error("Could not copy link");
|
||||
@ -240,11 +248,40 @@ export const TracerLinksPage: React.FC = () => {
|
||||
const getRowClicks = (row: JobTracerLinkAnalyticsItem) =>
|
||||
drilldownMode === "human" ? row.humanClicks : row.clicks;
|
||||
|
||||
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
|
||||
const getDateRangeDaysBucket = () => {
|
||||
if (!query.from || !query.to || query.to < query.from) return "unset";
|
||||
const secondsPerDay = 24 * 60 * 60;
|
||||
const days = Math.floor((query.to - query.from) / secondsPerDay) + 1;
|
||||
return bucketCount(days);
|
||||
};
|
||||
|
||||
const trackFiltersApplied = () => {
|
||||
trackProductEvent("tracer_filters_applied", {
|
||||
include_bots: includeBots,
|
||||
has_from: Boolean(fromDate),
|
||||
has_to: Boolean(toDate),
|
||||
date_range_days_bucket: getDateRangeDaysBucket(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectTopJob = (job: TracerAnalyticsTopJob, rank: number) => {
|
||||
trackProductEvent("tracer_drilldown_opened", {
|
||||
rank,
|
||||
human_clicks_bucket: bucketClicks(job.humanClicks),
|
||||
total_clicks_bucket: bucketClicks(job.clicks),
|
||||
});
|
||||
setSelectedDrilldownJobId(job.jobId);
|
||||
setIsDrilldownOpen(true);
|
||||
};
|
||||
|
||||
const handleSetDrilldownMode = (mode: "human" | "all") => {
|
||||
if (drilldownMode === mode) return;
|
||||
setDrilldownMode(mode);
|
||||
trackProductEvent("tracer_drilldown_mode_changed", {
|
||||
mode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@ -269,6 +306,7 @@ export const TracerLinksPage: React.FC = () => {
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(event) => setFromDate(event.target.value)}
|
||||
onBlur={trackFiltersApplied}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@ -278,6 +316,7 @@ export const TracerLinksPage: React.FC = () => {
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(event) => setToDate(event.target.value)}
|
||||
onBlur={trackFiltersApplied}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
@ -287,9 +326,15 @@ export const TracerLinksPage: React.FC = () => {
|
||||
<Checkbox
|
||||
id="tracer-include-bots"
|
||||
checked={includeBots}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeBots(Boolean(checked))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setIncludeBots(Boolean(checked));
|
||||
trackProductEvent("tracer_filters_applied", {
|
||||
include_bots: Boolean(checked),
|
||||
has_from: Boolean(fromDate),
|
||||
has_to: Boolean(toDate),
|
||||
date_range_days_bucket: getDateRangeDaysBucket(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Include likely bots</span>
|
||||
</label>
|
||||
@ -390,14 +435,14 @@ export const TracerLinksPage: React.FC = () => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(analytics?.topJobs ?? []).map((row) => (
|
||||
{(analytics?.topJobs ?? []).map((row, index) => (
|
||||
<TableRow
|
||||
key={row.jobId}
|
||||
className="cursor-pointer"
|
||||
data-state={
|
||||
selectedJobId === row.jobId ? "selected" : undefined
|
||||
}
|
||||
onClick={() => handleSelectTopJob(row)}
|
||||
onClick={() => handleSelectTopJob(row, index + 1)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium">{row.title}</div>
|
||||
@ -473,7 +518,7 @@ export const TracerLinksPage: React.FC = () => {
|
||||
variant={
|
||||
drilldownMode === "human" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => setDrilldownMode("human")}
|
||||
onClick={() => handleSetDrilldownMode("human")}
|
||||
>
|
||||
Human only
|
||||
</Button>
|
||||
@ -483,7 +528,7 @@ export const TracerLinksPage: React.FC = () => {
|
||||
variant={
|
||||
drilldownMode === "all" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => setDrilldownMode("all")}
|
||||
onClick={() => handleSetDrilldownMode("all")}
|
||||
>
|
||||
Human + bots
|
||||
</Button>
|
||||
@ -509,6 +554,12 @@ export const TracerLinksPage: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
onClick={() =>
|
||||
trackProductEvent("tracer_external_link_opened", {
|
||||
origin: "drilldown",
|
||||
drilldown_mode: drilldownMode,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@ -525,7 +576,7 @@ export const TracerLinksPage: React.FC = () => {
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
void handleCopyDestination(row.destinationUrl)
|
||||
void handleCopyDestination(row.destinationUrl, true)
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@ -564,6 +615,15 @@ export const TracerLinksPage: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
onClick={() =>
|
||||
trackProductEvent(
|
||||
"tracer_external_link_opened",
|
||||
{
|
||||
origin: "drilldown",
|
||||
drilldown_mode: drilldownMode,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@ -582,6 +642,7 @@ export const TracerLinksPage: React.FC = () => {
|
||||
onClick={() =>
|
||||
void handleCopyDestination(
|
||||
row.destinationUrl,
|
||||
false,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@ -50,6 +50,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import { EmptyState, PageHeader, PageMain } from "../components";
|
||||
@ -80,6 +81,7 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
const [accountKey, setAccountKey] = useState("default");
|
||||
const [maxMessages, setMaxMessages] = useState("100");
|
||||
const [searchDays, setSearchDays] = useState("90");
|
||||
const isDefaultAccountKey = accountKey.trim() === "default";
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
@ -291,7 +293,15 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
let syncToastId: string | number | null = null;
|
||||
try {
|
||||
if (action === "connect") {
|
||||
trackProductEvent("tracking_inbox_connect_started", {
|
||||
provider,
|
||||
account_key_is_default: isDefaultAccountKey,
|
||||
});
|
||||
if (provider !== "gmail") {
|
||||
trackProductEvent("tracking_inbox_connect_completed", {
|
||||
provider,
|
||||
result: "error",
|
||||
});
|
||||
toast.error(
|
||||
`${provider} connect is not implemented yet. Use Gmail for now.`,
|
||||
);
|
||||
@ -307,6 +317,10 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
"popup,width=520,height=720",
|
||||
);
|
||||
if (!popup) {
|
||||
trackProductEvent("tracking_inbox_connect_completed", {
|
||||
provider,
|
||||
result: "error",
|
||||
});
|
||||
toast.error(
|
||||
"Browser blocked the Gmail OAuth popup. Allow popups and retry.",
|
||||
);
|
||||
@ -331,6 +345,10 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
state: oauthStart.state,
|
||||
code: oauthResult.code,
|
||||
});
|
||||
trackProductEvent("tracking_inbox_connect_completed", {
|
||||
provider,
|
||||
result: "success",
|
||||
});
|
||||
toast.success("Provider connected");
|
||||
} else if (action === "sync") {
|
||||
const parsedMaxMessages = Number.parseInt(maxMessages, 10);
|
||||
@ -351,6 +369,11 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
syncToastId = toast.loading(
|
||||
"Sync in progress. This may take up to a couple of minutes.",
|
||||
);
|
||||
trackProductEvent("tracking_inbox_sync_started", {
|
||||
provider,
|
||||
max_messages: parsedMaxMessages,
|
||||
search_days: parsedSearchDays,
|
||||
});
|
||||
|
||||
await api.postApplicationProviderSync({
|
||||
provider,
|
||||
@ -358,16 +381,40 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
maxMessages: parsedMaxMessages,
|
||||
searchDays: parsedSearchDays,
|
||||
});
|
||||
trackProductEvent("tracking_inbox_sync_completed", {
|
||||
provider,
|
||||
result: "success",
|
||||
});
|
||||
toast.success("Sync completed", {
|
||||
...(syncToastId ? { id: syncToastId } : {}),
|
||||
});
|
||||
} else {
|
||||
await api.postApplicationProviderDisconnect({ provider, accountKey });
|
||||
trackProductEvent("tracking_inbox_disconnect_confirmed", {
|
||||
provider,
|
||||
});
|
||||
toast.success("Provider disconnected");
|
||||
}
|
||||
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (action === "connect") {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
trackProductEvent("tracking_inbox_connect_completed", {
|
||||
provider,
|
||||
result: message.includes("Timed out")
|
||||
? "timeout"
|
||||
: message.includes("window was closed")
|
||||
? "cancelled"
|
||||
: "error",
|
||||
});
|
||||
}
|
||||
if (action === "sync") {
|
||||
trackProductEvent("tracking_inbox_sync_completed", {
|
||||
provider,
|
||||
result: "error",
|
||||
});
|
||||
}
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@ -384,6 +431,7 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
},
|
||||
[
|
||||
accountKey,
|
||||
isDefaultAccountKey,
|
||||
maxMessages,
|
||||
provider,
|
||||
refresh,
|
||||
@ -393,7 +441,11 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (item: PostApplicationInboxItem, decision: "approve" | "deny") => {
|
||||
async (
|
||||
item: PostApplicationInboxItem,
|
||||
decision: "approve" | "deny",
|
||||
context: "main_inbox" | "run_modal",
|
||||
) => {
|
||||
const selectedJobId =
|
||||
appliedJobByMessageId[item.message.id] || item.message.matchedJobId;
|
||||
|
||||
@ -412,6 +464,13 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
jobId: selectedJobId ?? undefined,
|
||||
stageTarget: item.message.stageTarget ?? undefined,
|
||||
});
|
||||
trackProductEvent("tracking_inbox_review_action_completed", {
|
||||
action: "approve",
|
||||
context,
|
||||
item_count: 1,
|
||||
provider,
|
||||
result: "success",
|
||||
});
|
||||
toast.success("Message linked");
|
||||
} else {
|
||||
await api.denyPostApplicationInboxItem({
|
||||
@ -419,11 +478,25 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
provider,
|
||||
accountKey,
|
||||
});
|
||||
trackProductEvent("tracking_inbox_review_action_completed", {
|
||||
action: "deny",
|
||||
context,
|
||||
item_count: 1,
|
||||
provider,
|
||||
result: "success",
|
||||
});
|
||||
toast.success("Message ignored");
|
||||
}
|
||||
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
trackProductEvent("tracking_inbox_review_action_completed", {
|
||||
action: decision,
|
||||
context,
|
||||
item_count: 1,
|
||||
provider,
|
||||
result: "error",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@ -452,6 +525,16 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
|
||||
const { succeeded, failed, skipped } = result;
|
||||
const actionLabel = action === "approve" ? "approved" : "ignored";
|
||||
trackProductEvent("tracking_inbox_review_action_completed", {
|
||||
action,
|
||||
context: "main_inbox",
|
||||
item_count: result.requested,
|
||||
provider,
|
||||
result:
|
||||
failed === result.requested && result.requested > 0
|
||||
? "error"
|
||||
: "success",
|
||||
});
|
||||
|
||||
if (failed === 0 && skipped === 0) {
|
||||
toast.success(`All ${succeeded} messages ${actionLabel}`);
|
||||
@ -467,6 +550,13 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
trackProductEvent("tracking_inbox_review_action_completed", {
|
||||
action,
|
||||
context: "main_inbox",
|
||||
item_count: inbox.length,
|
||||
provider,
|
||||
result: "error",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@ -742,7 +832,7 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
appliedJobByMessageId={appliedJobByMessageId}
|
||||
onAppliedJobChange={handleAppliedJobChange}
|
||||
onDecision={(item, decision) =>
|
||||
void handleDecision(item, decision)
|
||||
void handleDecision(item, decision, "main_inbox")
|
||||
}
|
||||
isActionLoading={isActionLoading}
|
||||
isAppliedJobsLoading={isAppliedJobsLoading}
|
||||
@ -829,7 +919,7 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
appliedJobByMessageId={appliedJobByMessageId}
|
||||
onAppliedJobChange={handleAppliedJobChange}
|
||||
onDecision={(item, decision) =>
|
||||
void handleDecision(item, decision)
|
||||
void handleDecision(item, decision, "run_modal")
|
||||
}
|
||||
isActionLoading={isActionLoading}
|
||||
isAppliedJobsLoading={isAppliedJobsLoading}
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { bucketQueryLength, trackProductEvent } from "@/lib/analytics";
|
||||
import type { FilterTab } from "./constants";
|
||||
import {
|
||||
extractLeadingAtToken,
|
||||
@ -205,6 +206,14 @@ export const JobCommandBar: React.FC<JobCommandBarProps> = ({
|
||||
value={`${job.id} ${job.title} ${job.employer}`}
|
||||
keywords={[job.title, job.employer]}
|
||||
onSelect={() => {
|
||||
trackProductEvent("jobs_command_bar_job_selected", {
|
||||
had_status_lock: Boolean(activeLock),
|
||||
status_lock: activeLock ?? "none",
|
||||
result_group: group.id,
|
||||
query_length_bucket: bucketQueryLength(
|
||||
stripLeadingAtToken(query).trim(),
|
||||
),
|
||||
});
|
||||
closeDialog();
|
||||
onSelectJob(getFilterTab(job.status), job.id);
|
||||
}}
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
formatJobForWebhook,
|
||||
@ -215,13 +216,30 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
if (selectedJob.status === "ready") {
|
||||
await api.generateJobPdf(selectedJob.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "generate_pdf",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
});
|
||||
toast.success("Resume regenerated successfully");
|
||||
} else {
|
||||
await api.processJob(selectedJob.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "process_job",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "ready",
|
||||
});
|
||||
toast.success("Resume generated successfully");
|
||||
}
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: selectedJob.status === "ready" ? "generate_pdf" : "process_job",
|
||||
result: "error",
|
||||
from_status: selectedJob.status,
|
||||
...(selectedJob.status === "ready" ? {} : { to_status: "ready" }),
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to process job";
|
||||
toast.error(message);
|
||||
@ -234,9 +252,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await markAsAppliedMutation.mutateAsync(selectedJob.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "mark_applied",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "applied",
|
||||
});
|
||||
toast.success("Marked as applied");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "mark_applied",
|
||||
result: "error",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "applied",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to mark as applied";
|
||||
toast.error(message);
|
||||
@ -247,9 +277,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await skipJobMutation.mutateAsync(selectedJob.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
toast.message("Job skipped");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "error",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
@ -260,9 +302,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "move_in_progress",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "in_progress",
|
||||
});
|
||||
toast.success("Moved to in progress");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "move_in_progress",
|
||||
result: "error",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "in_progress",
|
||||
});
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@ -354,8 +408,22 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
<JobHeader
|
||||
job={selectedJob}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(selectedJob.id);
|
||||
await onJobUpdated();
|
||||
try {
|
||||
await api.checkSponsor(selectedJob.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "check_sponsor",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
});
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "check_sponsor",
|
||||
result: "error",
|
||||
from_status: selectedJob.status,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
} from "@shared/types.js";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import type { FilterTab } from "./constants";
|
||||
import { JobActionProgressToast } from "./JobActionProgressToast";
|
||||
import {
|
||||
@ -163,6 +164,11 @@ export function useJobSelectionActions({
|
||||
|
||||
try {
|
||||
setJobActionInFlight(action);
|
||||
trackProductEvent("jobs_bulk_action_started", {
|
||||
action,
|
||||
selected_count: selectedAtStart.length,
|
||||
tab: activeTab,
|
||||
});
|
||||
upsertProgressToast();
|
||||
await api.streamJobAction(
|
||||
{
|
||||
@ -227,6 +233,13 @@ export function useJobSelectionActions({
|
||||
const result = finalResult as JobActionResponse;
|
||||
const failedIds = getFailedJobIds(result);
|
||||
const successLabel = jobActionSuccessLabel[action];
|
||||
trackProductEvent("jobs_bulk_action_completed", {
|
||||
action: result.action,
|
||||
requested: result.requested,
|
||||
succeeded: result.succeeded,
|
||||
failed: result.failed,
|
||||
tab: activeTab,
|
||||
});
|
||||
|
||||
if (result.failed === 0) {
|
||||
toast.success(`${result.succeeded} ${successLabel}`);
|
||||
@ -262,7 +275,7 @@ export function useJobSelectionActions({
|
||||
setJobActionInFlight(null);
|
||||
}
|
||||
},
|
||||
[selectedJobIds, loadJobs],
|
||||
[activeTab, selectedJobIds, loadJobs],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import type { AppSettings, JobSource } from "@shared/types.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import type { AutomaticRunValues } from "./automatic-run";
|
||||
import {
|
||||
deriveExtractorLimits,
|
||||
@ -64,15 +65,27 @@ export function usePipelineControls(
|
||||
setIsCancelling(false);
|
||||
|
||||
if (pipelineTerminalEvent.status === "cancelled") {
|
||||
trackProductEvent("jobs_pipeline_run_finished", {
|
||||
status: "cancelled",
|
||||
had_error_message: false,
|
||||
});
|
||||
toast.message("Pipeline cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pipelineTerminalEvent.status === "failed") {
|
||||
trackProductEvent("jobs_pipeline_run_finished", {
|
||||
status: "failed",
|
||||
had_error_message: Boolean(pipelineTerminalEvent.errorMessage),
|
||||
});
|
||||
toast.error(pipelineTerminalEvent.errorMessage || "Pipeline failed");
|
||||
return;
|
||||
}
|
||||
|
||||
trackProductEvent("jobs_pipeline_run_finished", {
|
||||
status: "completed",
|
||||
had_error_message: false,
|
||||
});
|
||||
toast.success("Pipeline completed");
|
||||
}, [pipelineTerminalEvent, setIsPipelineRunning]);
|
||||
|
||||
@ -86,11 +99,30 @@ export function usePipelineControls(
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
sources: JobSource[];
|
||||
analytics?: {
|
||||
mode?: string;
|
||||
country?: string;
|
||||
hasCityLocations?: boolean;
|
||||
searchTermsCount?: number;
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
setIsPipelineRunning(true);
|
||||
setIsCancelling(false);
|
||||
await api.runPipeline(config);
|
||||
trackProductEvent("jobs_pipeline_run_started", {
|
||||
mode: config.analytics?.mode ?? "automatic",
|
||||
source_count: config.sources.length,
|
||||
top_n: config.topN,
|
||||
min_suitability_score: config.minSuitabilityScore,
|
||||
country: config.analytics?.country,
|
||||
has_city_locations: config.analytics?.hasCityLocations,
|
||||
search_terms_count: config.analytics?.searchTermsCount,
|
||||
});
|
||||
await api.runPipeline({
|
||||
topN: config.topN,
|
||||
minSuitabilityScore: config.minSuitabilityScore,
|
||||
sources: config.sources,
|
||||
});
|
||||
toast.message("Pipeline started", {
|
||||
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
|
||||
});
|
||||
@ -110,6 +142,9 @@ export function usePipelineControls(
|
||||
|
||||
try {
|
||||
setIsCancelling(true);
|
||||
trackProductEvent("jobs_pipeline_run_cancel_requested", {
|
||||
was_running: isPipelineRunning,
|
||||
});
|
||||
const result = await api.cancelPipeline();
|
||||
toast.message(result.message);
|
||||
} catch (error) {
|
||||
@ -167,6 +202,12 @@ export function usePipelineControls(
|
||||
topN: values.topN,
|
||||
minSuitabilityScore: values.minSuitabilityScore,
|
||||
sources: compatibleSources,
|
||||
analytics: {
|
||||
mode: "automatic",
|
||||
country: values.country,
|
||||
hasCityLocations: values.cityLocations.length > 0,
|
||||
searchTermsCount: values.searchTerms.length,
|
||||
},
|
||||
});
|
||||
setIsRunModeModalOpen(false);
|
||||
},
|
||||
@ -175,6 +216,9 @@ export function usePipelineControls(
|
||||
|
||||
const handleManualImported = useCallback(
|
||||
async (importedJobId: string) => {
|
||||
trackProductEvent("jobs_pipeline_run_started", {
|
||||
mode: "manual_import",
|
||||
});
|
||||
await loadJobs();
|
||||
navigateWithContext("ready", importedJobId);
|
||||
},
|
||||
|
||||
68
orchestrator/src/lib/analytics.test.ts
Normal file
68
orchestrator/src/lib/analytics.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {
|
||||
__resetAnalyticsTestState,
|
||||
bucketQueryLength,
|
||||
trackProductEvent,
|
||||
} from "./analytics";
|
||||
|
||||
describe("analytics", () => {
|
||||
const track = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-25T12:00:00Z"));
|
||||
track.mockReset();
|
||||
__resetAnalyticsTestState();
|
||||
Object.defineProperty(window, "umami", {
|
||||
configurable: true,
|
||||
value: { track },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dedupes identical product events within the dedupe window", () => {
|
||||
trackProductEvent("tracer_drilldown_mode_changed", { mode: "human" });
|
||||
trackProductEvent("tracer_drilldown_mode_changed", { mode: "human" });
|
||||
|
||||
expect(track).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
trackProductEvent("tracer_drilldown_mode_changed", { mode: "human" });
|
||||
|
||||
expect(track).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops disallowed keys and non-primitive payload values", () => {
|
||||
trackProductEvent("jobs_pipeline_run_started", {
|
||||
mode: "automatic",
|
||||
source_count: 2,
|
||||
top_n: 10,
|
||||
min_suitability_score: 50,
|
||||
country: "uk",
|
||||
has_city_locations: true,
|
||||
search_terms_count: 3,
|
||||
query: "software engineer",
|
||||
destination_url: "https://example.com",
|
||||
extra: { nested: true },
|
||||
} as any);
|
||||
|
||||
expect(track).toHaveBeenCalledTimes(1);
|
||||
expect(track).toHaveBeenCalledWith("jobs_pipeline_run_started", {
|
||||
mode: "automatic",
|
||||
source_count: 2,
|
||||
top_n: 10,
|
||||
min_suitability_score: 50,
|
||||
country: "uk",
|
||||
has_city_locations: true,
|
||||
search_terms_count: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets query lengths without sending raw query text", () => {
|
||||
expect(bucketQueryLength("")).toBe("0");
|
||||
expect(bucketQueryLength("abc")).toBe("1_3");
|
||||
expect(bucketQueryLength("hello world")).toBe("11_30");
|
||||
});
|
||||
});
|
||||
@ -12,3 +12,213 @@ export function trackEvent(event: string, data?: Record<string, unknown>) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.umami?.track(event, data);
|
||||
}
|
||||
|
||||
type ProductEventMap = {
|
||||
jobs_pipeline_run_started: {
|
||||
mode: string;
|
||||
source_count?: number;
|
||||
top_n?: number;
|
||||
min_suitability_score?: number;
|
||||
country?: string;
|
||||
has_city_locations?: boolean;
|
||||
search_terms_count?: number;
|
||||
};
|
||||
jobs_pipeline_run_cancel_requested: {
|
||||
was_running: boolean;
|
||||
};
|
||||
jobs_pipeline_run_finished: {
|
||||
status: "completed" | "failed" | "cancelled";
|
||||
had_error_message: boolean;
|
||||
};
|
||||
jobs_bulk_action_started: {
|
||||
action: string;
|
||||
selected_count: number;
|
||||
tab: string;
|
||||
};
|
||||
jobs_bulk_action_completed: {
|
||||
action: string;
|
||||
requested: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
tab: string;
|
||||
};
|
||||
jobs_job_action_completed: {
|
||||
action: string;
|
||||
result: "success" | "error";
|
||||
from_status?: string;
|
||||
to_status?: string;
|
||||
};
|
||||
jobs_command_bar_job_selected: {
|
||||
had_status_lock: boolean;
|
||||
status_lock: string;
|
||||
result_group: string;
|
||||
query_length_bucket: string;
|
||||
};
|
||||
tracking_inbox_connect_started: {
|
||||
provider: string;
|
||||
account_key_is_default: boolean;
|
||||
};
|
||||
tracking_inbox_connect_completed: {
|
||||
provider: string;
|
||||
result: "success" | "error" | "cancelled" | "timeout";
|
||||
};
|
||||
tracking_inbox_sync_started: {
|
||||
provider: string;
|
||||
max_messages: number;
|
||||
search_days: number;
|
||||
};
|
||||
tracking_inbox_sync_completed: {
|
||||
provider: string;
|
||||
result: "success" | "error";
|
||||
};
|
||||
tracking_inbox_disconnect_confirmed: {
|
||||
provider: string;
|
||||
};
|
||||
tracking_inbox_review_action_completed: {
|
||||
action: "approve" | "deny";
|
||||
context: "main_inbox" | "run_modal";
|
||||
item_count: number;
|
||||
provider: string;
|
||||
result: "success" | "error";
|
||||
};
|
||||
tracer_filters_applied: {
|
||||
include_bots: boolean;
|
||||
has_from: boolean;
|
||||
has_to: boolean;
|
||||
date_range_days_bucket: string;
|
||||
};
|
||||
tracer_drilldown_opened: {
|
||||
rank: number;
|
||||
human_clicks_bucket: string;
|
||||
total_clicks_bucket: string;
|
||||
};
|
||||
tracer_drilldown_mode_changed: {
|
||||
mode: "human" | "all";
|
||||
};
|
||||
tracer_destination_copied: {
|
||||
drilldown_mode: "human" | "all";
|
||||
is_active_link: boolean;
|
||||
};
|
||||
tracer_external_link_opened: {
|
||||
origin: "top_links" | "drilldown";
|
||||
drilldown_mode: "human" | "all";
|
||||
};
|
||||
visa_sponsor_search: {
|
||||
query_length_bucket: string;
|
||||
limit?: number;
|
||||
min_score?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ProductEventName = keyof ProductEventMap;
|
||||
type Primitive = string | number | boolean | null;
|
||||
type SanitizedPayload = Record<string, Primitive>;
|
||||
|
||||
const DEDUPE_WINDOW_MS = 3_000;
|
||||
const recentEventCache = new Map<string, number>();
|
||||
const DISALLOWED_KEY_PARTS = [
|
||||
"query",
|
||||
"url",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"code",
|
||||
] as const;
|
||||
|
||||
function sanitizeEventPayload(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): SanitizedPayload | undefined {
|
||||
if (!data) return undefined;
|
||||
const sanitized: SanitizedPayload = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const loweredKey = key.toLowerCase();
|
||||
if (DISALLOWED_KEY_PARTS.some((part) => loweredKey.includes(part))) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function stableStringify(value: Record<string, Primitive> | undefined): string {
|
||||
if (!value) return "";
|
||||
const orderedKeys = Object.keys(value).sort();
|
||||
const ordered: Record<string, Primitive> = {};
|
||||
for (const key of orderedKeys) {
|
||||
ordered[key] = value[key];
|
||||
}
|
||||
return JSON.stringify(ordered);
|
||||
}
|
||||
|
||||
function shouldDedupe(
|
||||
event: string,
|
||||
data: SanitizedPayload | undefined,
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
const cacheKey = `${event}:${stableStringify(data)}`;
|
||||
const lastSeenAt = recentEventCache.get(cacheKey);
|
||||
recentEventCache.set(cacheKey, now);
|
||||
|
||||
for (const [key, timestamp] of recentEventCache.entries()) {
|
||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||
recentEventCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return typeof lastSeenAt === "number" && now - lastSeenAt < DEDUPE_WINDOW_MS;
|
||||
}
|
||||
|
||||
export function trackProductEvent<T extends ProductEventName>(
|
||||
event: T,
|
||||
data: ProductEventMap[T],
|
||||
) {
|
||||
const sanitized = sanitizeEventPayload(data as Record<string, unknown>);
|
||||
if (shouldDedupe(event, sanitized)) return;
|
||||
trackEvent(event, sanitized);
|
||||
}
|
||||
|
||||
export function bucketCount(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
if (value === 1) return "1";
|
||||
if (value <= 5) return "2_5";
|
||||
if (value <= 20) return "6_20";
|
||||
if (value <= 100) return "21_100";
|
||||
return "101_plus";
|
||||
}
|
||||
|
||||
export function bucketClicks(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
if (value === 1) return "1";
|
||||
if (value <= 5) return "2_5";
|
||||
if (value <= 20) return "6_20";
|
||||
if (value <= 50) return "21_50";
|
||||
return "51_plus";
|
||||
}
|
||||
|
||||
export function bucketQueryLength(value: string | number): string {
|
||||
const length =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.length
|
||||
: 0;
|
||||
if (!Number.isFinite(length) || length <= 0) return "0";
|
||||
if (length <= 3) return "1_3";
|
||||
if (length <= 10) return "4_10";
|
||||
if (length <= 30) return "11_30";
|
||||
if (length <= 100) return "31_100";
|
||||
return "101_plus";
|
||||
}
|
||||
|
||||
export function __resetAnalyticsTestState() {
|
||||
recentEventCache.clear();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user