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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/ai/src/mcp/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions apps/ai/src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -224,6 +225,22 @@ export class McpToolExecutor extends Context.Service<McpToolExecutor, McpToolExe
"maple.mcp.tool": name,
"maple.mcp.surface": surface,
})
// A public MCP call is also Maple's own `mcp_tool_called` product event,
// projected from this span. Agent turns (`chat`, `bot`) are not: the
// investigation that ran them is the event.
if (surface === "mcp") {
const base = { tool: name, source: "mcp" }
const props =
tenant.mcpClientName === undefined ? base : { ...base, client: tenant.mcpClientName }
yield* Effect.annotateCurrentSpan(
productEventSpanAttributes({
name: "mcp_tool_called",
userId: tenant.userId,
groupId: tenant.orgId,
props,
}),
)
}
const result = yield* callMcpToolUnscoped(name, input, surface).pipe(
Effect.provideService(CurrentMcpTenant, tenant),
Effect.provide(runtimeServices),
Expand Down
15 changes: 12 additions & 3 deletions apps/api/src/routes/webhooks/clerk.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
decodeClerkUserCreated,
decodeClerkUserDeleted,
isClerkMembershipEvent,
membershipProductEvent,
signupCompletedEvent,
type ClerkMembershipEventType,
} from "@maple/backend/services/product-events/clerk-events"
Expand All @@ -20,7 +21,8 @@ import { receiveSvixWebhook, webhookText } from "./svix-receiver"
/**
* Clerk webhook receiver. Three jobs:
*
* - `user.created` → `signup_completed` product event.
* - `user.created` → `signup_completed` product event; membership created /
* deleted → `member_joined` / `member_removed`.
* - membership lifecycle → an org audit entry (`member.added` / `role_changed`
* / `removed`). Membership is the one org change the web app makes in Clerk
* rather than through Maple's API, so this receiver is the only writer of
Expand Down Expand Up @@ -73,6 +75,7 @@ const handler = Effect.gen(function* () {
const handleMembership = Effect.fn("ClerkWebhook.membership")(function* (
data: unknown,
event: ClerkMembershipEventType,
timestamp: number | undefined,
) {
const payload = yield* decodeClerkOrganizationMembership(data).pipe(Effect.option)
if (Option.isNone(payload)) {
Expand Down Expand Up @@ -103,15 +106,21 @@ const handler = Effect.gen(function* () {
affectedUserId: userId.value,
...(payload.value.role !== undefined ? { metadata: { role: payload.value.role } } : undefined),
})
const productEvent = membershipProductEvent(event, payload.value, timestamp)
if (event === "organizationMembership.created") {
if (productEvent !== undefined) yield* productEvents.track(productEvent)
yield* Effect.annotateCurrentSpan({
"http.response.status_code": 200,
"maple.webhook.outcome": "handled",
})
return webhookText("ok", 200)
}
if (event === "organizationMembership.deleted") {
return yield* revoke(revocation.revokeMembership(orgId.value, userId.value))
const response = yield* revoke(revocation.revokeMembership(orgId.value, userId.value))
// Only once the sweep succeeded: a 500 is retried by Clerk and would emit it twice.
if (response.status === 200 && productEvent !== undefined)
yield* productEvents.track(productEvent)
return response
}

// A role change is not just a cache eviction: CLI/MCP/device keys pin the
Expand Down Expand Up @@ -153,7 +162,7 @@ const handler = Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ "maple.webhook.event": envelope.value.type })

if (isClerkMembershipEvent(envelope.value.type)) {
return yield* handleMembership(envelope.value.data, envelope.value.type)
return yield* handleMembership(envelope.value.data, envelope.value.type, envelope.value.timestamp)
}
if (envelope.value.type === "user.deleted") {
const payload = yield* decodeClerkUserDeleted(envelope.value.data).pipe(Effect.option)
Expand Down
19 changes: 18 additions & 1 deletion apps/api/src/routes/webhooks/webhooks.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ describe("ClerkWebhookRouter", () => {

// 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()
Expand Down Expand Up @@ -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())))
}),
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)))
}),
)
Expand Down
146 changes: 140 additions & 6 deletions apps/web/src/data/analytics-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down Expand Up @@ -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<typeof toLoadFailure> | 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<void, string>) =>
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<typeof UptimeFormMessage.CompletedSave>[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<typeof SloFormMessage.CompletedSave>[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<typeof BuilderMessage.CompletedCreate>[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 => ({
Expand All @@ -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)
})
})
14 changes: 14 additions & 0 deletions apps/web/src/data/product-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading