Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
327f95e
feat(schema): product_events replaces web_events; identity on session…
Makisuo Aug 17, 2026
58b4b9c
feat(ingest): POST /v1/events writes product_events directly
Makisuo Aug 17, 2026
587ea58
feat(cli): local schema v6 — product_events + identity_links, drop we…
Makisuo Aug 17, 2026
dfa3e2e
feat(sdk): stamp identity on session events; server-side MapleEvents.…
Makisuo Aug 17, 2026
036360a
feat(api): ProductEventsService + Clerk/Autumn webhooks emit signup/p…
Makisuo Aug 17, 2026
381f2eb
test(domain): drop invalid requiredForIngest assertion on 0016
Makisuo Aug 17, 2026
fcfc700
feat(query-engine): funnels over product_events; readers flip off web…
Makisuo Aug 17, 2026
0eba0b4
feat(web,mcp): funnels — /analytics Funnels tab, dashboard funnel wid…
Makisuo Aug 17, 2026
a1b5d05
chore: lint/format fixes on touched files; console warnings out of Ef…
Makisuo Aug 17, 2026
2d6e827
docs: product-events plan → implemented + rollout checklist
Makisuo Aug 17, 2026
8b911d1
feat(billing): product_events is its own metered feature
Makisuo Aug 17, 2026
3ee3d45
feat(billing): product_events free + unlimited during beta
Makisuo Aug 17, 2026
ccac667
fix(product-events): review fixes for funnels and metering
Makisuo Aug 17, 2026
eeceb36
fix(product-events): harden funnels for launch
Makisuo Aug 21, 2026
1256802
Merge remote-tracking branch 'origin/main' into feat/product-events-f…
Makisuo Aug 21, 2026
37aa0bf
fix(funnels): adapt funnel chart call to TanStack charts props post-m…
Makisuo Aug 21, 2026
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ MAPLE_ROOT_PASSWORD=change-me
# CLERK_PUBLISHABLE_KEY=pk_test_xxx
# Optional: networkless JWT verification
# CLERK_JWT_KEY=-----BEGIN PUBLIC KEY-----...
# Svix signing secret of the Clerk webhook endpoint pointing at POST /webhooks/clerk
# (user.created → signup_completed product event). Route answers 503 while unset.
# CLERK_WEBHOOK_SECRET=whsec_xxx

# Required when MAPLE_AUTH_MODE=self_hosted
MAPLE_DEFAULT_ORG_ID=default
Expand Down Expand Up @@ -104,6 +107,9 @@ INGEST_REQUIRE_TLS=false

# Billing (Autumn)
# AUTUMN_SECRET_KEY=am_sk_test_xxx
# Svix signing secret of the Autumn webhook endpoint pointing at POST /webhooks/autumn
# (billing.updated → plan_started / plan_changed / plan_cancelled). 503 while unset.
# AUTUMN_WEBHOOK_SECRET=whsec_xxx
# A configured Autumn account is authoritative for ingest entitlements. Checks
# fail open when Autumn is unavailable.

Expand Down Expand Up @@ -186,6 +192,7 @@ INGEST_REQUIRE_TLS=false
# MAPLE_ENVIRONMENT=local # "local" = no export; any other value enables OTLP
# MAPLE_ENDPOINT=http://127.0.0.1:3474 # Ingest gateway endpoint (enriches with org_id)
# MAPLE_INGEST_KEY= # Ingest key (maple_pk_* or maple_sk_*) for self-observability
# MAPLE_PRODUCT_EVENTS_INGEST_KEY= # Optional override for server-side product events (defaults to MAPLE_INGEST_KEY)
# COMMIT_SHA= # Git commit SHA for service version

# Scraper internal token
Expand Down
7 changes: 7 additions & 0 deletions apps/api/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
...optionalSecret("CLERK_SECRET_KEY"),
...optionalPlain("CLERK_PUBLISHABLE_KEY"),
...optionalSecret("CLERK_JWT_KEY"),
// Svix signing secrets for the public webhook receivers (`/webhooks/clerk`,
// `/webhooks/autumn`); each route answers 503 until its secret is set.
...optionalSecret("CLERK_WEBHOOK_SECRET"),
...optionalSecret("AUTUMN_WEBHOOK_SECRET"),
// Server-side product events default to MAPLE_INGEST_KEY (below); set this
// only if the funnel should land in a different org than the API's traces.
...optionalSecret("MAPLE_PRODUCT_EVENTS_INGEST_KEY"),
...optionalSecret("AUTUMN_SECRET_KEY"),
...optionalSecret("SD_INTERNAL_TOKEN"),
...optionalSecret("INTERNAL_SERVICE_TOKEN"),
Expand Down
20 changes: 20 additions & 0 deletions apps/api/autumn.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export const browserSessions = feature({
consumable: true,
})

// Product events (`track()` calls, `/v1/events` rows) — unit is one event.
// Metered by the ingest gateway on both paths (see `PRODUCT_EVENTS_FEATURE_ID`
// in apps/ingest/src/main.rs).
export const productEvents = feature({
id: "product_events",
name: "Product Events",
type: "metered",
consumable: true,
})

export const aiInputTokens = feature({
id: "ai_input_tokens",
name: "AI Input Tokens",
Expand Down Expand Up @@ -97,6 +107,16 @@ export const startup = plan({
interval: "month",
},
},
{
// BETA (2026-08-17): free and unlimited while product events are in
// beta. Usage is still metered by the gateway and tracked in Autumn so
// we know real volumes before pricing it. To start charging, replace
// `unlimited` with e.g. `included: 1_000_000` and a
// `price: { amount: 0.05, billingUnits: 1000, billingMethod:
// "usage_based", interval: "month" }` ($0.05 per 1,000 events).
featureId: "product_events",
unlimited: true,
},
],
freeTrial: {
durationLength: 14,
Expand Down
21 changes: 19 additions & 2 deletions apps/api/src/mcp/lib/dashboard-schema-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,25 @@ describe("generated JSON examples decode", () => {
// inferred that envelope correctly but reported it as a guess.
const examples = jsonExamples(renderDashboardSchemaSection("data_sources"))
const widgets = examples.filter((example) => Object.hasOwn(example as object, "dataSource"))
expect(widgets).toHaveLength(1)
expect(() => decodeWidget(widgets[0])).not.toThrow()
// The generic widget and the product-event funnel widget, whose data source
// is derived from `display.funnel` and so only makes sense shown whole.
expect(widgets).toHaveLength(2)
for (const widget of widgets) expect(() => decodeWidget(widget)).not.toThrow()
})

it("shows the product-event funnel definition on `display.funnel` with a derived route", () => {
const doc = renderDashboardSchemaSection("data_sources")
expect(doc).toContain("product_events_funnel")
const funnel = jsonExamples(doc).find(
(example) =>
Object.hasOwn(example as object, "dataSource") &&
(example as { visualization?: string }).visualization === "funnel",
) as {
dataSource: { endpoint?: string; params?: { steps?: unknown[] } }
display: { funnel?: { steps?: unknown[] } }
}
expect(funnel.dataSource.endpoint).toBe("product_events_funnel")
expect(funnel.dataSource.params?.steps).toEqual(funnel.display.funnel?.steps)
})

it("no example uses the retired v2 shape", () => {
Expand Down
62 changes: 61 additions & 1 deletion apps/api/src/mcp/lib/dashboard-schema-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type WidgetTypeMeta,
} from "@maple/domain/http"
import {
makeProductEventsFunnelDataSource,
makeQueryDataSource,
makeRawSqlDataSource,
makeStaticDataSource,
Expand Down Expand Up @@ -140,6 +141,36 @@ const exampleBreakdownSource = () =>
],
})

/** The steps a product-event funnel example runs; shared by the data-source and widget examples. */
const exampleFunnelDefinition = () => ({
steps: [
{ kind: "page" as const, pagePath: "/pricing" },
{ kind: "event" as const, eventName: "signup_completed" },
{ kind: "event" as const, eventName: "plan_started", attributeEquals: { plan: "pro" } },
],
keyBy: "person" as const,
windowSeconds: 7 * 24 * 3600,
})

/**
* A product-event funnel widget in full: the definition on `display.funnel`
* (what `add_dashboard_widget` reads) and the route data source it derives.
*/
const exampleFunnelWidget = () => {
const funnel = exampleFunnelDefinition()
return {
id: "w-signup-funnel",
visualization: "funnel",
dataSource: makeProductEventsFunnelDataSource(funnel),
display: {
title: "Signup funnel",
chartId: "query-builder-funnel",
funnel: { showStepPercent: true, ...funnel },
},
layout: { x: 0, y: 0, w: 6, h: 4 },
}
}

/**
* A complete persisted widget, not just its data source.
*
Expand Down Expand Up @@ -227,6 +258,35 @@ const dataSourcesSection = (): string =>
"",
json(exampleBreakdownSource()),
"",
'### Product-event funnels (`panel_type: "funnel"` + `display.funnel.steps`)',
"",
"A funnel widget has two modes. Without `display.funnel.steps` it draws a group-by breakdown",
"as descending stages (the shape above). With them it is a **conversion funnel over product",
"events** — page views, `track()` events and server-side events, stitched per person — and",
"the query set is not used at all. Set the definition on `display_json.funnel` and",
'`add_dashboard_widget` derives the data source (`kind: "route"`,',
'`endpoint: "product_events_funnel"`) for you; do not pass `data_source_json`.',
"",
'- `steps` — 1–10, in order. `{ kind: "event", eventName, attributeEquals? }`,',
' `{ kind: "page", pagePath, host? }`, or — **step 1 only** —',
' `{ kind: "session", dimension, value }` with `dimension` one of `referrerHost`,',
" `utmSource`, `utmMedium`, `utmCampaign`, `country`, `host`.",
"- `keyBy` — `person` (default; user id, else the visitor's linked user, else the visitor),",
" `visitor`, `user`, or `session`.",
"- `windowSeconds` — the whole chain must complete within this many seconds of step 1",
" (default 86400). Must be positive.",
"",
"The step count, the step-1-only session rule and a positive `windowSeconds` are enforced on",
"write: a definition that breaks one of them is rejected rather than saved, because the",
"query engine would reject it again on every render.",
"- `breakdownBy` — stored for parity with the /analytics Funnels view; the widget renders",
" the unsegmented funnel. Use `query_funnel` for a breakdown.",
"",
"Use `list_product_events` to see which event names exist, and `query_funnel` to try a",
"definition before pinning it to a board.",
"",
json(exampleFunnelWidget()),
"",
"### A complete widget",
"",
"The sections above describe `add_dashboard_widget`'s parameters, which it assembles into a",
Expand Down Expand Up @@ -371,7 +431,7 @@ const displaySection = (): string =>
"| `gauge` | gauge | `{ min, max }` — defaults to 0–100, which is wrong for a `percent` unit. |",
"| `histogram` | histogram | `{ bucketCount, bucketWidth, logScaleY }`. |",
"| `heatmap` | heatmap | `{ colorScale, scaleType }`. |",
"| `funnel` | funnel | `{ showStepPercent }`. |",
"| `funnel` | funnel | `{ showStepPercent, steps?, keyBy?, windowSeconds?, breakdownBy? }` — with `steps` it is a product-event funnel (see Data sources). |",
"| `markdown` | markdown | `{ content }` — the note body. |",
"| `sparkline` | stat | `{ enabled, dataSource? }`; embeds a full nested data source. |",
"",
Expand Down
50 changes: 49 additions & 1 deletion apps/api/src/mcp/lib/validate-widget-renderability.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"
import { makeQueryDataSource, makeRawSqlDataSource, makeStaticDataSource } from "@maple/widgets/dashboard"
import {
makeProductEventsFunnelDataSource,
makeQueryDataSource,
makeRawSqlDataSource,
makeStaticDataSource,
} from "@maple/widgets/dashboard"
import { makeQueryDraft } from "@/dashboard-templates/helpers"
import type { PanelType } from "@maple/domain/http"
import { collectDocumentRenderWarnings, validateWidgetRenderability } from "./validate-widget-renderability"
Expand Down Expand Up @@ -66,6 +71,49 @@ describe("fatal — combinations that always render wrong", () => {
expect(issues.fatal).toEqual([])
expect(issues.warnings).toEqual([])
})

// A funnel definition the query builder rejects would persist and then 400 on
// every render, signed-in and shared alike, with nothing in the authoring
// tool able to repair it. These are the builder's own three rules.
describe("a product-event funnel definition the builder cannot compile", () => {
const funnelWidget = (funnel: Record<string, unknown>) =>
widget("funnel", makeProductEventsFunnelDataSource({ steps: [] as never }), { funnel })
const step = (eventName: string) => ({ kind: "event", eventName })

it("more than ten steps", () => {
const steps = Array.from({ length: 11 }, (_, i) => step(`e${i}`))
expect(validateWidgetRenderability(funnelWidget({ steps })).fatal.join(" ")).toContain(
"at most 10 steps",
)
})

it("a session step past step 1", () => {
const steps = [
step("signup_completed"),
{ kind: "session", dimension: "utmSource", value: "twitter" },
]
expect(validateWidgetRenderability(funnelWidget({ steps })).fatal.join(" ")).toContain(
"only valid as step 1",
)
})

it("a non-positive conversion window", () => {
const steps = [step("a"), step("b")]
expect(
validateWidgetRenderability(funnelWidget({ steps, windowSeconds: 0 })).fatal.join(" "),
).toContain("windowSeconds")
})

it("a valid definition, and a funnel with no steps at all, are clean", () => {
const steps = [{ kind: "session", dimension: "utmSource", value: "x" }, step("signup")]
expect(validateWidgetRenderability(funnelWidget({ steps, windowSeconds: 3600 })).fatal).toEqual(
[],
)
// No steps: the widget is the original group-by breakdown drawn as a
// funnel, and none of these rules apply to it.
expect(validateWidgetRenderability(funnelWidget({ showStepPercent: true })).fatal).toEqual([])
})
})
})

describe("warnings — heuristics that must not block a restore", () => {
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/mcp/lib/validate-widget-renderability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type PanelType,
} from "@maple/domain/http"
import { dataSourceQuerySet, dataSourceRawSql, dataSourceTransform } from "@maple/widgets/dashboard"
import { FUNNEL_MAX_STEPS } from "@maple/query-model"
import { isGroupByRequested } from "./inspect-widget"

type DashboardWidget = typeof DashboardWidgetSchema.Type
Expand Down Expand Up @@ -120,6 +121,33 @@ export const validateWidgetRenderability = (input: ValidateWidgetRenderabilityIn
)
}

// A product-event funnel definition the query builder cannot compile. These
// are the same three rules `validate()` in `@maple/query-engine`'s
// `product-events.ts` enforces, and they have to run HERE, on the write, or
// the widget persists and then 400s on every render — signed-in and shared
// alike — with no way to repair it from the tool that created it. The
// /analytics view and the web widget builder both block on them already.
const funnelSteps = widget.display.funnel?.steps
if (funnelSteps !== undefined && funnelSteps.length > 0) {
if (funnelSteps.length > FUNNEL_MAX_STEPS) {
fatal.push(
`A product-event funnel has at most ${FUNNEL_MAX_STEPS} steps, but \`display_json.funnel.steps\` has ${funnelSteps.length}.`,
)
}
const lateSession = funnelSteps.findIndex((step, index) => index > 0 && step.kind === "session")
if (lateSession !== -1) {
fatal.push(
`A \`{ "kind": "session" }\` funnel step describes how the session was acquired, so it is only valid as step 1 — \`display_json.funnel.steps\` has one at step ${lateSession + 1}.`,
)
}
const windowSeconds = widget.display.funnel?.windowSeconds
if (windowSeconds !== undefined && (!Number.isFinite(windowSeconds) || windowSeconds <= 0)) {
fatal.push(
`\`display_json.funnel.windowSeconds\` is the conversion window and must be a positive number of seconds (got ${JSON.stringify(windowSeconds)}). Omit it to use the default 86400 (24h).`,
)
}
}

// --- warnings ----------------------------------------------------------

// The breakdown endpoint is meaningless ungrouped: one bucket per time slice
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/mcp/resources/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const InstructionsResource = McpServer.resource({
- Trend analysis: query_data (timeseries or breakdown)
- Service discovery: list_services -> diagnose_service
- Alert management: list_alert_rules -> get_alert_rule -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents
- Product analytics / conversion: list_product_events -> query_funnel (steps over page views, \`track()\` events and server events, stitched per person; \`breakdown_by\` a UTM/referrer dimension or an event attribute) -> add_dashboard_widget with \`panel_type: "funnel"\` and \`display_json.funnel.steps\` to pin it

## Dashboards

Expand Down
Loading
Loading