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
106 changes: 106 additions & 0 deletions apps/api/src/routes/v1/query-engine.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ import {
WorkloadDetailSummaryResponse,
WorkloadInfraTimeseriesResponse,
WorkloadFacetsResponse,
WebAnalyticsSummaryResponse,
WebAnalyticsTimeseriesResponse,
WebAnalyticsPageviewsResponse,
WebAnalyticsPagesResponse,
WebAnalyticsBreakdownsResponse,
CommitSha,
FingerprintHash,
ServiceName,
Expand Down Expand Up @@ -1520,6 +1525,107 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine",
return new WorkloadFacetsResponse({ data: buckets })
}),
)
.handle("webAnalyticsSummary", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const row = yield* runQueryFirst(Queries.webAnalyticsSummary, tenant, payload)
// A window with no sessions returns no rows at all, not a row of
// zeroes — the page needs the zeroes to render its empty state.
return new WebAnalyticsSummaryResponse({
data: {
visitors: Number(row?.visitors) || 0,
sessions: Number(row?.sessions) || 0,
newSessions: Number(row?.newSessions) || 0,
bouncedSessions: Number(row?.bouncedSessions) || 0,
identifiedSessions: Number(row?.identifiedSessions) || 0,
avgDurationMs: Number(row?.avgDurationMs) || 0,
},
})
}),
)
.handle("webAnalyticsTimeseries", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.webAnalyticsTimeseries, tenant, payload)
return new WebAnalyticsTimeseriesResponse({
data: rows.map((row) => ({
bucket: String(row.bucket),
visitors: Number(row.visitors) || 0,
sessions: Number(row.sessions) || 0,
newSessions: Number(row.newSessions) || 0,
})),
})
}),
)
.handle("webAnalyticsPageviews", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.webAnalyticsPageviews, tenant, payload)
return new WebAnalyticsPageviewsResponse({
data: rows.map((row) => ({
bucket: String(row.bucket),
pageViews: Number(row.pageViews) || 0,
sessions: Number(row.sessions) || 0,
})),
})
}),
)
.handle("webAnalyticsPages", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.webAnalyticsPages, tenant, payload)
return new WebAnalyticsPagesResponse({
data: rows.map((row) => ({
host: String(row.host),
pagePath: String(row.pagePath),
pageViews: Number(row.pageViews) || 0,
sessions: Number(row.sessions) || 0,
})),
})
}),
)
.handle("webAnalyticsBreakdowns", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.webAnalyticsBreakdowns, tenant, payload)
const buckets = {
referrerHosts: [] as Array<{ name: string; count: number }>,
countries: [] as Array<{ name: string; count: number }>,
deviceTypes: [] as Array<{ name: string; count: number }>,
browsers: [] as Array<{ name: string; count: number }>,
operatingSystems: [] as Array<{ name: string; count: number }>,
languages: [] as Array<{ name: string; count: number }>,
utmSources: [] as Array<{ name: string; count: number }>,
utmMediums: [] as Array<{ name: string; count: number }>,
utmCampaigns: [] as Array<{ name: string; count: number }>,
entryPaths: [] as Array<{ name: string; count: number }>,
exitPaths: [] as Array<{ name: string; count: number }>,
hosts: [] as Array<{ name: string; count: number }>,
}
// facetType → response key. A table rather than a twelve-arm switch:
// the mapping is the whole content of this step, and the keys have to
// stay in step with WebAnalyticsFacetKey in the query builder.
const bucketOf: Record<string, keyof typeof buckets> = {
referrerHost: "referrerHosts",
country: "countries",
deviceType: "deviceTypes",
browserName: "browsers",
osName: "operatingSystems",
language: "languages",
utmSource: "utmSources",
utmMedium: "utmMediums",
utmCampaign: "utmCampaigns",
entryPath: "entryPaths",
exitPath: "exitPaths",
host: "hosts",
}
for (const row of rows) {
const key = bucketOf[row.facetType]
if (key) buckets[key].push({ name: String(row.name), count: Number(row.count) || 0 })
}
return new WebAnalyticsBreakdownsResponse({ data: buckets })
}),
)
.handle("executeRawSql", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down
10 changes: 10 additions & 0 deletions apps/ingest/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,16 @@ export const createMapleIngest = ({ stage, domains, region }: CreateMapleIngestO
TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"),
INGEST_KEY_STORE_BACKEND: "postgres",

// Trust `Cf-IPCountry` on inbound requests, which is what gates
// `derive_country` in `apps/ingest/src/main.rs` and therefore whether
// `session_replays.Country` is ever non-empty. Safe here specifically
// because the ALB security group only admits Cloudflare's proxy ranges
// (see `albSecurityGroup` above), so the header cannot be
// client-supplied. Left unset until now, which is why every session
// recorded before this deploy has `Country = ''` — the gateway never
// stores a client IP, so there is nothing to backfill from.
MAPLE_INGEST_TRUST_PROXY_GEO: "true",

INGEST_QUEUE_MAX_BYTES: String(WAL_MAX_BYTES),
INGEST_WAL_SHARDS: String(WAL_SHARDS),

Expand Down
254 changes: 254 additions & 0 deletions apps/web/src/api/warehouse/web-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// ---------------------------------------------------------------------------
// Web Analytics query wrappers
//
// One filter schema shared by all five 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,
WebAnalyticsPagesRequest,
WebAnalyticsPageviewsRequest,
WebAnalyticsSummaryRequest,
WebAnalyticsTimeseriesRequest,
} from "@maple/domain/http"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils"

const WebAnalyticsFilterFields = {
host: Schema.optional(Schema.String),
pagePath: Schema.optional(Schema.String),
referrerHost: Schema.optional(Schema.String),
country: Schema.optional(Schema.String),
deviceType: Schema.optional(Schema.String),
browserName: Schema.optional(Schema.String),
osName: Schema.optional(Schema.String),
language: Schema.optional(Schema.String),
utmSource: Schema.optional(Schema.String),
utmMedium: Schema.optional(Schema.String),
utmCampaign: Schema.optional(Schema.String),
visitorType: Schema.optional(Schema.Literals(["new", "returning"])),
} as const

const TimeWindowFields = {
startTime: WarehouseDateTimeString,
endTime: WarehouseDateTimeString,
} as const

const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))

const WebAnalyticsSummaryInputSchema = Schema.Struct({
...TimeWindowFields,
...WebAnalyticsFilterFields,
})

const WebAnalyticsTimeseriesInputSchema = Schema.Struct({
...TimeWindowFields,
...WebAnalyticsFilterFields,
bucketSeconds: Schema.optional(PositiveInt),
})

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

const WebAnalyticsBreakdownsInputSchema = Schema.Struct({
...TimeWindowFields,
...WebAnalyticsFilterFields,
limitPerDimension: Schema.optional(PositiveInt),
})

export type GetWebAnalyticsSummaryInput = (typeof WebAnalyticsSummaryInputSchema)["Encoded"]
export type GetWebAnalyticsTimeseriesInput = (typeof WebAnalyticsTimeseriesInputSchema)["Encoded"]
export type GetWebAnalyticsPagesInput = (typeof WebAnalyticsPagesInputSchema)["Encoded"]
export type GetWebAnalyticsBreakdownsInput = (typeof WebAnalyticsBreakdownsInputSchema)["Encoded"]

export interface WebAnalyticsSummary {
visitors: number
sessions: number
newSessions: number
bouncedSessions: number
identifiedSessions: number
avgDurationMs: number
/**
* Share of sessions whose SDK build posts the analytics block, i.e. the share
* of traffic every visitor-level number on the page actually describes. The
* page surfaces it rather than presenting a partial count as the whole.
*/
coverage: number
/**
* Bounces over **identified** sessions, 0–1. Not over all sessions: page views
* are part of the analytics block, so sessions without it report zero of them
* and would every one count as a bounce. `null` when nothing reports page
* views — the honest answer there is "unknown", not 0% and not 100%.
*/
bounceRate: number | null
}

export interface WebAnalyticsTimeseriesPoint {
bucket: string
visitors: number
sessions: number
newSessions: number
}

export interface WebAnalyticsPageviewsPoint {
bucket: string
pageViews: number
sessions: number
}

export interface WebAnalyticsPage {
host: string
pagePath: string
pageViews: number
sessions: number
}

export interface WebAnalyticsFacetRow {
name: string
count: number
}

export interface WebAnalyticsBreakdowns {
referrerHosts: ReadonlyArray<WebAnalyticsFacetRow>
countries: ReadonlyArray<WebAnalyticsFacetRow>
deviceTypes: ReadonlyArray<WebAnalyticsFacetRow>
browsers: ReadonlyArray<WebAnalyticsFacetRow>
operatingSystems: ReadonlyArray<WebAnalyticsFacetRow>
languages: ReadonlyArray<WebAnalyticsFacetRow>
utmSources: ReadonlyArray<WebAnalyticsFacetRow>
utmMediums: ReadonlyArray<WebAnalyticsFacetRow>
utmCampaigns: ReadonlyArray<WebAnalyticsFacetRow>
entryPaths: ReadonlyArray<WebAnalyticsFacetRow>
exitPaths: ReadonlyArray<WebAnalyticsFacetRow>
hosts: ReadonlyArray<WebAnalyticsFacetRow>
}

const ratio = (numerator: number, denominator: number): number =>
denominator > 0 ? numerator / denominator : 0

export function getWebAnalyticsSummary({ data }: { data: GetWebAnalyticsSummaryInput }) {
return getWebAnalyticsSummaryEffect({ data })
}

const getWebAnalyticsSummaryEffect = Effect.fn("QueryEngine.getWebAnalyticsSummary")(function* ({
data,
}: {
data: GetWebAnalyticsSummaryInput
}) {
const input = yield* decodeInput(WebAnalyticsSummaryInputSchema, data, "getWebAnalyticsSummary")

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

const row = result.data
return {
...row,
coverage: ratio(row.identifiedSessions, row.sessions),
bounceRate: row.identifiedSessions > 0 ? row.bouncedSessions / row.identifiedSessions : null,
} satisfies WebAnalyticsSummary
})

export function getWebAnalyticsTimeseries({ data }: { data: GetWebAnalyticsTimeseriesInput }) {
return getWebAnalyticsTimeseriesEffect({ data })
}

const getWebAnalyticsTimeseriesEffect = Effect.fn("QueryEngine.getWebAnalyticsTimeseries")(function* ({
data,
}: {
data: GetWebAnalyticsTimeseriesInput
}) {
const input = yield* decodeInput(WebAnalyticsTimeseriesInputSchema, data, "getWebAnalyticsTimeseries")

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

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

export function getWebAnalyticsPageviews({ data }: { data: GetWebAnalyticsTimeseriesInput }) {
return getWebAnalyticsPageviewsEffect({ data })
}

const getWebAnalyticsPageviewsEffect = Effect.fn("QueryEngine.getWebAnalyticsPageviews")(function* ({
data,
}: {
data: GetWebAnalyticsTimeseriesInput
}) {
const input = yield* decodeInput(WebAnalyticsTimeseriesInputSchema, data, "getWebAnalyticsPageviews")

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

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

export function getWebAnalyticsPages({ data }: { data: GetWebAnalyticsPagesInput }) {
return getWebAnalyticsPagesEffect({ data })
}

const getWebAnalyticsPagesEffect = Effect.fn("QueryEngine.getWebAnalyticsPages")(function* ({
data,
}: {
data: GetWebAnalyticsPagesInput
}) {
const input = yield* decodeInput(WebAnalyticsPagesInputSchema, data, "getWebAnalyticsPages")

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

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

export function getWebAnalyticsBreakdowns({ data }: { data: GetWebAnalyticsBreakdownsInput }) {
return getWebAnalyticsBreakdownsEffect({ data })
}

const getWebAnalyticsBreakdownsEffect = Effect.fn("QueryEngine.getWebAnalyticsBreakdowns")(function* ({
data,
}: {
data: GetWebAnalyticsBreakdownsInput
}) {
const input = yield* decodeInput(WebAnalyticsBreakdownsInputSchema, data, "getWebAnalyticsBreakdowns")

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

return result.data satisfies WebAnalyticsBreakdowns
})
Loading
Loading