Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/api/src/routes/internal/query-engine.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
WebAnalyticsTimeseriesResponse,
WebAnalyticsPageviewsResponse,
WebAnalyticsPagesResponse,
WebAnalyticsEventsResponse,
WebAnalyticsBreakdownsResponse,
CommitSha,
FingerprintHash,
Expand Down Expand Up @@ -1695,6 +1696,24 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
})
}),
)
.handle("webAnalyticsEvents", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* withWebEventsFallback(
(t, pl) => runQuery(Queries.webAnalyticsEvents, t, pl),
(t, pl) => runQuery(Queries.webAnalyticsEventsRaw, t, pl),
tenant,
payload,
)
return new WebAnalyticsEventsResponse({
data: rows.map((row) => ({
name: String(row.name),
events: Number(row.events) || 0,
sessions: Number(row.sessions) || 0,
})),
})
}),
)
.handle("webAnalyticsBreakdowns", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ const FILTER_CASES: ReadonlyArray<{ readonly label: string; readonly filters: CH
// narrow the page-view source through a subquery.
{ label: "replays-dimension", filters: { country: "DE" } },
{ label: "replays-dimension+path", filters: { country: "DE", pagePath: "/pricing" } },
// The event semi-join, alone and composed with a page filter.
{ label: "event", filters: { eventName: "signup_started" } },
{ label: "event+path", filters: { eventName: "signup_started", pagePath: "/pricing" } },
{
label: "all-dimensions",
filters: {
Expand All @@ -298,6 +301,7 @@ const FILTER_CASES: ReadonlyArray<{ readonly label: string; readonly filters: CH
language: "en-US",
utmSource: "twitter",
visitorType: "new",
eventName: "signup_started",
},
},
]
Expand Down Expand Up @@ -325,6 +329,10 @@ const QUERIES: ReadonlyArray<{
name: "webAnalyticsPages",
compile: (filters) => CH.compile(CH.webAnalyticsPagesQuery({ ...filters, limit: 100 }), window).sql,
},
{
name: "webAnalyticsEvents",
compile: (filters) => CH.compile(CH.webAnalyticsEventsQuery({ ...filters, limit: 100 }), window).sql,
},
{
name: "webAnalyticsBreakdowns",
compile: (filters) =>
Expand Down
41 changes: 40 additions & 1 deletion apps/web/src/api/warehouse/web-analytics.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// One filter schema shared by all five queries, so the /analytics route builds a
// One filter schema shared by all six queries, so the /analytics route builds a
// single filter object and every panel narrows identically. See
// packages/query-engine/src/ch/queries/web-analytics.ts for why the page reads
// two tables and what each half covers.

import { Effect, Schema } from "effect"
import {
WebAnalyticsBreakdownsRequest,
WebAnalyticsEventsRequest,
WebAnalyticsPagesRequest,
WebAnalyticsPageviewsRequest,
WebAnalyticsSummaryRequest,
Expand All @@ -27,6 +28,7 @@ const WebAnalyticsFilterFields = {
utmMedium: Schema.optional(Schema.String),
utmCampaign: Schema.optional(Schema.String),
visitorType: Schema.optional(Schema.Literals(["new", "returning"])),
eventName: Schema.optional(Schema.String),
} as const

const TimeWindowFields = {
Expand All @@ -53,6 +55,12 @@ const WebAnalyticsPagesInputSchema = Schema.Struct({
limit: Schema.optional(PositiveInt),
})

const WebAnalyticsEventsInputSchema = Schema.Struct({
...TimeWindowFields,
...WebAnalyticsFilterFields,
limit: Schema.optional(PositiveInt),
})

const WebAnalyticsBreakdownsInputSchema = Schema.Struct({
...TimeWindowFields,
...WebAnalyticsFilterFields,
Expand All @@ -62,6 +70,7 @@ const WebAnalyticsBreakdownsInputSchema = Schema.Struct({
export type GetWebAnalyticsSummaryInput = (typeof WebAnalyticsSummaryInputSchema)["Encoded"]
export type GetWebAnalyticsTimeseriesInput = (typeof WebAnalyticsTimeseriesInputSchema)["Encoded"]
export type GetWebAnalyticsPagesInput = (typeof WebAnalyticsPagesInputSchema)["Encoded"]
export type GetWebAnalyticsEventsInput = (typeof WebAnalyticsEventsInputSchema)["Encoded"]
export type GetWebAnalyticsBreakdownsInput = (typeof WebAnalyticsBreakdownsInputSchema)["Encoded"]

export interface WebAnalyticsSummary {
Expand Down Expand Up @@ -115,6 +124,13 @@ export interface WebAnalyticsPage {
sessions: number
}

/** One `track()` event name, with firings and the distinct sessions that fired it. */
export interface WebAnalyticsEvent {
name: string
events: number
sessions: number
}

export interface WebAnalyticsFacetRow {
name: string
count: number
Expand Down Expand Up @@ -235,6 +251,29 @@ const getWebAnalyticsPagesEffect = Effect.fn("QueryEngine.getWebAnalyticsPages")
return { data: result.data satisfies ReadonlyArray<WebAnalyticsPage> }
})

export function getWebAnalyticsEvents({ data }: { data: GetWebAnalyticsEventsInput }) {
return getWebAnalyticsEventsEffect({ data })
}

const getWebAnalyticsEventsEffect = Effect.fn("QueryEngine.getWebAnalyticsEvents")(function* ({
data,
}: {
data: GetWebAnalyticsEventsInput
}) {
const input = yield* decodeInput(WebAnalyticsEventsInputSchema, data, "getWebAnalyticsEvents")

const result = yield* runWarehouseQuery("webAnalyticsEvents", () =>
Effect.gen(function* () {
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.webAnalyticsEvents({
payload: new WebAnalyticsEventsRequest(input),
})
}),
)

return { data: result.data satisfies ReadonlyArray<WebAnalyticsEvent> }
})

export function getWebAnalyticsBreakdowns({ data }: { data: GetWebAnalyticsBreakdownsInput }) {
return getWebAnalyticsBreakdownsEffect({ data })
}
Expand Down
18 changes: 15 additions & 3 deletions apps/web/src/components/analytics/analytics-breakdown-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export interface BreakdownDimension {
* icon follows from the name keep using `analyticsRowIcon`.
*/
readonly renderIcon?: (row: BreakdownRow) => ReactNode
/**
* Column head for `views`, when the dimension carries it. Defaults to "Views";
* the Events dimension ranks by firings and says so.
*/
readonly viewsLabel?: string
}

type SortKey = "name" | "count" | "views"
Expand Down Expand Up @@ -152,6 +157,7 @@ export function AnalyticsBreakdownPanel({
// have them, sessions otherwise — so the bar always tracks the number the
// rows are actually ordered by.
const hasViews = dimension.rows.some((row) => row.views !== undefined)
const viewsLabel = dimension.viewsLabel ?? "Views"

const rows = useMemo(() => {
const total = dimension.rows.reduce((sum, row) => sum + (hasViews ? (row.views ?? 0) : row.count), 0)
Expand Down Expand Up @@ -188,6 +194,7 @@ export function AnalyticsBreakdownPanel({
rows: sorted,
label,
hasViews,
viewsLabel,
hasIcons,
selected,
waiting,
Expand Down Expand Up @@ -257,8 +264,11 @@ export function AnalyticsBreakdownPanel({
<div className="flex flex-col gap-1 pe-8">
<DialogTitle className="text-base">{dimension.tab}</DialogTitle>
<DialogDescription>
Ranked by {hasViews ? "page views" : "sessions"} over the selected window.
Pick a row to filter the page.
Ranked by{" "}
{hasViews
? (dimension.viewsLabel?.toLowerCase() ?? "page views")
: "sessions"}{" "}
over the selected window. Pick a row to filter the page.
</DialogDescription>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
Expand Down Expand Up @@ -357,6 +367,7 @@ interface BreakdownTableProps {
rows: ReadonlyArray<BreakdownRow & { share: number }>
label: (name: string) => string
hasViews: boolean
viewsLabel: string
hasIcons: boolean
selected: string | undefined
waiting?: boolean
Expand All @@ -380,6 +391,7 @@ function BreakdownTable({
rows,
label,
hasViews,
viewsLabel,
hasIcons,
selected,
waiting,
Expand Down Expand Up @@ -420,7 +432,7 @@ function BreakdownTable({
/>
{hasViews ? (
<ColumnHead<SortKey>
label="Views"
label={viewsLabel}
width={ranked ? "w-16 sm:w-24" : "w-16"}
align="right"
sortKey="views"
Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/components/analytics/analytics-filter-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Result } from "@/lib/effect-atom"
import { Separator } from "@maple/ui/components/ui/separator"
import { cn } from "@maple/ui/lib/utils"

import type { WebAnalyticsBreakdowns } from "@/api/warehouse/web-analytics"
import type { WebAnalyticsBreakdowns, WebAnalyticsEvent } from "@/api/warehouse/web-analytics"
import type { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms"
import {
FilterSection,
Expand Down Expand Up @@ -32,6 +32,7 @@ import { browserIconFor, deviceIconFor } from "@/components/replays/session-icon

interface AnalyticsFilterSidebarProps {
breakdownsResult: Result.Result<WebAnalyticsBreakdowns, QueryAtomFailure>
eventsResult: Result.Result<{ data: ReadonlyArray<WebAnalyticsEvent> }, QueryAtomFailure>
filters: AnalyticsFilters
onFilterChange: (key: AnalyticsFilterKey, value: string | undefined) => void
onClearFilters: () => void
Expand Down Expand Up @@ -60,6 +61,7 @@ const utmSourceIcon = (name: string) => (isHostLike(name) ? hostIcon(name) : nul

export function AnalyticsFilterSidebar({
breakdownsResult,
eventsResult,
filters,
onFilterChange,
onClearFilters,
Expand All @@ -70,6 +72,11 @@ export function AnalyticsFilterSidebar({
.onSuccess((breakdowns, result) => (
<AnalyticsFilterSidebarView
breakdowns={breakdowns}
// Decorative beside the breakdowns: a slow or failed events query
// drops the Event section rather than blanking the whole sidebar.
events={Result.builder(eventsResult)
.onSuccess((rows) => rows.data)
.orElse(() => [])}
waiting={result.waiting}
filters={filters}
onFilterChange={onFilterChange}
Expand All @@ -81,12 +88,14 @@ export function AnalyticsFilterSidebar({

function AnalyticsFilterSidebarView({
breakdowns,
events,
waiting,
filters,
onFilterChange,
onClearFilters,
}: {
breakdowns: WebAnalyticsBreakdowns
events: ReadonlyArray<WebAnalyticsEvent>
waiting: boolean
filters: AnalyticsFilters
onFilterChange: (key: AnalyticsFilterKey, value: string | undefined) => void
Expand Down Expand Up @@ -146,6 +155,16 @@ function AnalyticsFilterSidebarView({
options={toOptions(breakdowns.entryPaths)}
{...single("pagePath")}
/>
{/* Counts are sessions that fired the event, matching every other count
in this rail; firings live on the card. Hidden outright when nothing
is tracked — an empty "Event" section would read as a broken filter. */}
{events.length > 0 || filters.eventName ? (
<SearchableFilterSection
title={FILTER_SECTION_LABEL_TEXT.eventName}
options={events.map((event) => ({ name: event.name, count: event.sessions }))}
{...single("eventName")}
/>
) : null}
<SearchableFilterSection
title={FILTER_SECTION_LABEL_TEXT.referrerHost}
options={toOptions(breakdowns.referrerHosts)}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/analytics/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const analyticsFilterSearchFields = {
utmMedium: Schema.optional(Schema.String),
utmCampaign: Schema.optional(Schema.String),
visitorType: Schema.optional(Schema.Literals(["new", "returning"])),
eventName: Schema.optional(Schema.String),
}

export interface AnalyticsFilters {
Expand All @@ -38,6 +39,8 @@ export interface AnalyticsFilters {
utmMedium?: string
utmCampaign?: string
visitorType?: "new" | "returning"
/** Sessions in which a `track(eventName)` call fired. */
eventName?: string
}

export type AnalyticsFilterKey = keyof AnalyticsFilters
Expand All @@ -56,6 +59,7 @@ export const FILTER_CHIP_LABEL: Record<AnalyticsFilterKey, string> = {
utmMedium: "utm_medium",
utmCampaign: "utm_campaign",
visitorType: "visitor",
eventName: "event",
} satisfies Record<AnalyticsFilterKey, string>

/** Filter key → sidebar section heading. Sentence case, matching the rest of the app. */
Expand All @@ -72,6 +76,7 @@ export const FILTER_SECTION_LABEL: Record<AnalyticsFilterKey, string> = {
utmMedium: "UTM medium",
utmCampaign: "UTM campaign",
visitorType: "Visitor",
eventName: "Event",
} satisfies Record<AnalyticsFilterKey, string>

const FILTER_KEYS = Object.keys(FILTER_CHIP_LABEL) as ReadonlyArray<AnalyticsFilterKey>
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/services/atoms/warehouse-query-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
import { getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions"
import {
getWebAnalyticsBreakdowns,
getWebAnalyticsEvents,
getWebAnalyticsPages,
getWebAnalyticsPageviews,
getWebAnalyticsSummary,
Expand Down Expand Up @@ -253,7 +254,7 @@ export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, {
staleTime: 30_000,
})

// Web analytics — one page, five atoms, all 30s. Traffic numbers are watched
// Web analytics — one page, six atoms, all 30s. Traffic numbers are watched
// during a launch, so a longer TTL reads as a stalled page; a shorter one just
// re-runs the same 30-day-TTL scans.
export const webAnalyticsSummaryResultAtom = makeQueryAtomFamily(getWebAnalyticsSummary, {
Expand All @@ -272,6 +273,10 @@ export const webAnalyticsPagesResultAtom = makeQueryAtomFamily(getWebAnalyticsPa
staleTime: 30_000,
})

export const webAnalyticsEventsResultAtom = makeQueryAtomFamily(getWebAnalyticsEvents, {
staleTime: 30_000,
})

export const webAnalyticsBreakdownsResultAtom = makeQueryAtomFamily(getWebAnalyticsBreakdowns, {
staleTime: 30_000,
})
Expand Down
Loading
Loading