From ca6620fe2cf5b69eb3160ec522438f553d46111f Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Tue, 10 Nov 2026 15:04:14 +0100 Subject: [PATCH] feat(product-events): identify Maple's own events per org and user, and cover in-product actions - Server events no longer land twice: ProductEvents.track's span carried maple.product_event.name, so the trace MV projected every plan_* event again as an unidentified Source='trace' row. - Clerk membership webhook emits member_joined / member_removed (user + org). - Public MCP tool calls are mcp_tool_called and a started investigation is investigation_started, both projected from existing spans with user + org and only their own props (productEventSpanAttributes). - Web identify adds role and plan traits and re-identifies when the plan settles; new web events: member_invited, integration_disconnected, alert_incident_acknowledged, uptime_monitor_created, slo_created, status_page_created, first_data_received. - docs/product-events-catalog.md lists every event, its attributes and identity. --- apps/ai/src/mcp/dispatcher.test.ts | 25 +++ apps/ai/src/mcp/dispatcher.ts | 17 ++ apps/api/src/routes/webhooks/clerk.http.ts | 15 +- .../src/routes/webhooks/webhooks.http.test.ts | 19 ++- apps/web/src/data/analytics-events.test.ts | 146 +++++++++++++++++- apps/web/src/data/product-events.ts | 14 ++ apps/web/src/page/alerts/hub/command.ts | 9 ++ .../web/src/page/alerts/hub/hub.scene.test.ts | 3 + apps/web/src/page/alerts/hub/message.ts | 2 + apps/web/src/page/alerts/hub/update.ts | 3 + apps/web/src/page/alerts/incident/command.ts | 9 ++ .../alerts/incident/incident.scene.test.ts | 5 + apps/web/src/page/alerts/incident/message.ts | 2 + apps/web/src/page/alerts/incident/update.ts | 12 +- apps/web/src/page/alerts/slo-form/command.ts | 7 + apps/web/src/page/alerts/slo-form/message.ts | 2 + apps/web/src/page/alerts/slo-form/update.ts | 10 +- .../src/page/alerts/uptime-form/command.ts | 10 ++ .../src/page/alerts/uptime-form/message.ts | 2 + .../web/src/page/alerts/uptime-form/update.ts | 3 + .../aws/integration/update-aws.ts | 3 +- .../gcp/integration/update-gcp.ts | 2 + apps/web/src/page/integrations/command.ts | 10 ++ apps/web/src/page/integrations/message.ts | 2 + apps/web/src/page/integrations/update-chat.ts | 6 + apps/web/src/page/integrations/update.ts | 22 ++- .../src/page/onboarding/quick-start-team.ts | 14 +- apps/web/src/page/onboarding/quick-start.ts | 30 ++++ apps/web/src/page/settings/members/model.ts | 4 +- apps/web/src/page/settings/members/update.ts | 20 ++- .../builder/builder.scene.test.ts | 8 +- .../src/page/status-pages/builder/command.ts | 16 ++ .../src/page/status-pages/builder/message.ts | 2 + .../src/page/status-pages/builder/update.ts | 15 +- .../src/page/status-pages/editor/command.ts | 12 ++ .../status-pages/editor/editor.scene.test.ts | 1 + .../src/page/status-pages/editor/message.ts | 2 + .../status-pages/editor/update-components.ts | 8 +- .../src/page/status-pages/editor/update.ts | 1 + apps/web/src/platform/clerk.ts | 9 ++ apps/web/src/platform/session-identity.ts | 24 ++- apps/web/src/update.customer-grace.test.ts | 1 + apps/web/src/update.pending-task.test.ts | 1 + apps/web/src/update.test.ts | 45 +++++- apps/web/src/update.ts | 42 +++-- docs/product-events-catalog.md | 124 +++++++++++++++ docs/product-events-funnels.md | 4 +- .../errors/InvestigationService.test.ts | 32 +++- .../services/errors/InvestigationService.ts | 10 ++ .../ProductEventsService.test.ts | 29 +++- .../product-events/ProductEventsService.ts | 12 +- .../services/product-events/clerk-events.ts | 21 +++ packages/domain/package.json | 1 + .../tinybird/product-event-attributes.test.ts | 19 +++ .../src/tinybird/product-event-attributes.ts | 21 +++ 55 files changed, 835 insertions(+), 53 deletions(-) create mode 100644 docs/product-events-catalog.md diff --git a/apps/ai/src/mcp/dispatcher.test.ts b/apps/ai/src/mcp/dispatcher.test.ts index 2c5d3d1019..29b1b10f74 100644 --- a/apps/ai/src/mcp/dispatcher.test.ts +++ b/apps/ai/src/mcp/dispatcher.test.ts @@ -299,6 +299,31 @@ describe("MCP dispatcher", () => { }), ) + it.effect( + "makes a public MCP call an identified mcp_tool_called product event, an agent turn's not", + () => + Effect.gen(function* () { + const executor = yield* makeValidationExecutor + const { spans, tracer } = makeRecordingTracer() + const executorSpans = () => Arr.filter(spans, (s) => s.name === "McpToolExecutor.execute") + + yield* executor + .execute({ ...TENANT, mcpClientName: "claude-code" }, "inspect_trace", {}, "mcp") + .pipe(Effect.withTracer(tracer)) + yield* executor + .execute(TENANT, "inspect_trace", {}, "chat") + .pipe(Effect.withTracer(tracer)) + + const [publicCall, agentCall] = executorSpans() + expect(publicCall?.attributes.get("maple.product_event.name")).toBe("mcp_tool_called") + expect(publicCall?.attributes.get("maple.product_event.user_id")).toBe("user_test") + expect(publicCall?.attributes.get("maple.product_event.group_id")).toBe("org_test") + expect(publicCall?.attributes.get("maple.product_event.prop.tool")).toBe("inspect_trace") + expect(publicCall?.attributes.get("maple.product_event.prop.client")).toBe("claude-code") + expect(agentCall?.attributes.has("maple.product_event.name")).toBe(false) + }), + ) + it.effect("marks a failed tool call on the dispatcher span", () => Effect.gen(function* () { const executor = yield* makeValidationExecutor diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index ded089d845..e91285ae2e 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -18,6 +18,7 @@ import { recordExpectedMcpFailure, spanFailureMessage } from "./expected-failure import { loadRegistry } from "./tool-catalog" import { recordMcpToolAudit } from "@maple/backend/services/audit/audit-access" import type { TenantContext } from "@maple/domain/http" +import { productEventSpanAttributes } from "@maple/domain/tinybird/product-event-attributes" /** * How a failure reads to the model: one short lead naming the kind of failure, never an error tag. @@ -224,6 +225,22 @@ export class McpToolExecutor extends Context.Service { // Membership is changed in Clerk, never through Maple's API, so this receiver // is the only writer of `affected_user`. - it.live("audits an organizationMembership.created delivery against the member", () => + it.live("audits an organizationMembership.created delivery and emits member_joined", () => Effect.gen(function* () { const events = recordingProductEvents() const audit = recordingAudit() @@ -303,6 +303,15 @@ describe("ClerkWebhookRouter", () => { // here would be a guess, so the entry is Maple recording what it learned. assert.strictEqual(entry.actor.type, "system") assert.strictEqual(entry.source, "system") + assert.deepStrictEqual(events.tracked, [ + { + name: "member_joined", + userId: "user_2abc", + groupId: "org_42", + timestamp: 1_700_000_000_000, + attributes: { role: "org:admin" }, + }, + ]) }).pipe(Effect.ensuring(Effect.promise(() => configured.dispose()))) }), ) @@ -429,6 +438,11 @@ describe("ClerkWebhookRouter", () => { "demote:org_9:user_9:org:member", "revokeUser:user_9", ]) + // The removal is a product event on the member and org; a role change is not. + assert.deepStrictEqual( + Arr.map(events.tracked, (event) => [event.name, event.userId, event.groupId]), + [["member_removed", "user_9", "org_9"]], + ) // No role in the payload: we cannot tell demotion from promotion, so // the caches are evicted and nothing is revoked on a guess. @@ -485,8 +499,11 @@ describe("ClerkWebhookRouter", () => { yield* Clock.currentTimeMillis, "msg_fail", ) + const before = events.tracked.length const response = yield* post(broken.handler, "/webhooks/clerk", body, headers) assert.strictEqual(response.status, 500) + // Clerk retries a 500, so the failed delivery must not emit member_removed. + assert.strictEqual(events.tracked.length, before) }).pipe(Effect.ensuring(Effect.promise(broken.dispose))) }), ) diff --git a/apps/web/src/data/analytics-events.test.ts b/apps/web/src/data/analytics-events.test.ts index 98782105b7..3d0d7fc975 100644 --- a/apps/web/src/data/analytics-events.test.ts +++ b/apps/web/src/data/analytics-events.test.ts @@ -2,13 +2,28 @@ import { OrgId, UserId } from "@maple/domain" import { V2ApiKey, V2ApiKeyWithSecret } from "@maple/domain/http/v2" import { Array as Arr, Option, Result, Schema } from "effect" import { describe, expect, test } from "vitest" +import { toLoadFailure } from "@maple/views/data/load-failure" +import type { SignalPresences } from "@maple/views/data/signal-presence" +import { checkoutSlo } from "../page/alerts/shared/slo-test-fixtures" +import { httpMonitor } from "../page/kit/uptime/test-monitors" +import * as SloForm from "../page/alerts/slo-form/update" +import { Message as SloFormMessage } from "../page/alerts/slo-form/message" +import * as UptimeForm from "../page/alerts/uptime-form/update" +import { Message as UptimeFormMessage } from "../page/alerts/uptime-form/message" import type { PageContext } from "../page/contract" import { Message as IntegrationsMessage } from "../page/integrations/message" import { init as initIntegrations, update as updateIntegrations } from "../page/integrations/update" import * as PricingCards from "../page/onboarding/pricing-cards" import * as QuickStart from "../page/onboarding/quick-start" +import { ConnectMessage } from "../page/onboarding/quick-start-connect" import { DEFAULT_QUICK_START_STATE } from "../page/onboarding/quick-start-state" +import { loadingTeam, TeamMessage, updateTeam } from "../page/onboarding/quick-start-team" import * as CreateDialog from "../page/settings/keys/create-dialog" +import { Message as MembersMessage } from "../page/settings/members/model" +import * as Members from "../page/settings/members/update" +import { Message as BuilderMessage } from "../page/status-pages/builder/message" +import * as Builder from "../page/status-pages/builder/update" +import { statusPage } from "../page/status-pages/shared/test-pages" import type { ClerkSnapshot } from "../platform/clerk" import { identityOf, isSameIdentity } from "../platform/session-identity" import * as CheckoutReturn from "../page/kit/checkout-return" @@ -18,6 +33,7 @@ import * as CheckoutReturn from "../page/kit/checkout-return" type Commands = { readonly commands?: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> } const tracked = (result: Commands, name: string) => Option.getOrUndefined(Arr.findFirst(result.commands ?? [], (command) => command.name === name))?.args +const names = (result: Commands) => Arr.map(result.commands ?? [], (command) => command.name) const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -109,6 +125,113 @@ describe("product events", () => { ) expect(tracked(timedOut, "TrackCheckoutReturned")).toEqual({ confirmed: false }) }) + + test("a confirmed disconnect is integration_disconnected with its provider", () => { + const model = initIntegrations({ search: { integration: "cloudflare" } }, context).model + const disconnected = (failure: ReturnType | null) => + updateIntegrations( + model, + IntegrationsMessage.CompletedDisconnect({ integration: "cloudflare-multi", failure }), + context, + ) + expect(tracked(disconnected(null), "TrackIntegrationDisconnected")).toEqual({ + provider: "cloudflare", + }) + expect( + tracked(disconnected(toLoadFailure(new Error("denied"))), "TrackIntegrationDisconnected"), + ).toBeUndefined() + }) + + test("a sent invitation is member_invited with the invited role", () => { + const model = Members.init([]).model + const invited = (result: Result.Result) => + Members.update( + model, + MembersMessage.CompletedInvite({ email: "grace@acme.test", role: "org:admin", result }), + ) + expect(tracked(invited(Result.succeed(undefined)), "TrackMemberInvited")).toEqual({ + role: "org:admin", + }) + expect( + tracked(invited(Result.fail("Couldn't send invitation")), "TrackMemberInvited"), + ).toBeUndefined() + }) + + test("an onboarding invitation is member_invited; a rename is not", () => { + const team = loadingTeam() + const succeeded = (operation: "invite" | "rename") => + updateTeam(team, TeamMessage.SucceededTeamOperation({ operation, value: "grace@acme.test" })) + expect(Arr.map(succeeded("invite").commands, ({ name }) => name)).toEqual([ + "TrackOnboardingMemberInvited", + ]) + expect(succeeded("rename").commands).toEqual([]) + expect(updateTeam(team, TeamMessage.FailedTeamOperation({ message: "nope" })).commands).toEqual([]) + }) + + test("a created uptime monitor is uptime_monitor_created with its kind; an edit is not", () => { + const created = UptimeForm.init({ _tag: "AlertsUptimeNew", search: {} }, context).model + const saved = ( + model: typeof created, + result: Parameters[0]["result"], + ) => UptimeForm.update(model, UptimeFormMessage.CompletedSave({ result }), context) + const monitor = httpMonitor() + expect(tracked(saved(created, Result.succeed(monitor)), "TrackUptimeMonitorCreated")).toEqual({ + kind: monitor.kind, + }) + expect( + tracked( + saved({ ...created, editingId: monitor.id }, Result.succeed(monitor)), + "TrackUptimeMonitorCreated", + ), + ).toBeUndefined() + expect( + tracked( + saved(created, Result.fail(toLoadFailure(new Error("boom")))), + "TrackUptimeMonitorCreated", + ), + ).toBeUndefined() + }) + + test("a created SLO is slo_created; a failed save is not", () => { + const model = SloForm.init({ _tag: "AlertsSloNew", search: {} }, context).model + const saved = (result: Parameters[0]["result"]) => + SloForm.update(model, SloFormMessage.CompletedSave({ result }), context) + expect(names(saved(Result.succeed(checkoutSlo())))).toContain("TrackSloCreated") + expect(names(saved(Result.fail(toLoadFailure(new Error("boom")))))).not.toContain("TrackSloCreated") + }) + + test("a created status page is status_page_created; a failed create is not", () => { + const model = Builder.init({ _tag: "StatusPageNew" }, context).model + const created = (result: Parameters[0]["result"]) => + Builder.update(model, BuilderMessage.CompletedCreate({ mode: "draft", result }), context) + expect(names(created(Result.succeed(statusPage())))).toContain("TrackStatusPageCreated") + expect(names(created(Result.fail(toLoadFailure(new Error("boom")))))).not.toContain( + "TrackStatusPageCreated", + ) + }) + + test("the connect step seeing a first signal arrive is first_data_received, once", () => { + const presence = (status: "present" | "absent"): SignalPresences => ({ + traces: { status, lastSeenMs: null }, + logs: { status: "absent", lastSeenMs: null }, + }) + const read = (model: QuickStart.Model, presences: SignalPresences) => + QuickStart.update( + model, + QuickStart.Message.GotConnectMessage({ + message: ConnectMessage.LoadedPresence({ result: Result.succeed(presences) }), + }), + true, + ) + const start = QuickStart.init("maple-onboarding-v6-org_1").model + const empty = read(start, presence("absent")) + expect(tracked(empty, "TrackFirstDataReceived")).toBeUndefined() + const arrived = read(empty.model, presence("present")) + expect(tracked(arrived, "TrackFirstDataReceived")).toEqual({ signal: "traces" }) + expect(tracked(read(arrived.model, presence("present")), "TrackFirstDataReceived")).toBeUndefined() + // Data already there when the step opened is not an arrival it saw. + expect(tracked(read(start, presence("present")), "TrackFirstDataReceived")).toBeUndefined() + }) }) const snapshot = (fullName: string | null): ClerkSnapshot => ({ @@ -117,22 +240,33 @@ const snapshot = (fullName: string | null): ClerkSnapshot => ({ maybeUser: Option.some({ fullName, email: "ada@acme.test", imageUrl: "" }), maybeOrganization: Option.none(), memberships: [], + maybeRole: Option.some("org:admin"), maybePendingTask: Option.none(), }) describe("session identity", () => { - test("the signed-in user and org name the session; signed out is nobody", () => { - expect(identityOf(snapshot("Ada Lovelace"))).toEqual({ + test("the signed-in user, org, role and plan name the session; signed out is nobody", () => { + expect(identityOf(snapshot("Ada Lovelace"), "startup")).toEqual({ id: "user_ada", username: "Ada Lovelace", email: "ada@acme.test", groupId: "org_acme", + traits: { role: "org:admin", plan: "startup" }, }) - expect(identityOf({ ...snapshot(null), maybeUserId: Option.none() })).toBeNull() + expect(identityOf({ ...snapshot("Ada"), maybeRole: Option.none() }, null)?.traits).toEqual({}) + expect(identityOf({ ...snapshot(null), maybeUserId: Option.none() }, null)).toBeNull() }) - test("identify runs again only when a primitive changed", () => { - expect(isSameIdentity(identityOf(snapshot("Ada")), identityOf(snapshot("Ada")))).toBe(true) - expect(isSameIdentity(identityOf(snapshot("Ada")), identityOf(snapshot("Ada L.")))).toBe(false) + test("identify runs again only when a primitive changed, the plan and role included", () => { + const ada = identityOf(snapshot("Ada"), null) + expect(isSameIdentity(ada, identityOf(snapshot("Ada"), null))).toBe(true) + expect(isSameIdentity(ada, identityOf(snapshot("Ada L."), null))).toBe(false) + expect(isSameIdentity(ada, identityOf(snapshot("Ada"), "startup"))).toBe(false) + expect( + isSameIdentity( + ada, + identityOf({ ...snapshot("Ada"), maybeRole: Option.some("org:member") }, null), + ), + ).toBe(false) }) }) diff --git a/apps/web/src/data/product-events.ts b/apps/web/src/data/product-events.ts index 773df88709..fb5f2930a5 100644 --- a/apps/web/src/data/product-events.ts +++ b/apps/web/src/data/product-events.ts @@ -18,6 +18,20 @@ export type ProductEvent = /** The org admin redeemed the onboarding-checklist credit from the top bar. */ | "onboarding_reward_claimed" | "integration_connected" + /** An integration's connection was removed; `provider` as in `integration_connected`. */ + | "integration_disconnected" + /** An admin invited a teammate to the org; `role` is the invited membership role. */ + | "member_invited" + /** Someone acknowledged an open alert incident from the alerts inbox or the incident page. */ + | "alert_incident_acknowledged" + /** An uptime monitor was created; `kind` is its check type (`http`, `tcp`, ...). */ + | "uptime_monitor_created" + /** An SLO was created. */ + | "slo_created" + /** A status page was created, as a draft or published. */ + | "status_page_created" + /** The onboarding connect step saw the org's first telemetry arrive; `signal` is its kind. */ + | "first_data_received" | "alert_rule_created" | "api_key_created" | "dashboard_created" diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 409b535a78..fb5b68fa19 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -16,6 +16,7 @@ import { Cause, Clock, Effect, Schema } from "effect" import { IdleTtl } from "../../../data/idle-ttl" import { readThrough } from "../../../data/read-cache" import { toLoadFailure, settleTo } from "@maple/views/data/load-failure" +import { trackProduct } from "../../../data/analytics" import { Command } from "foldkit" import { dismissSheetUrl, pushSheetUrl } from "../../kit/sheet-param" import { @@ -117,6 +118,14 @@ export const AcknowledgeIncident = Command.define("AcknowledgeAlertIncident", { ), }) +/** An acknowledgement that landed is the `alert_incident_acknowledged` product event. */ +export const TrackAcknowledged = Command.define("TrackHubIncidentAcknowledged", { + messages: [Message.CompletedTrackAcknowledged], + execute: trackProduct("alert_incident_acknowledged").pipe( + Effect.as(Message.CompletedTrackAcknowledged()), + ), +}) + /** The row's toggle and the dialog's save: PATCH the destination. */ export const UpdateDestination = Command.define("UpdateAlertDestination", { args: { destinationId: AlertDestinationId, payload: V2AlertDestinationUpdateParams }, diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 44a0aadfdb..fc6d8c2d08 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -327,6 +327,9 @@ describe("alerts hub", () => { }), ) expect(succeeded.model.acknowledgingIncidentIds).toEqual([]) + // Only an acknowledgement that landed is the product event. + expect(names(succeeded.commands)).toContain("TrackHubIncidentAcknowledged") + expect(names(failed.commands)).not.toContain("TrackHubIncidentAcknowledged") scene( { update: pageUpdate, view }, given(succeeded.model), diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 1f8d6313b8..71ba17656c 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -81,6 +81,8 @@ export const Message = defineMessageUnion({ acknowledged: Schema.Boolean, result: LoadOutcome(V2AlertIncident), }, + /** `trackProduct("alert_incident_acknowledged")` was handed to the session SDK. */ + CompletedTrackAcknowledged: {}, ClickedToggleDestination: { destinationId: AlertDestinationId }, CompletedUpdateDestination: { destinationId: AlertDestinationId, result: LoadOutcome(Schema.Void) }, ClickedTestDestination: { destinationId: AlertDestinationId }, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 18ce4f5095..b98878bb1b 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -44,6 +44,7 @@ import { FetchHubSchedules, DeleteChain, AcknowledgeIncident, + TrackAcknowledged, DeleteDestination, FetchHubChains, DeleteMonitor, @@ -1169,6 +1170,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? null : withAcknowledgement(settled.incidents, incident), }, + commands: acknowledged ? [TrackAcknowledged()] : [], }), onFailure: (failure) => ({ model: settled, @@ -1181,6 +1183,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), }) }, + CompletedTrackAcknowledged: () => ({ model }), ClickedToggleDestination: ({ destinationId }) => toggleDestination(model, destinationId), CompletedUpdateDestination: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 86269c81f2..906a098403 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -11,6 +11,7 @@ import { listMonitors } from "../../kit/uptime/commands" import { retrieveIncidentEscalation } from "../shared/escalation-chains" import { listActiveSilences } from "../shared/silences" import { readGrants } from "../../../data/session-tenant" +import { trackProduct } from "../../../data/analytics" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -66,6 +67,14 @@ export const SetIncidentAcknowledged = Command.define("SetAlertIncidentAcknowled ), }) +/** An acknowledgement that landed is the `alert_incident_acknowledged` product event. */ +export const TrackAcknowledged = Command.define("TrackIncidentAcknowledged", { + messages: [Message.CompletedTrackAcknowledged], + execute: trackProduct("alert_incident_acknowledged").pipe( + Effect.as(Message.CompletedTrackAcknowledged()), + ), +}) + /** `investigations.list({ incident_kind: "alert", incident_id, limit: 1 })`: the incident's one. */ export const FetchIncidentInvestigation = Command.define("FetchAlertIncidentInvestigation", { args: { key: Schema.String, incidentId: AlertIncidentId }, diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 6844f3e339..9eb682e994 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -21,6 +21,7 @@ import { FetchIncidentActivity, FetchIncidentEscalation, SetIncidentAcknowledged, + TrackAcknowledged, } from "./command" import { encodePublicId, @@ -551,6 +552,7 @@ describe("alert incident", () => { result: Result.succeed(ackedIncident(ackedAt)), }), ), + Command.resolve(TrackAcknowledged, Message.CompletedTrackAcknowledged()), // The patched row's ack moved the history's key, so the history is read again and drawn. Command.resolve( FetchIncidentActivity, @@ -623,6 +625,9 @@ describe("alert incident", () => { title: "Couldn't acknowledge the incident", }) expect(failed.model.isUpdatingAcknowledgement).toBe(false) + // Only an acknowledgement that landed is the product event. + expect(names(done.commands)).toContain("TrackIncidentAcknowledged") + expect(names(failed.commands)).not.toContain("TrackIncidentAcknowledged") }) test("a resolved incident offers no acknowledge button", () => { diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 3f19f4005f..189a0d2c1a 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -72,6 +72,8 @@ export const Message = defineMessageUnion({ acknowledged: Schema.Boolean, result: Schema.Result(V2AlertIncident, LoadFailure), }, + /** `trackProduct("alert_incident_acknowledged")` was handed to the session SDK. */ + CompletedTrackAcknowledged: {}, CompletedCreateInvestigation: { result: Schema.Result(Schema.String, LoadFailure) }, ...ChartEnvMessages, GotChartMessage: { message: Chart.Message }, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index ae250985a1..5581dfd3b7 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -37,6 +37,7 @@ import { FetchIncidentSlos, ReadIncidentClock, SetIncidentAcknowledged, + TrackAcknowledged, } from "./command" import { withAcknowledgement } from "../shared/commands" import { SLO_LIST_LIMIT } from "../shared/slo-commands" @@ -568,8 +569,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), CompletedSetAcknowledged: ({ acknowledged, result }) => Result.match(result, { - onSuccess: (incident) => - followData( + onSuccess: (incident) => { + const next = followData( { ...model, isUpdatingAcknowledgement: false, @@ -579,7 +580,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re : withAcknowledgement(model.incidents, incident), }, context, - ), + ) + return acknowledged + ? { ...next, commands: [...(next.commands ?? []), TrackAcknowledged()] } + : next + }, onFailure: (failure) => ({ model: { ...model, isUpdatingAcknowledgement: false }, outMessage: PageOutMessage.RequestedToast({ @@ -619,6 +624,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re commands: [CreateInvestigation({ payload })], }), }), + CompletedTrackAcknowledged: () => ({ model }), CompletedCreateInvestigation: ({ result }) => Result.match(result, { onSuccess: (id) => ({ diff --git a/apps/web/src/page/alerts/slo-form/command.ts b/apps/web/src/page/alerts/slo-form/command.ts index 7248e12e4c..4d0346ff39 100644 --- a/apps/web/src/page/alerts/slo-form/command.ts +++ b/apps/web/src/page/alerts/slo-form/command.ts @@ -1,6 +1,7 @@ import { SloId } from "@maple/domain" import { Effect, Schema } from "effect" import { Command } from "foldkit" +import { trackProduct } from "../../../data/analytics" import { recentServiceNames } from "../shared/services" import { retrieveSlo, saveSlo } from "../shared/slo-commands" import { Message } from "./message" @@ -33,3 +34,9 @@ export const SaveSloDraft = Command.define("SaveSloDraft", { Effect.map((result) => Message.CompletedSave({ result })), ), }) + +/** A created SLO is the `slo_created` product event. */ +export const TrackSloCreated = Command.define("TrackSloCreated", { + messages: [Message.CompletedTrackCreated], + execute: trackProduct("slo_created").pipe(Effect.as(Message.CompletedTrackCreated())), +}) diff --git a/apps/web/src/page/alerts/slo-form/message.ts b/apps/web/src/page/alerts/slo-form/message.ts index a77625ed14..6ab1faefaf 100644 --- a/apps/web/src/page/alerts/slo-form/message.ts +++ b/apps/web/src/page/alerts/slo-form/message.ts @@ -24,5 +24,7 @@ export const Message = defineMessageUnion({ GotDestinationMenuMessage: { tier: Tier, message: Menu.Message }, ClickedSave: {}, CompletedSave: { result: LoadOutcome(V2Slo) }, + /** `trackProduct("slo_created")` was handed to the session SDK. */ + CompletedTrackCreated: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/slo-form/update.ts b/apps/web/src/page/alerts/slo-form/update.ts index ff2a03b284..fd645ac04a 100644 --- a/apps/web/src/page/alerts/slo-form/update.ts +++ b/apps/web/src/page/alerts/slo-form/update.ts @@ -8,7 +8,13 @@ import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { pickedDestination } from "../../kit/destination-picker" import { sloHref, sloIdFromUrl } from "../shared/slo-commands" -import { FetchExistingSlo, FetchSloFormServices, FetchSloFormSession, SaveSloDraft } from "./command" +import { + FetchExistingSlo, + FetchSloFormServices, + FetchSloFormSession, + SaveSloDraft, + TrackSloCreated, +} from "./command" import { formFromSlo, formIssues, wirePayload } from "./derive" import { emptyForm, type Form, type Tier } from "./form" import { Message } from "./message" @@ -132,6 +138,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re Result.match(result, { onSuccess: (slo): Return => ({ model: { ...model, isSaving: false }, + commands: model.editingId === null ? [TrackSloCreated()] : [], outMessage: PageOutMessage.RequestedNavigation({ url: sloHref(slo.id), isReplace: true, @@ -145,4 +152,5 @@ export const update = (model: Model, message: Message, context: PageContext): Re // Inline beside the action bar, never a toast over its buttons. onFailure: (saveFailure): Return => ({ model: { ...model, isSaving: false, saveFailure } }), }), + CompletedTrackCreated: () => ({ model }), }) diff --git a/apps/web/src/page/alerts/uptime-form/command.ts b/apps/web/src/page/alerts/uptime-form/command.ts index 1dd5abfaa6..1c7a94569c 100644 --- a/apps/web/src/page/alerts/uptime-form/command.ts +++ b/apps/web/src/page/alerts/uptime-form/command.ts @@ -1,7 +1,9 @@ import { UptimeMonitorId } from "@maple/domain" +import { UptimeMonitorKind } from "@maple/primitives" import { Effect, Schema } from "effect" import { Command } from "foldkit" import { readSupportedTimeZones } from "../../../platform/time-zone" +import { trackProduct } from "../../../data/analytics" import { recentServiceNames } from "../shared/services" import { retrieveMonitor, saveMonitor, testMonitorConfig } from "../../kit/uptime/commands" import { Message } from "./message" @@ -50,3 +52,11 @@ export const SaveDraft = Command.define("SaveUptimeDraft", { Effect.map((result) => Message.CompletedSave({ result })), ), }) + +/** A created monitor is the `uptime_monitor_created` product event. */ +export const TrackCreated = Command.define("TrackUptimeMonitorCreated", { + args: { kind: UptimeMonitorKind }, + messages: [Message.CompletedTrackCreated], + execute: ({ kind }) => + trackProduct("uptime_monitor_created", { kind }).pipe(Effect.as(Message.CompletedTrackCreated())), +}) diff --git a/apps/web/src/page/alerts/uptime-form/message.ts b/apps/web/src/page/alerts/uptime-form/message.ts index 4f9dffbe0b..09f8cc3874 100644 --- a/apps/web/src/page/alerts/uptime-form/message.ts +++ b/apps/web/src/page/alerts/uptime-form/message.ts @@ -44,5 +44,7 @@ export const Message = defineMessageUnion({ CompletedTest: { result: LoadOutcome(V2UptimeMonitorTestResult) }, ClickedSave: {}, CompletedSave: { result: LoadOutcome(V2UptimeMonitor) }, + /** `trackProduct("uptime_monitor_created")` was handed to the session SDK. */ + CompletedTrackCreated: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/uptime-form/update.ts b/apps/web/src/page/alerts/uptime-form/update.ts index 96906033b8..6c88fb1134 100644 --- a/apps/web/src/page/alerts/uptime-form/update.ts +++ b/apps/web/src/page/alerts/uptime-form/update.ts @@ -15,6 +15,7 @@ import { ReadFormTimeZones, SaveDraft, TestDraft, + TrackCreated, } from "./command" import { defaultName, @@ -222,6 +223,7 @@ const updateForm = (model: Model, message: Message, context: PageContext): Retur Result.match(result, { onSuccess: (monitor): Return => ({ model: { ...model, isSaving: false }, + commands: model.editingId === null ? [TrackCreated({ kind: monitor.kind })] : [], outMessage: PageOutMessage.RequestedNavigation({ url: monitorHref(monitor.id), isReplace: true, @@ -235,4 +237,5 @@ const updateForm = (model: Model, message: Message, context: PageContext): Retur // Inline beside the action bar, never a toast over its buttons. onFailure: (saveFailure): Return => ({ model: { ...model, isSaving: false, saveFailure } }), }), + CompletedTrackCreated: () => ({ model }), }) diff --git a/apps/web/src/page/infra-integrations/aws/integration/update-aws.ts b/apps/web/src/page/infra-integrations/aws/integration/update-aws.ts index eddef367d2..e935610130 100644 --- a/apps/web/src/page/infra-integrations/aws/integration/update-aws.ts +++ b/apps/web/src/page/infra-integrations/aws/integration/update-aws.ts @@ -33,7 +33,7 @@ import { WriteAwsRemoved, } from "./aws" import { integrationOfSearch } from "../../../integrations/catalog" -import { ReadClock } from "../../../integrations/command" +import { ReadClock, TrackDisconnected } from "../../../integrations/command" import { disconnectSpec } from "../../../integrations/disconnect" import { Message } from "../../../integrations/message" import type { Model } from "../../../integrations/model" @@ -303,6 +303,7 @@ export const completedDisconnect = ( "Delete the stack in AWS to remove the role.", ), (next) => requestStatus(next, context, "aws", "Supersede"), + (next) => ({ model: next, commands: [TrackDisconnected({ provider: "aws" })] }), ], ) } diff --git a/apps/web/src/page/infra-integrations/gcp/integration/update-gcp.ts b/apps/web/src/page/infra-integrations/gcp/integration/update-gcp.ts index fac0ca2a5d..bc27e4e05c 100644 --- a/apps/web/src/page/infra-integrations/gcp/integration/update-gcp.ts +++ b/apps/web/src/page/infra-integrations/gcp/integration/update-gcp.ts @@ -30,6 +30,7 @@ import { disconnectSpec } from "../../../integrations/disconnect" import { Message } from "../../../integrations/message" import type { Model } from "../../../integrations/model" import type { ConfigureTarget } from "./gcp-schema" +import { TrackDisconnected } from "../../../integrations/command" import { chain, requestStatus } from "../../../integrations/update-status" type Return = PageReturn @@ -274,6 +275,7 @@ export const completedDisconnect = ( (next) => requestStatus(next, context, "gcp", "Supersede"), // The band's resource inventory is a read of its own. (next) => refreshInventory(next), + (next) => ({ model: next, commands: [TrackDisconnected({ provider: "gcp" })] }), ]) } diff --git a/apps/web/src/page/integrations/command.ts b/apps/web/src/page/integrations/command.ts index f8b7deaea8..4f8ebc0718 100644 --- a/apps/web/src/page/integrations/command.ts +++ b/apps/web/src/page/integrations/command.ts @@ -113,6 +113,16 @@ export const TrackConnected = Command.define("TrackIntegrationConnected", { ), }) +/** A confirmed disconnect is the `integration_disconnected` product event. */ +export const TrackDisconnected = Command.define("TrackIntegrationDisconnected", { + args: { provider: Schema.String }, + messages: [Message.CompletedTrackDisconnected], + execute: ({ provider }) => + trackProduct("integration_disconnected", { provider }).pipe( + Effect.as(Message.CompletedTrackDisconnected()), + ), +}) + /** The session the shell already read (`SessionTenant`): the settings rail and connecting read its grants. */ export const LoadSession = Command.define("LoadIntegrationsSession", { messages: [Message.CompletedLoadSession], diff --git a/apps/web/src/page/integrations/message.ts b/apps/web/src/page/integrations/message.ts index 00c5af11a8..f0d22cd8a3 100644 --- a/apps/web/src/page/integrations/message.ts +++ b/apps/web/src/page/integrations/message.ts @@ -120,6 +120,8 @@ export const Message = defineMessageUnion({ CompletedLoadCustomer: result(BillingCustomer), /** `trackProduct("integration_connected")` was handed to the session SDK. */ CompletedTrackConnected: {}, + /** `trackProduct("integration_disconnected")` was handed to the session SDK. */ + CompletedTrackDisconnected: {}, /** The shared session read answered with its grants. */ CompletedLoadSession: { grants: Schema.Array(Policy.PermissionName), hasDataSources: Schema.Boolean }, /** A catalog row or card, or the header's back button. */ diff --git a/apps/web/src/page/integrations/update-chat.ts b/apps/web/src/page/integrations/update-chat.ts index c80fc088e8..84565dfb2e 100644 --- a/apps/web/src/page/integrations/update-chat.ts +++ b/apps/web/src/page/integrations/update-chat.ts @@ -12,6 +12,7 @@ import { LinkChatIdentity, UnlinkChatIdentity, } from "./chat" +import { TrackDisconnected } from "./command" import { disconnectSpec } from "./disconnect" import { Message } from "./message" import type { Model } from "./model" @@ -135,8 +136,13 @@ export const completedDisconnect = ( withCard(model, { busy: null, confirmId: null }), Dialog.close(model.disconnect.dialog), ) + const connector = connectorOfPage(model) return chain(closed.model, [ () => closed, (next) => outcome(next, context, failure, disconnectSpec("chat")), + (next) => + failure === null && connector !== null + ? { model: next, commands: [TrackDisconnected({ provider: connector })] } + : { model: next }, ]) } diff --git a/apps/web/src/page/integrations/update.ts b/apps/web/src/page/integrations/update.ts index be547aef04..8967b1181d 100644 --- a/apps/web/src/page/integrations/update.ts +++ b/apps/web/src/page/integrations/update.ts @@ -34,7 +34,14 @@ import { ChartRuntime } from "@maple/views/charts/chart-runtime" import { isMultiAccount } from "../infra-integrations/cloudflare/integration/view-cloudflare" import * as Connect from "./update-connect" import { isClerkAuthEnabled } from "../../platform/auth-mode" -import { CompleteOAuthReturn, LoadCustomer, LoadSession, ReadClock, StripChatParams } from "./command" +import { + CompleteOAuthReturn, + LoadCustomer, + LoadSession, + ReadClock, + StripChatParams, + TrackDisconnected, +} from "./command" import type { OAuthReturnOutcome } from "./oauth-return" import { chain, requestStatus, statusNamesOf } from "./update-status" import { Message } from "./message" @@ -317,6 +324,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re false, ), CompletedTrackConnected: () => ({ model }), + CompletedTrackDisconnected: () => ({ model }), ClickedIntegration: ({ integration }) => navigateTo(model, hrefOfSearch(integration === null ? {} : { integration }), false), ClickedSettingsTab: ({ tab }) => navigateTo(model, `/settings?tab=${encodeURIComponent(tab)}`, false), @@ -631,6 +639,18 @@ export const update = (model: Model, message: Message, context: PageContext): Re ) return chain(settled, [ (next) => ({ model: next, commands: closed.commands, outMessage: toast }), + (next) => + failure === null + ? { + model: next, + commands: [ + TrackDisconnected({ + provider: + integration === "cloudflare-multi" ? "cloudflare" : integration, + }), + ], + } + : { model: next }, ...Arr.map( failure === null ? spec.refresh : [], (name) => (next: Model) => requestStatus(next, context, name, "Supersede"), diff --git a/apps/web/src/page/onboarding/quick-start-team.ts b/apps/web/src/page/onboarding/quick-start-team.ts index cf4fcfab72..7af6b110ac 100644 --- a/apps/web/src/page/onboarding/quick-start-team.ts +++ b/apps/web/src/page/onboarding/quick-start-team.ts @@ -2,6 +2,7 @@ import { makeOnboardingTeamActions } from "./team-actions" import { Effect, Option, Schema, Array as Arr, String as Str } from "effect" import { Command } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import { trackProduct } from "../../data/analytics" import { clerkTeamOrganization, clerkTry } from "../../platform/clerk" // The team step: rename the workspace and invite teammates through the Clerk organization, or the self-hosted notice. @@ -55,6 +56,8 @@ export const TeamMessage = defineMessageUnion({ SubmittedInvite: {}, SucceededTeamOperation: { operation: Operation, value: Schema.String }, FailedTeamOperation: { message: Schema.String }, + /** `trackProduct("member_invited")` was handed to the session SDK. */ + TrackedMemberInvited: {}, }) export type TeamMessage = typeof TeamMessage.Type @@ -103,6 +106,14 @@ export const RunTeamOperation = Command.define("RunOnboardingTeamOperation", { ), }) +/** A sent invitation is the `member_invited` product event; onboarding invites members only. */ +const TrackMemberInvited = Command.define("TrackOnboardingMemberInvited", { + messages: [TeamMessage.TrackedMemberInvited], + execute: trackProduct("member_invited", { role: "org:member" }).pipe( + Effect.as(TeamMessage.TrackedMemberInvited()), + ), +}) + export const isNameDirty = (team: Team): boolean => Str.trim(team.name) !== team.savedName export const isAlreadyInvited = (team: Team): boolean => @@ -152,10 +163,11 @@ export const updateTeam = (team: Team, message: TeamMessage): TeamStep => invited: [...team.invited, value], feedback: { message: `Invitation sent to ${value}.`, isError: false }, }, - commands: [], + commands: operation === "invite" ? [TrackMemberInvited()] : [], }), FailedTeamOperation: ({ message: failure }) => ({ team: { ...team, pending: null, feedback: { message: failure, isError: true } }, commands: [], }), + TrackedMemberInvited: () => ({ team, commands: [] }), }) diff --git a/apps/web/src/page/onboarding/quick-start.ts b/apps/web/src/page/onboarding/quick-start.ts index 43ac2ac349..cf208b47a9 100644 --- a/apps/web/src/page/onboarding/quick-start.ts +++ b/apps/web/src/page/onboarding/quick-start.ts @@ -109,6 +109,8 @@ export const Message = defineMessageUnion({ CompletedSaveQuickStartState: {}, /** `trackProduct("onboarding_step_completed")` was handed to the session SDK. */ CompletedTrackStepCompleted: {}, + /** `trackProduct("first_data_received")` was handed to the session SDK. */ + CompletedTrackFirstData: {}, PickedRegion: { region: MapleRegion }, ClickedRegionContinue: {}, SucceededChooseRegion: { region: MapleRegion }, @@ -139,6 +141,14 @@ export const TrackStepCompleted = Command.define("TrackOnboardingStepCompleted", ), }) +/** The connect step saw the org's first signal arrive. */ +export const TrackFirstData = Command.define("TrackFirstDataReceived", { + args: { signal: Schema.String }, + messages: [Message.CompletedTrackFirstData], + execute: ({ signal }) => + trackProduct("first_data_received", { signal }).pipe(Effect.as(Message.CompletedTrackFirstData())), +}) + export const SaveQuickStartState = Command.define("SaveQuickStartState", { args: { key: Schema.String, state: QuickStartState }, messages: [Message.CompletedSaveQuickStartState], @@ -345,6 +355,23 @@ const presentCount = (connect: Connect): number => (presence) => presence.status === "present", ).length +/** + * The signal a presence read found arriving after an earlier read found none, `null` otherwise: the + * flip the step watched happen, not data that was already there when it opened. + */ +const firstArrivedSignal = (before: Connect, after: Connect): string | null => + Option.isSome(AsyncData.getData(before.presence)) && presentCount(before) === 0 + ? Option.getOrNull( + Option.map( + Arr.findFirst( + Rec.toEntries(AsyncData.getOrElse(after.presence, () => ({}))), + ([, presence]) => presence.status === "present", + ), + ([signal]) => signal, + ), + ) + : null + const saved = (model: Model, next: (state: QuickStartState) => QuickStartState): UpdateReturn => { if (model.state === null) return { model } const state = next(model.state) @@ -407,6 +434,7 @@ const step = (model: Model, message: Message, hasPlan: boolean, context: PageCon // On the last screen, a signal that just arrived re-reads the cards' findings. const isOnLive = model.state !== null && resolveQuickStartStep(model.state, hasPlan) === "live" const isNewSignal = presentCount(next.connect) > presentCount(model.connect) + const firstSignal = firstArrivedSignal(model.connect, next.connect) return { model: { ...model, connect: next.connect }, commands: [ @@ -414,6 +442,7 @@ const step = (model: Model, message: Message, hasPlan: boolean, context: PageCon ...(isOnLive && isNewSignal ? [Command.mapMessage(toLive)(LoadLiveFindings({ timeZone: "UTC" }))] : []), + ...(firstSignal === null ? [] : [TrackFirstData({ signal: firstSignal })]), ], } }, @@ -467,6 +496,7 @@ const step = (model: Model, message: Message, hasPlan: boolean, context: PageCon ClickedBack: ({ to }) => saved(model, (state) => ({ ...state, activeStep: to })), CompletedSaveQuickStartState: () => ({ model }), CompletedTrackStepCompleted: () => ({ model }), + CompletedTrackFirstData: () => ({ model }), PickedRegion: ({ region }) => model.region.isSaving ? { model } diff --git a/apps/web/src/page/settings/members/model.ts b/apps/web/src/page/settings/members/model.ts index cc86be8d50..5f88d48b2d 100644 --- a/apps/web/src/page/settings/members/model.ts +++ b/apps/web/src/page/settings/members/model.ts @@ -114,7 +114,9 @@ export const Message = defineMessageUnion({ GotInviteEmailMessage: { message: Field.Message }, GotInviteRoleMessage: { message: Select.Message }, SubmittedInvite: {}, - CompletedInvite: { email: Schema.String, ...done(Schema.Void) }, + CompletedInvite: { email: Schema.String, role: OrgRole, ...done(Schema.Void) }, + /** `trackProduct("member_invited")` was handed to the session SDK. */ + CompletedTrackMemberInvited: {}, GotRoleSelectMessage: { memberId: Schema.String, message: Select.Message }, ClickedCancelRole: {}, ClickedConfirmRole: {}, diff --git a/apps/web/src/page/settings/members/update.ts b/apps/web/src/page/settings/members/update.ts index e3e750c909..b80ed2bdbe 100644 --- a/apps/web/src/page/settings/members/update.ts +++ b/apps/web/src/page/settings/members/update.ts @@ -9,6 +9,7 @@ import type { ClerkOrganizationResource, } from "../../../platform/clerk" import { ClerkActionError, clerkTry, currentClerkOrganization } from "../../../platform/clerk" +import { trackProduct } from "../../../data/analytics" import * as Dialog from "@maple/views/ui/dialog" import * as Field from "@maple/views/ui/field" import * as Menu from "@maple/views/ui/menu" @@ -166,10 +167,18 @@ const InviteMember = Command.define("InviteClerkMember", { organization.inviteMember({ emailAddress: email, role }), ), ), - settleClerk((result) => Message.CompletedInvite({ email, result })), + settleClerk((result) => Message.CompletedInvite({ email, role, result })), ), }) +/** A sent invitation is the `member_invited` product event. */ +const TrackMemberInvited = Command.define("TrackMemberInvited", { + args: { role: OrgRole }, + messages: [Message.CompletedTrackMemberInvited], + execute: ({ role }) => + trackProduct("member_invited", { role }).pipe(Effect.as(Message.CompletedTrackMemberInvited())), +}) + const ChangeRole = Command.define("ChangeClerkMemberRole", { args: { memberId: Schema.String, name: Schema.String, next: OrgRole }, messages: [Message.CompletedRoleChange], @@ -524,7 +533,7 @@ export const update = (model: Model, message: Message): Return => commands: [InviteMember({ email, role: inviteRoleOf(model) })], } }, - CompletedInvite: ({ email, result }) => + CompletedInvite: ({ email, role, result }) => Result.match(result, { onSuccess: () => { const reset = { @@ -537,7 +546,11 @@ export const update = (model: Model, message: Message): Return => const refreshed = revalidateInvitations(closed.model) return { model: refreshed.model, - commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + commands: [ + ...(closed.commands ?? []), + ...(refreshed.commands ?? []), + TrackMemberInvited({ role }), + ], outMessage: toast("success", `Invitation sent to ${email}`), } }, @@ -546,6 +559,7 @@ export const update = (model: Model, message: Message): Return => outMessage: toast("error", error), }), }), + CompletedTrackMemberInvited: () => ({ model }), GotRoleSelectMessage: ({ memberId, message: child }) => Option.match( Option.flatMap(AsyncData.getData(model.members), (data) => diff --git a/apps/web/src/page/status-pages/builder/builder.scene.test.ts b/apps/web/src/page/status-pages/builder/builder.scene.test.ts index d7c5206171..9c716498e1 100644 --- a/apps/web/src/page/status-pages/builder/builder.scene.test.ts +++ b/apps/web/src/page/status-pages/builder/builder.scene.test.ts @@ -151,12 +151,16 @@ describe("status page builder", () => { { name: "CreateStatusPageMonitor", args: { url: "https://api.acme.com" } }, ]) const monitor = httpMonitor({ id: monitorId(8), name: "api.acme.com" }) - const created = builderUpdate( + const answered = builderUpdate( creating.model, Message.CompletedCreateMonitor({ result: Result.succeed(monitor) }), - ).model + ) + const created = answered.model expect(created.picker.rows.at(-1)).toMatchObject({ monitorId: monitor.id, isChecked: true }) expect(created.picker.url).toBe("") + expect(answered.commands).toContainEqual( + expect.objectContaining({ name: "TrackStatusPageMonitorCreated", args: { kind: "http" } }), + ) }) test("a URL the uptime form would refuse is refused here, without a create", () => { diff --git a/apps/web/src/page/status-pages/builder/command.ts b/apps/web/src/page/status-pages/builder/command.ts index 96a8df7897..0c3446f1e9 100644 --- a/apps/web/src/page/status-pages/builder/command.ts +++ b/apps/web/src/page/status-pages/builder/command.ts @@ -1,7 +1,9 @@ import { readClock } from "@maple/views/data/timing" import { StatusPagePreviewParams, V2StatusPageCreateParams } from "@maple/domain/http/v2" +import { UptimeMonitorKind } from "@maple/primitives" import { Duration, Effect, Schema } from "effect" import { Command, Dom, Mount } from "foldkit" +import { trackProduct } from "../../../data/analytics" import { listMonitors, saveMonitor } from "../../kit/uptime/commands" import { createPage, previewPage } from "../shared/commands" import { monitorPayloadFor } from "../shared/picker" @@ -69,6 +71,20 @@ export const CreateBuilderPage = Command.define("CreateStatusPage", { ), }) +/** A created page is the `status_page_created` product event. */ +export const TrackPageCreated = Command.define("TrackStatusPageCreated", { + messages: [Message.CompletedTrackProduct], + execute: trackProduct("status_page_created").pipe(Effect.as(Message.CompletedTrackProduct())), +}) + +/** A monitor created from the picker is the `uptime_monitor_created` product event. */ +export const TrackMonitorCreated = Command.define("TrackStatusPageMonitorCreated", { + args: { kind: UptimeMonitorKind }, + messages: [Message.CompletedTrackProduct], + execute: ({ kind }) => + trackProduct("uptime_monitor_created", { kind }).pipe(Effect.as(Message.CompletedTrackProduct())), +}) + export const FocusBuilderField = Command.define("FocusStatusPageBuilderField", { args: { id: Schema.String }, messages: [Message.CompletedFocus], diff --git a/apps/web/src/page/status-pages/builder/message.ts b/apps/web/src/page/status-pages/builder/message.ts index afe08f84f7..4d8528d2c4 100644 --- a/apps/web/src/page/status-pages/builder/message.ts +++ b/apps/web/src/page/status-pages/builder/message.ts @@ -34,5 +34,7 @@ export const Message = defineMessageUnion({ }, ClickedSubmit: { mode: SubmitMode }, CompletedCreate: { mode: SubmitMode, result: LoadOutcome(V2StatusPage) }, + /** A `trackProduct` call was handed to the session SDK. */ + CompletedTrackProduct: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/status-pages/builder/update.ts b/apps/web/src/page/status-pages/builder/update.ts index ab2063bab8..b8dadc6521 100644 --- a/apps/web/src/page/status-pages/builder/update.ts +++ b/apps/web/src/page/status-pages/builder/update.ts @@ -32,6 +32,8 @@ import { FetchBuilderSession, FocusBuilderField, ReadBuilderClock, + TrackMonitorCreated, + TrackPageCreated, WaitBuilderPreview, } from "./command" import { Message } from "./message" @@ -215,15 +217,20 @@ export const update = (model: Model, message: Message, context: PageContext): Re GotPickerMessage: ({ message: child }) => pickerStep(model, child), CompletedCreateMonitor: ({ result }) => Result.match(result, { - onSuccess: (monitor): Return => - edited({ + onSuccess: (monitor): Return => { + const next = edited({ ...model, monitors: { ...model.monitors, data: AsyncData.map(model.monitors.data, (monitors) => [...monitors, monitor]), }, picker: monitorCreated(model.picker, monitor), - }), + }) + return { + ...next, + commands: [...(next.commands ?? []), TrackMonitorCreated({ kind: monitor.kind })], + } + }, onFailure: (failure): Return => ({ model: { ...model, picker: monitorFailed(model.picker, failure.message) }, }), @@ -267,6 +274,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re Result.match(result, { onSuccess: (page): Return => ({ model: { ...model, submitting: null }, + commands: [TrackPageCreated()], outMessage: PageOutMessage.RequestedNavigation({ url: statusPageHref(page.id, mode === "publish" ? "live" : undefined), isReplace: true, @@ -289,4 +297,5 @@ export const update = (model: Model, message: Message, context: PageContext): Re } : { model: { ...model, submitting: null, createFailure: failure } }, }), + CompletedTrackProduct: () => ({ model }), }) diff --git a/apps/web/src/page/status-pages/editor/command.ts b/apps/web/src/page/status-pages/editor/command.ts index 75fca89206..376e6cb8f5 100644 --- a/apps/web/src/page/status-pages/editor/command.ts +++ b/apps/web/src/page/status-pages/editor/command.ts @@ -1,5 +1,6 @@ import { readClock } from "@maple/views/data/timing" import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" +import { UptimeMonitorKind } from "@maple/primitives" import { StatusPageConfig, StatusPageDraftComponent, @@ -12,6 +13,7 @@ import { } from "@maple/domain/http/v2" import { Duration, Effect, Schema } from "effect" import { Command, Dom, Mount } from "foldkit" +import { trackProduct } from "../../../data/analytics" import { applyPageConfig, createIncident, @@ -273,6 +275,16 @@ export const CreateEditorMonitor = Command.define("CreateStatusPageEditorMonitor ), }) +/** A monitor created from the picker is the `uptime_monitor_created` product event. */ +export const TrackMonitorCreated = Command.define("TrackStatusPageEditorMonitorCreated", { + args: { kind: UptimeMonitorKind }, + messages: [Message.CompletedTrackMonitorCreated], + execute: ({ kind }) => + trackProduct("uptime_monitor_created", { kind }).pipe( + Effect.as(Message.CompletedTrackMonitorCreated()), + ), +}) + /** Adds the picked components in order, one create each; the first failure stops the rest. */ export const AddComponents = Command.define("AddStatusPageComponents", { args: { diff --git a/apps/web/src/page/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/status-pages/editor/editor.scene.test.ts index c8248abe69..1d41c33c6a 100644 --- a/apps/web/src/page/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/status-pages/editor/editor.scene.test.ts @@ -462,6 +462,7 @@ describe("the page as the editor", () => { name: "AddStatusPageComponents", args: { components: [{ name: monitor.name, monitor_id: monitor.id }] }, }, + { name: "TrackStatusPageEditorMonitorCreated", args: { kind: monitor.kind } }, ]) const closed = pageUpdate(adding, Message.ClickedClosePanel()).model expect(names(pageUpdate(closed, created).commands)).not.toContain("AddStatusPageComponents") diff --git a/apps/web/src/page/status-pages/editor/message.ts b/apps/web/src/page/status-pages/editor/message.ts index a8c85b1c91..2491ae71aa 100644 --- a/apps/web/src/page/status-pages/editor/message.ts +++ b/apps/web/src/page/status-pages/editor/message.ts @@ -76,6 +76,8 @@ export const Message = defineMessageUnion({ ClickedAddComponent: {}, GotAddPickerMessage: { message: PickerMessage }, CompletedCreateMonitor: { session: Schema.Number, result: LoadOutcome(V2UptimeMonitor) }, + /** `trackProduct("uptime_monitor_created")` was handed to the session SDK. */ + CompletedTrackMonitorCreated: {}, ClickedSubmitAdd: {}, CompletedAddComponents: { session: Schema.Number, diff --git a/apps/web/src/page/status-pages/editor/update-components.ts b/apps/web/src/page/status-pages/editor/update-components.ts index 630e3eafa3..3205503a4a 100644 --- a/apps/web/src/page/status-pages/editor/update-components.ts +++ b/apps/web/src/page/status-pages/editor/update-components.ts @@ -28,6 +28,7 @@ import { FocusEditorElement, ReorderComponents, SaveComponent, + TrackMonitorCreated, } from "./command" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -282,10 +283,11 @@ export const addMonitorCreated = ( data: AsyncData.map(model.monitors.data, (monitors) => [...monitors, monitor]), }, } + const track = TrackMonitorCreated({ kind: monitor.kind }) // Closed or reopened while the monitor was created: keep the monitor, touch nothing else. - return isAddSession(model, session) - ? submitAdd({ ...withMonitor, addPicker: monitorCreated(model.addPicker, monitor) }) - : { model: withMonitor } + if (!isAddSession(model, session)) return { model: withMonitor, commands: [track] } + const next = submitAdd({ ...withMonitor, addPicker: monitorCreated(model.addPicker, monitor) }) + return { ...next, commands: [...(next.commands ?? []), track] } }, onFailure: (failure): Return => isAddSession(model, session) diff --git a/apps/web/src/page/status-pages/editor/update.ts b/apps/web/src/page/status-pages/editor/update.ts index 4edaa8dc1f..9b0eb4c068 100644 --- a/apps/web/src/page/status-pages/editor/update.ts +++ b/apps/web/src/page/status-pages/editor/update.ts @@ -403,6 +403,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedAddComponent: () => openAdd(model), GotAddPickerMessage: ({ message: child }) => addPickerStep(model, child), CompletedCreateMonitor: ({ session, result }) => addMonitorCreated(model, session, result), + CompletedTrackMonitorCreated: () => ({ model }), ClickedSubmitAdd: () => submitAdd(model), CompletedAddComponents: ({ session, result }) => addedComponents(model, session, result, context), // Incidents diff --git a/apps/web/src/platform/clerk.ts b/apps/web/src/platform/clerk.ts index abf323447b..9e7baf6154 100644 --- a/apps/web/src/platform/clerk.ts +++ b/apps/web/src/platform/clerk.ts @@ -245,6 +245,8 @@ export const ClerkSnapshot = Schema.Struct({ maybeOrganization: Schema.Option(ClerkOrganization), /** `useOrganizationList({ userMemberships })`, the first page. */ memberships: Schema.Array(ClerkOrganization), + /** The user's membership role in the active org (`org:admin`), for the session's traits. */ + maybeRole: Schema.Option(Schema.String), /** The API treats a pending session as signed out, so the app shows the task instead. */ maybePendingTask: Schema.Option(PendingSessionTask), }) @@ -280,6 +282,13 @@ const toSnapshot = (resources: ClerkResources): Effect.Effect => memberships: Arr.map(resources.user?.organizationMemberships ?? [], (membership) => toOrganization(membership.organization, nowMs), ), + maybeRole: Option.flatMap( + Arr.findFirst( + resources.user?.organizationMemberships ?? [], + (membership) => membership.organization.id === resources.organization?.id, + ), + (membership) => Option.fromNullishOr(membership.role), + ), maybePendingTask: Option.filter( Option.fromNullishOr( resources.session?.status === "pending" ? resources.session.currentTask?.key : null, diff --git a/apps/web/src/platform/session-identity.ts b/apps/web/src/platform/session-identity.ts index de3973e9af..8119b642ae 100644 --- a/apps/web/src/platform/session-identity.ts +++ b/apps/web/src/platform/session-identity.ts @@ -1,8 +1,8 @@ import { Option, Schema } from "effect" import type { ClerkSnapshot } from "./clerk" -// The session identity: the -// session and its product events carry the signed-in user and their org. +// The session identity: the session and its product events carry the signed-in user, their org, +// and their role and plan there. export const SessionIdentity = Schema.Struct({ id: Schema.String, @@ -10,14 +10,22 @@ export const SessionIdentity = Schema.Struct({ email: Schema.optionalKey(Schema.String), groupId: Schema.optionalKey(Schema.String), groupName: Schema.optionalKey(Schema.String), + /** The membership role in the active org and its Autumn plan id, each once known. */ + traits: Schema.Struct({ + role: Schema.optionalKey(Schema.String), + plan: Schema.optionalKey(Schema.String), + }), }) export type SessionIdentity = typeof SessionIdentity.Type const defined = (key: K, value: string | null | undefined) => value === null || value === undefined ? {} : { [key]: value } -/** The identity a snapshot names, `null` when signed out (`identify(undefined)`). */ -export const identityOf = (snapshot: ClerkSnapshot): SessionIdentity | null => +/** + * The identity a snapshot names, `null` when signed out (`identify(undefined)`). `plan` is the active + * org's plan id, `null` until its customer read settles. + */ +export const identityOf = (snapshot: ClerkSnapshot, plan: string | null): SessionIdentity | null => Option.match(snapshot.maybeUserId, { onNone: () => null, onSome: (id) => { @@ -29,6 +37,10 @@ export const identityOf = (snapshot: ClerkSnapshot): SessionIdentity | null => ...defined("email", user?.email), ...defined("groupId", Option.getOrNull(snapshot.maybeOrgId)), ...defined("groupName", organization?.name), + traits: { + ...defined("role", Option.getOrNull(snapshot.maybeRole)), + ...defined("plan", plan), + }, } }, }) @@ -41,4 +53,6 @@ export const isSameIdentity = (a: SessionIdentity | null, b: SessionIdentity | n a.username === b.username && a.email === b.email && a.groupId === b.groupId && - a.groupName === b.groupName + a.groupName === b.groupName && + a.traits.role === b.traits.role && + a.traits.plan === b.traits.plan diff --git a/apps/web/src/update.customer-grace.test.ts b/apps/web/src/update.customer-grace.test.ts index e10b8343a0..ecdb07c8f7 100644 --- a/apps/web/src/update.customer-grace.test.ts +++ b/apps/web/src/update.customer-grace.test.ts @@ -64,6 +64,7 @@ const snapshot: ClerkSnapshot = { }, }), memberships: [], + maybeRole: Option.none(), maybePendingTask: Option.none(), } diff --git a/apps/web/src/update.pending-task.test.ts b/apps/web/src/update.pending-task.test.ts index 091638bdc3..dec5b0bd11 100644 --- a/apps/web/src/update.pending-task.test.ts +++ b/apps/web/src/update.pending-task.test.ts @@ -49,6 +49,7 @@ const snapshotOf = (task: PendingSessionTask | null): ClerkSnapshot => ({ maybeUser: Option.some({ fullName: "Ada Lovelace", email: "ada@acme.test", imageUrl: "" }), maybeOrganization: Option.none(), memberships: [], + maybeRole: Option.none(), maybePendingTask: Option.fromNullishOr(task), }) diff --git a/apps/web/src/update.test.ts b/apps/web/src/update.test.ts index 5da137b576..d6c0cc9dce 100644 --- a/apps/web/src/update.test.ts +++ b/apps/web/src/update.test.ts @@ -1,4 +1,5 @@ import { OrgId, UserId } from "@maple/domain" +import { BillingCustomer } from "@maple/domain/http" import { Option, Record as Rec, Result, Schema, Array as Arr, String as Str } from "effect" import { UrlRequest } from "foldkit/navigation" import { fromString as urlFromString } from "foldkit/url" @@ -8,7 +9,7 @@ import { describe, expect, test, vi } from "vitest" vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) import { Message } from "./message" import { ClerkPhase, type Model, Session } from "./model" -import { initCustomer } from "./page/onboarding/plan-gate" +import { CustomerData, initCustomer } from "./page/onboarding/plan-gate" import * as Preload from "./page/preload" import { enterRoute, pageIdentity } from "./page/registry" import { Message as ServicesMessage } from "./page/services/list/message" @@ -161,6 +162,7 @@ describe("telemetry identity", () => { maybeUser: Option.some({ fullName: "Ada Lovelace", email: "ada@acme.test", imageUrl: "" }), maybeOrganization: Option.none(), memberships: [], + maybeRole: Option.some("org:admin"), maybePendingTask: Option.none(), } const loaded = update( @@ -175,12 +177,52 @@ describe("telemetry identity", () => { username: "Ada Lovelace", email: "ada@acme.test", groupId: "org_acme", + traits: { role: "org:admin" }, }, }, }) const again = update(loaded.model, Message.ChangedClerkSession({ snapshot })) expect(Arr.map(commandsOf(again), ({ name }) => name)).not.toContain("IdentifySession") }) + + test("the org's plan joins the identity once its customer read settles, and only then", () => { + const snapshot: ClerkSnapshot = { + maybeUserId: Option.some(UserId.make("user_ada")), + maybeOrgId: Option.some(orgId), + maybeUser: Option.none(), + maybeOrganization: Option.none(), + memberships: [], + maybeRole: Option.none(), + maybePendingTask: Option.none(), + } + const customer = Schema.decodeSync(BillingCustomer)({ + id: "org_acme", + subscriptions: [ + { + planId: "startup", + status: "active", + currentPeriodStart: 0, + currentPeriodEnd: 30 * 86_400_000, + }, + ], + }) + // The org's customer read is out (`loadCustomer`), as it is for a served org. + const reading: Model = { + ...base(AppRoute.Services({ search: {} })), + customer: { orgId: "org_acme", data: CustomerData.Loading() }, + } + const loaded = update(reading, Message.CompletedLoadClerk({ snapshot })) + const settled = update(loaded.model, Message.SucceededLoadCustomer({ orgId: "org_acme", customer })) + expect(commandsOf(settled)).toContainEqual({ + name: "IdentifySession", + args: { identity: { id: "user_ada", groupId: "org_acme", traits: { plan: "startup" } } }, + }) + const resettled = update( + settled.model, + Message.SucceededLoadCustomer({ orgId: "org_acme", customer }), + ) + expect(Arr.map(commandsOf(resettled), ({ name }) => name)).not.toContain("IdentifySession") + }) }) describe("re-render on change only", () => { @@ -204,6 +246,7 @@ describe("re-render on change only", () => { }, }), memberships: [], + maybeRole: Option.none(), maybePendingTask: Option.none(), }) const loadedHome = () => diff --git a/apps/web/src/update.ts b/apps/web/src/update.ts index f6a04849c5..8edb3da4ad 100644 --- a/apps/web/src/update.ts +++ b/apps/web/src/update.ts @@ -60,7 +60,8 @@ import { import * as SignIn from "./page/sign-in" import type { Resources } from "./platform/resources" import { ClerkSnapshot } from "./platform/clerk" -import { identityOf, isSameIdentity } from "./platform/session-identity" +import { identityOf, isSameIdentity, type SessionIdentity } from "./platform/session-identity" +import { getActivePlan } from "./page/kit/billing/plan-gating" import { AppRoute, flaggedRouteRedirect, @@ -827,15 +828,20 @@ const settledCustomer = (model: Model, orgId: string, data: CustomerSlot["data"] const next = modifyFields(model, { customer: () => ({ orgId, data }) }) const access = accessOf(next.customer) const settled = withSettledPlan(settle(next)) - return access === "app" || access === "onboarding" - ? { - model: settled.model, - commands: [ - ...(settled.commands ?? []), - SaveSelectedPlanKnown({ orgId, mayRenderApp: access === "app" }), - ], - } - : settled + const known = sessionIdentityOf(model) + const identity = sessionIdentityOf(next) + return { + model: settled.model, + commands: [ + ...(known === undefined || identity === undefined || isSameIdentity(known, identity) + ? [] + : [IdentifySession({ identity })]), + ...(settled.commands ?? []), + ...(access === "app" || access === "onboarding" + ? [SaveSelectedPlanKnown({ orgId, mayRenderApp: access === "app" })] + : []), + ], + } } /** The signed-in org, on a shell route. */ @@ -928,13 +934,25 @@ const changedClerkSession = (model: Model, snapshot: ClerkSnapshot): UpdateRetur ? { model } : appliedClerkSnapshot(snapshot, false)(model) +/** The active org's Autumn plan id, once its own customer read (not another org's) found one. */ +const activePlanIdOf = (model: Model, snapshot: ClerkSnapshot): string | null => + Option.getOrNull(snapshot.maybeOrgId) === model.customer.orgId + ? (getActivePlan(customerOf(model.customer))?.planId ?? null) + : null + +/** The identity the model names; `undefined` before Clerk's first answer. */ +const sessionIdentityOf = (model: Model): SessionIdentity | null | undefined => + model.clerkSnapshot === null + ? undefined + : identityOf(model.clerkSnapshot, activePlanIdOf(model, model.clerkSnapshot)) + /** `useClerkAuthSettled`: a signed-out first answer waits for a refreshing session, except on public pages. */ const appliedClerkSnapshot = (snapshot: ClerkSnapshot, isFirstAnswer: boolean): Step => (previous) => { const model = modifyFields(previous, { clerkSnapshot: () => snapshot }) - const identity = identityOf(snapshot) - const known = previous.clerkSnapshot === null ? undefined : identityOf(previous.clerkSnapshot) + const identity = identityOf(snapshot, activePlanIdOf(model, snapshot)) + const known = sessionIdentityOf(previous) const identify = known !== undefined && isSameIdentity(known, identity) ? [] : [IdentifySession({ identity })] if (Option.isSome(snapshot.maybeUserId)) { diff --git a/docs/product-events-catalog.md b/docs/product-events-catalog.md new file mode 100644 index 0000000000..a56048d259 --- /dev/null +++ b/docs/product-events-catalog.md @@ -0,0 +1,124 @@ +# Maple's own product events: catalog + +What Maple records about its own users in the dogfood org's `product_events` table, so per-customer +usage (active users, feature adoption, churn signals) can be read straight from it. How the table and +its feeds work is in `product-events-funnels.md`; this page is only the vocabulary. + +## Identity + +The customer is a Clerk organization, a contact is a Clerk user. + +- `GroupId` = Clerk org id (`org_…`), `UserId` = Clerk user id (`user_…`). +- **Browser** (`app.maple.dev`): `identify()` runs on every Clerk snapshot whose identity changed + (`apps/web/src/update.ts`, `appliedClerkSnapshot`), so a sign-in and an org switch both re-identify. + Every later session row, page view and `track()` event carries the user and the active org. + Traits on the session: `email`, `username` (full name), `groupName` (org name), `role` (Clerk role + key, e.g. `org:admin`) and `plan` (Autumn plan id), the last two once known. +- **Server** (`POST /v1/events`, `ProductEventsService`): every emit passes `userId` / `groupId` + from the request or webhook payload. Billing webhooks know only the org (`GroupId` set, `UserId` + empty). +- **Trace-derived** (`Source = 'trace'`): a Maple span carrying `maple.product_event.*` + (`productEventSpanAttributes` in `packages/domain/src/tinybird/product-event-attributes.ts`) + projects into the table with the tenant's user and org. Its `Attributes` are only the props + listed below, never the span's other attributes. +- **iOS** (`apps/ios`): events reach the table without `UserId` / `GroupId`; the Swift SDK + (`maple-swift` 0.3.3) does not stamp identity on session event rows. Exclude or treat as + anonymous until it does. +- **Landing** (`maple.dev`): anonymous marketing events (`VisitorId` only). Not customer usage. + +Attributes never carry PII beyond these ids, nor names, free text, query contents or secrets. + +## Active users and feature usage + +Use the identified `$pageview` rows on the app host (`Kind = 'navigation'`, `Host = 'app.maple.dev'`): +about 95% carry `UserId` and `GroupId`. `PagePath` is the feature area: `/traces`, `/traces/:id`, +`/logs`, `/metrics`, `/dashboards/:id`, `/alerts`, `/errors`, `/infra`, `/agent-sessions`, +`/status-pages`, `/settings`, … There are deliberately no `*_viewed` events duplicating them. + +## Events + +`source`: where the event is emitted. `web` = `trackProduct()` in `apps/web` (`ProductEvent` union in +`apps/web/src/data/product-events.ts`), `server` = `ProductEventsService.track` in `apps/api` +(`ProductEventName`), `trace` = a span in `apps/api` / `apps/ai`. + +### Account and team + +| Event | Source | Fires when | Attributes | Identity | +| ------------------ | ------ | ---------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------- | +| `signup_completed` | server | Clerk `user.created` webhook | `sign_up_source` (`email`, `google`, `github`…), `email_domain` | user | +| `member_joined` | server | Clerk `organizationMembership.created` webhook | `role` | user + org | +| `member_removed` | server | Clerk `organizationMembership.deleted`, after the revocation sweep succeeded | `role` (when sent) | user + org | +| `member_invited` | web | An invite is sent from Settings › Members or the onboarding team step | `role` | user + org | + +### Onboarding + +| Event | Source | Fires when | Attributes | Identity | +| --------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ---------- | +| `onboarding_step_completed` | web | Continue on a quick-start step | `step` | user + org | +| `onboarding_reward_claimed` | web | Org admin redeems the onboarding-checklist credit | (none) | user + org | +| `first_data_received` | web | The quick-start connect step's presence poll sees the org's first signal arrive (not data already there when it opened) | `signal` (`traces`, `logs`, …) | user + org | + +### Product usage + +| Event | Source | Fires when | Attributes | Identity | +| ----------------------------- | ------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ---------- | +| `dashboard_created` | web | A dashboard is created (blank, template, import, duplicate, "add to dashboard") | `widgets` (count) | user + org | +| `alert_rule_created` | web | An alert rule save lands | `signal` | user + org | +| `integration_connected` | web | An integration's connect callback succeeds | `provider` | user + org | +| `api_key_created` | web | An API key is created | `kind` (`standard`/`mcp`), `access` | user + org | +| `investigation_started` | trace | An AI investigation is created and claimed (web, API or MCP), `apps/api` | `subject_type` | user + org | +| `mcp_tool_called` | trace | Every tool call on the public MCP server, `apps/ai` | `tool`, `source` (`mcp`), `client` (MCP client name, e.g. `claude-code`) | user + org | +| `integration_disconnected` | web | An integration (incl. AWS, GCP, chat workspaces) is disconnected | `provider` | user + org | +| `alert_incident_acknowledged` | web | An incident is acknowledged from the alerts inbox or incident page | (none) | user + org | +| `uptime_monitor_created` | web | An uptime monitor is created (form, or from a URL in the status-page builder/editor) | `kind` | user + org | +| `slo_created` | web | An SLO is created | (none) | user + org | +| `status_page_created` | web | A status page is created (draft or published) | (none) | user + org | + +`mcp_tool_called` is the AI-usage signal and the only per-call event (about 12k a day across all orgs +in November 2026). MCP tools that create things (`create_dashboard`, `create_alert_rule`, …) show up here under +`tool`, not as `*_created`; the `*_created` events above are the web UI's. + +### Billing + +| Event | Source | Fires when | Attributes | Identity | +| ----------------------------- | ------ | ------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------ | +| `plan_checkout_started` | web | Right before the Stripe redirect | `plan_id` | user + org | +| `plan_checkout_returned` | web | Buyer back from Stripe | `confirmed` | user + org | +| `plan_started` | server | Autumn `billing.updated` (activated), or inline `attach` with no redirect | `plan_id`, `trigger` (`webhook`/`attach`), `kind`, `trial` | org (+ user on `attach`) | +| `plan_changed` | server | Autumn `billing.updated` (updated) | `plan_id`, `trigger`, `kind` | org | +| `plan_cancelled` | server | Autumn `billing.updated` (expired) | `plan_id`, `trigger`, `kind` | org | +| `support_channel_invite_sent` | web | A member asks for a Slack Connect invite to the support channel | (none) | user + org | + +Webhook plan events set `GroupId` to the Autumn customer id, which is the paying org's Clerk id. An +org billed through another org (`billing-orgs.md`) has its plan events under that billing org. + +## Not covered + +- **The Clerk webhook is not delivering in production** (no `ClerkWebhook.receive` span from + 2026-11-03 to 2026-11-10, and no `signup_completed` row ever). Until `POST /webhooks/clerk` is + registered in Clerk for `user.created` and `organizationMembership.created/updated/deleted`, + `signup_completed`, `member_joined` and `member_removed` stay empty (and the membership revocation + sweep does not run). +- **Daily ingest volume per org.** Not a product event: it lives in billing usage + (`DailySpendService`), and there is no per-org daily hook to emit it from without a new job. +- **iOS identity**, see above. +- Explorer queries are not events (they run on every filter change); use page views. + +## Historical duplicates + +Before `ProductEvents.track` stopped carrying `maple.product_event.name` on its own span, every +server event (`signup_completed`, `plan_*`) was also projected as an unidentified `Source = 'trace'` +row. To read history, drop those rows: + +```sql +NOT (Source = 'trace' AND ServiceName = 'maple-api' AND GroupId = '' + AND EventName IN ('signup_completed', 'plan_started', 'plan_changed', 'plan_cancelled')) +``` + +## Adding an event + +1. Name it `object_action`, snake_case, and add it to the union it is emitted from + (`ProductEvent` in `apps/web/src/data/product-events.ts`, `ProductEventName` in `ProductEventsService.ts`, + or `productEventSpanAttributes` on a span that already exists). +2. Fire it once, when the action succeeded. Low-cardinality attributes only. +3. Add it to this page. diff --git a/docs/product-events-funnels.md b/docs/product-events-funnels.md index c321206c0b..871df24282 100644 --- a/docs/product-events-funnels.md +++ b/docs/product-events-funnels.md @@ -1,5 +1,7 @@ # Product events + funnels: plan +The names Maple itself emits, and how they are identified, are in `product-events-catalog.md`. + Status: **implemented in code (2026-08-17), pending rollout.** Goal: answer "referral → signed up → started a plan" for any org (and for Maple itself), from browser, mobile and backend events, as a real funnel in the product rather than a hand-written `run_sql`. @@ -204,7 +206,7 @@ is one row per (visitor,user) pair. `web_events` references; the page-view queries move here unchanged): - `productEventsFunnelQuery({ steps, keyBy: "person" | "visitor" | "user" | "session", -windowSeconds, filters })` → per-step `count`, `conversion_from_prev`, `conversion_from_first`. + windowSeconds, filters })` → per-step `count`, `conversion_from_prev`, `conversion_from_first`. Step = `{ eventName } | { pagePath, host? } | { referrerHost | utmSource | ... }`. A session-dimension step (referral) becomes step 0's condition through the person's first `session_replays` row; event steps are `EventName = …` conditions on `product_events`. diff --git a/packages/backend/src/services/errors/InvestigationService.test.ts b/packages/backend/src/services/errors/InvestigationService.test.ts index f1690ec4ca..d66d87593c 100644 --- a/packages/backend/src/services/errors/InvestigationService.test.ts +++ b/packages/backend/src/services/errors/InvestigationService.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" import { afterEach, assert, describe, it } from "@effect/vitest" -import { Cause, ConfigProvider, Effect, Exit, Layer, Schema, Array as Arr, Clock } from "effect" +import { Cause, ConfigProvider, Effect, Exit, Layer, Schema, Array as Arr, Clock, Tracer } from "effect" import { TestClock } from "effect/testing" import { AiTriageEvidence, @@ -406,6 +406,36 @@ describe("InvestigationService", () => { }).pipe(Effect.provide(makeLayer())), ) + it.effect("a start is an identified investigation_started product event; a joined run is not", () => { + const chat = chatSessionHarness() + const harness = makeHarness(chat.env, chat.chatSessions) + const spans: Array = [] + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options) + spans.push(span) + return span + }, + }) + const events = () => + Arr.filter( + spans, + (span) => span.attributes.get("maple.product_event.name") === "investigation_started", + ) + return Effect.gen(function* () { + const service = yield* InvestigationService + yield* service.createAndStartInvestigation(ORG, null, freeformRequest("why is checkout slow")) + assert.lengthOf(events(), 1) + const [event] = events() + assert.strictEqual(event.attributes.get("maple.product_event.group_id"), ORG) + assert.strictEqual(event.attributes.get("maple.product_event.prop.subject_type"), "freeform") + yield* service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_once")) + // The same incident again joins the run already going: no second event. + yield* service.createAndStartInvestigation(ORG, null, criticalIncidentRequest("err_once")) + assert.lengthOf(events(), 2) + }).pipe(Effect.withTracer(tracer), Effect.provide(harness.layer)) + }) + it.effect("refuses to restart a run that is still in flight", () => { const chat = chatSessionHarness() const harness = makeHarness(chat.env, chat.chatSessions) diff --git a/packages/backend/src/services/errors/InvestigationService.ts b/packages/backend/src/services/errors/InvestigationService.ts index 3ffe52dc53..7715d4de6f 100644 --- a/packages/backend/src/services/errors/InvestigationService.ts +++ b/packages/backend/src/services/errors/InvestigationService.ts @@ -26,6 +26,7 @@ import { type UserId, } from "@maple/domain/http" import { AnomalyIncidentId, ErrorIssueId, InvestigationId } from "@maple/primitives" +import { productEventSpanAttributes } from "@maple/domain/tinybird/product-event-attributes" import * as PG from "@maple-dev/effect-orm/postgres" import { AnomalyIncidents, Investigations, type InvestigationRow } from "@maple/db/tables" @@ -599,6 +600,15 @@ export class InvestigationService extends Context.Service diff --git a/packages/backend/src/services/product-events/ProductEventsService.test.ts b/packages/backend/src/services/product-events/ProductEventsService.test.ts index db62b3ae74..3778d0fb9c 100644 --- a/packages/backend/src/services/product-events/ProductEventsService.test.ts +++ b/packages/backend/src/services/product-events/ProductEventsService.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest" -import { Effect, Record as Rec, String as Str } from "effect" +import { Array as Arr, Effect, Record as Rec, String as Str, Tracer } from "effect" import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" +import { PRODUCT_EVENT_NAME_KEY } from "@maple/domain/tinybird/product-event-attributes" import { makeProductEvents, toProductEventLine } from "./ProductEventsService" interface Captured { @@ -95,6 +96,32 @@ describe("ProductEventsService", () => { }), ) + // A span carrying `maple.product_event.name` is itself projected into + // `product_events` as a `Source='trace'` row, so the span that posts an event + // must not carry it, or every server event lands twice. + it.effect("does not mark its own span as a trace-derived product event", () => + Effect.gen(function* () { + const spans: Array = [] + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options) + spans.push(span) + return span + }, + }) + const { client } = stubClient([200]) + const events = makeProductEvents({ + httpClient: client, + endpoint: "https://ingest.example.test", + ingestKey: "maple_sk_test", + }) + yield* events.track({ name: "plan_started", groupId: "org_1" }).pipe(Effect.withTracer(tracer)) + const trackSpan = Arr.findFirst(spans, (span) => span.name === "ProductEvents.track") + assert.isTrue(trackSpan._tag === "Some") + assert.isFalse(Arr.some(spans, (span) => span.attributes.has(PRODUCT_EVENT_NAME_KEY))) + }), + ) + it.effect("retries once on 5xx and never fails the caller", () => Effect.gen(function* () { const { client, captured } = stubClient([503, 503, 503]) diff --git a/packages/backend/src/services/product-events/ProductEventsService.ts b/packages/backend/src/services/product-events/ProductEventsService.ts index 78051ab6e8..912d6e3746 100644 --- a/packages/backend/src/services/product-events/ProductEventsService.ts +++ b/packages/backend/src/services/product-events/ProductEventsService.ts @@ -38,6 +38,8 @@ export type ProductEventName = | "plan_started" | "plan_changed" | "plan_cancelled" + | "member_joined" + | "member_removed" export interface ProductEventInput { readonly name: ProductEventName @@ -156,9 +158,13 @@ export const makeProductEvents = (options: { ) const track = Effect.fn("ProductEvents.track")(function* (event: ProductEventInput) { + // `maple.product_events.*`, not `maple.product_event.*`: a span carrying + // `maple.product_event.name` is itself projected into `product_events` + // (`Source='trace'`, see `product-event-attributes.ts`), which duplicated + // every server event as an unidentified trace row. yield* Effect.annotateCurrentSpan({ - "maple.product_event.name": event.name, - "maple.product_event.enabled": ingestKey !== undefined, + "maple.product_events.event": event.name, + "maple.product_events.enabled": ingestKey !== undefined, }) if (ingestKey === undefined) { yield* Effect.logDebug("Product event dropped: no ingest key configured").pipe( @@ -183,7 +189,7 @@ export const makeProductEvents = (options: { Effect.catchCause((cause) => Effect.logDebug("Product event dropped").pipe( Effect.annotateLogs({ event: event.name, cause: String(cause) }), - Effect.tap(() => Effect.annotateCurrentSpan({ "maple.product_event.dropped": true })), + Effect.tap(() => Effect.annotateCurrentSpan({ "maple.product_events.dropped": true })), ), ), ) diff --git a/packages/backend/src/services/product-events/clerk-events.ts b/packages/backend/src/services/product-events/clerk-events.ts index d352215684..3c062b5043 100644 --- a/packages/backend/src/services/product-events/clerk-events.ts +++ b/packages/backend/src/services/product-events/clerk-events.ts @@ -127,3 +127,24 @@ export const signupCompletedEvent = ( }, } } + +/** + * A membership that started or ended → `member_joined` / `member_removed`, on + * the member and the org. A role change emits nothing: the identify trait + * carries the current role. `role` is Clerk's role key (`org:admin`), never a + * name or address. + */ +export const membershipProductEvent = ( + event: ClerkMembershipEventType, + data: ClerkOrganizationMembershipData, + envelopeTimestamp: number | undefined, +): ProductEventInput | undefined => { + if (event === "organizationMembership.updated") return undefined + return { + name: event === "organizationMembership.created" ? "member_joined" : "member_removed", + userId: data.public_user_data.user_id, + groupId: data.organization.id, + timestamp: envelopeTimestamp, + ...(data.role !== undefined ? { attributes: { role: data.role } } : undefined), + } +} diff --git a/packages/domain/package.json b/packages/domain/package.json index 03365f2850..c71fa2c108 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -79,6 +79,7 @@ "./tinybird/db-query-shape-sql": "./src/tinybird/db-query-shape-sql.ts", "./tinybird/fingerprint": "./src/tinybird/fingerprint.ts", "./tinybird/http-endpoint-sql": "./src/tinybird/http-endpoint-sql.ts", + "./tinybird/product-event-attributes": "./src/tinybird/product-event-attributes.ts", "./tinybird/gen-ai-columns": "./src/tinybird/gen-ai-columns.ts", "./tinybird/semconv-renames": "./src/tinybird/semconv-renames.ts", "./tinybird/span-display-name": "./src/tinybird/span-display-name.ts", diff --git a/packages/domain/src/tinybird/product-event-attributes.test.ts b/packages/domain/src/tinybird/product-event-attributes.test.ts index de9e4e5b13..37b84854be 100644 --- a/packages/domain/src/tinybird/product-event-attributes.test.ts +++ b/packages/domain/src/tinybird/product-event-attributes.test.ts @@ -4,11 +4,30 @@ import { PRODUCT_EVENT_ATTRIBUTE_NAMESPACE, PRODUCT_EVENT_INCLUDE_KEY, PRODUCT_EVENT_PROP_PREFIX, + productEventSpanAttributes, } from "./product-event-attributes" import { productEvents } from "./datasources" import { Array as Arr, Option, Record as Rec, String as Str } from "effect" describe("product event span attributes", () => { + it("marks one of Maple's own spans as an identified event carrying only its props", () => { + const attributes = productEventSpanAttributes({ + name: "mcp_tool_called", + userId: "user_1", + groupId: "org_1", + props: { tool: "search_traces", source: "mcp" }, + }) + expect(attributes).toEqual({ + "maple.product_event.name": "mcp_tool_called", + "maple.product_event.user_id": "user_1", + "maple.product_event.group_id": "org_1", + // Present and empty: the span's own attributes stay out of `Attributes`. + "maple.product_event.include": "", + "maple.product_event.prop.tool": "search_traces", + "maple.product_event.prop.source": "mcp", + }) + }) + it("casts the base map to the target key type", () => { // Not cosmetic: `SpanAttributes` is keyed `LowCardinality(String)` and // `product_events.Attributes` is keyed plain `String`. Without the CAST the diff --git a/packages/domain/src/tinybird/product-event-attributes.ts b/packages/domain/src/tinybird/product-event-attributes.ts index 55ce35408a..fcd42cf803 100644 --- a/packages/domain/src/tinybird/product-event-attributes.ts +++ b/packages/domain/src/tinybird/product-event-attributes.ts @@ -1,3 +1,5 @@ +import { Record as Rec } from "effect" + /** * The span-attribute contract that turns an instrumented span into a product * event. A customer marks a span they already emit: @@ -122,6 +124,25 @@ export const PRODUCT_EVENTS_TRACE_PROJECTION_SQL = `OrgId, TraceId, SpanId` +/** + * Maple's own spans that ARE a product event (`mcp_tool_called`, + * `investigation_started`): the span attributes that project it with exactly + * `props` as `Attributes` (an empty `include`, so none of the span's other keys + * leak into a 365-day table) and the member and org as `UserId` / `GroupId`. + */ +export const productEventSpanAttributes = (event: { + readonly name: string + readonly userId: string + readonly groupId: string + readonly props: Readonly> +}): Record => ({ + [PRODUCT_EVENT_NAME_KEY]: event.name, + [PRODUCT_EVENT_USER_ID_KEY]: event.userId, + [PRODUCT_EVENT_GROUP_ID_KEY]: event.groupId, + [PRODUCT_EVENT_INCLUDE_KEY]: "", + ...Rec.mapKeys(event.props, (key) => `${PRODUCT_EVENT_PROP_PREFIX}${key}`), +}) + /** * The predicate, also shared. `!= ''` rather than `mapContains`: a key present * with an empty value would otherwise mint an event with no name, which is a row