Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/growth-signals-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"server": patch
---

Add the `growthsignals` package, which describes notable moments in Gram — organizations and projects created, MCP servers deployed, security policies written, members invited and joining — as a single PostHog `gram_activity` event with a stable property shape. It carries the activity taxonomy, the map from audit action to activity (including the pass-through name that gives uncurated actions coverage and the exclusion list that keeps high-volume noise out), the event builder, a repo-backed enricher behind a TTL cache, and the emitter that skips the demo organization and logs rather than returns capture failures. Nothing calls it yet, so no events are emitted and no behaviour changes.

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions server/internal/growthsignals/actionmap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package growthsignals

import (
"strings"

"github.com/speakeasy-api/gram/server/internal/audit"
)

// McpKind names the flavour of MCP server an ActivityMcpServerCreated event
// describes. Five audit actions create MCP servers and Growth reads them as one
// moment, so the flavour survives as a property instead of as five activities.
type McpKind string

const (
// McpKindHosted is a server Gram builds and hosts from a deployment.
McpKindHosted McpKind = "hosted"

// McpKindRemote is a third-party server Gram proxies.
McpKindRemote McpKind = "remote"

// McpKindTunneled is a server reachable through a customer-run tunnel.
McpKindTunneled McpKind = "tunneled"

// McpKindUnproxied is a server registered for visibility but not proxied.
McpKindUnproxied McpKind = "unproxied"

// McpKindMeta is a server that aggregates other servers.
McpKindMeta McpKind = "meta"
)

// ActionMapping is everything one audit action contributes to an event.
type ActionMapping struct {
// Activity is the taxonomy name the action is reported as. ActivitySkip
// means the action is excluded and nothing should be emitted for it.
Activity Activity

// Extra holds the static properties the mapping itself contributes, such
// as the mcp_kind of an MCP server creation, and is nil when it
// contributes none. Each call returns a freshly allocated map, so callers
// may add their own per-event extras to it.
Extra map[string]string
}

// curatedActivities gives the moments Growth named a friendly activity, so the
// significant-events channel can filter on a stable name rather than on the
// shape of whichever audit action happened to produce it.
//
// It is deliberately short. Every other audited action still reaches PostHog
// through the pass-through name, so this map is a renaming layer rather than an
// allowlist that has to be extended for new coverage.
//
//nolint:exhaustive // a renaming layer over a subset of actions, not a case analysis
var curatedActivities = map[audit.Action]Activity{
audit.ActionProjectCreate: ActivityProjectCreated,

audit.ActionMcpServerCreate: ActivityMcpServerCreated,
audit.ActionRemoteMcpServerCreate: ActivityMcpServerCreated,
audit.ActionTunneledMcpServerCreate: ActivityMcpServerCreated,
audit.ActionUnproxiedMcpServerCreate: ActivityMcpServerCreated,
audit.ActionMetaMcpServerCreate: ActivityMcpServerCreated,

audit.ActionRiskPolicyCreate: ActivitySecurityPolicyCreated,
audit.ActionRiskPolicyUpdate: ActivitySecurityPolicyUpdated,

audit.ActionMcpServerUpdate: ActivityMcpServerUpdated,
audit.ActionMCPMetadataUpdate: ActivityMcpServerUpdated,
audit.ActionMcpServerToolMetadataUpdate: ActivityMcpServerUpdated,

audit.ActionOrganizationInviteCreate: ActivityMemberInvited,
}

// mcpCreateKinds records which MCP server flavour each creation action makes.
//
//nolint:exhaustive // only MCP creation actions have a server flavour
var mcpCreateKinds = map[audit.Action]McpKind{
audit.ActionMcpServerCreate: McpKindHosted,
audit.ActionRemoteMcpServerCreate: McpKindRemote,
audit.ActionTunneledMcpServerCreate: McpKindTunneled,
audit.ActionUnproxiedMcpServerCreate: McpKindUnproxied,
audit.ActionMetaMcpServerCreate: McpKindMeta,
}

// excludedActions are audited actions with no ops value that would otherwise
// dominate the firehose: an assistant's tool calls and wake timers, chat
// session reads, and the diagnostics call Platform MCP clients poll.
//
// They are dropped here rather than at the PostHog destination because the
// volume is the problem, and a destination filter still pays for every event
// that reaches it.
//
//nolint:exhaustive // an exclusion list is partial by definition
var excludedActions = map[audit.Action]struct{}{
audit.ActionAssistantToolCall: {},
audit.ActionWakeScheduled: {},
audit.ActionWakeFired: {},
audit.ActionWakeCancelled: {},
audit.ActionChatSessionAccess: {},
audit.ActionPlatformMcpDiagnosticsUserStatusRead: {},
}

// ActivityForAction resolves an audit action to what it contributes to an
// event.
//
// An action nobody curated still maps to an activity, derived from the action
// itself, so the firehose covers every audited mutation without an allowlist to
// maintain as services add audit coverage.
func ActivityForAction(action audit.Action) ActionMapping {
if _, excluded := excludedActions[action]; excluded {
return ActionMapping{Activity: ActivitySkip, Extra: nil}
}

activity, curated := curatedActivities[action]
if !curated {
return ActionMapping{Activity: passThroughActivity(action), Extra: nil}
Comment thread
simplesagar marked this conversation as resolved.
}

if kind, isMcpCreate := mcpCreateKinds[action]; isMcpCreate {
return ActionMapping{
Activity: activity,
Extra: map[string]string{PropertyMcpKind: string(kind)},
}
}

return ActionMapping{Activity: activity, Extra: nil}
}

// passThroughActivity derives an activity name from a raw audit action:
// "toolset:create" becomes "toolset_create".
//
// Audit actions use several separators (":", "-", "_") and the activity
// property is read by humans in Slack and grouped on in PostHog, so every
// non-alphanumeric run collapses to a single underscore. An action with no
// alphanumeric content at all names nothing and is skipped rather than emitted
// as a blank activity.
func passThroughActivity(action audit.Action) Activity {
var name strings.Builder
name.Grow(len(action))

separatorPending := false
for _, r := range strings.ToLower(string(action)) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
separatorPending = true
continue
}

if separatorPending && name.Len() > 0 {
name.WriteByte('_')
}
separatorPending = false
name.WriteRune(r)
}

if name.Len() == 0 {
return ActivitySkip
}

return Activity(name.String())
}
142 changes: 142 additions & 0 deletions server/internal/growthsignals/actionmap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package growthsignals_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/speakeasy-api/gram/server/internal/audit"
"github.com/speakeasy-api/gram/server/internal/growthsignals"
)

func TestActivityForActionCuratedMoments(t *testing.T) {
t.Parallel()

tests := []struct {
name string
action audit.Action
want growthsignals.Activity
}{
{name: "project create", action: audit.ActionProjectCreate, want: growthsignals.ActivityProjectCreated},
{name: "risk policy create", action: audit.ActionRiskPolicyCreate, want: growthsignals.ActivitySecurityPolicyCreated},
{name: "risk policy update", action: audit.ActionRiskPolicyUpdate, want: growthsignals.ActivitySecurityPolicyUpdated},
{name: "mcp server update", action: audit.ActionMcpServerUpdate, want: growthsignals.ActivityMcpServerUpdated},
{name: "mcp metadata update", action: audit.ActionMCPMetadataUpdate, want: growthsignals.ActivityMcpServerUpdated},
{name: "mcp tool metadata update", action: audit.ActionMcpServerToolMetadataUpdate, want: growthsignals.ActivityMcpServerUpdated},
{name: "organization invite create", action: audit.ActionOrganizationInviteCreate, want: growthsignals.ActivityMemberInvited},
}

for _, tt := range tests {
mapping := growthsignals.ActivityForAction(tt.action)

require.Equal(t, tt.want, mapping.Activity, "activity for %s (%s)", tt.name, tt.action)
require.Empty(t, mapping.Extra, "extras for %s (%s)", tt.name, tt.action)
}
}

// The five MCP creation actions collapse to one activity, so the flavour has to
// survive as a property or the distinction is lost entirely.
func TestActivityForActionCarriesMcpKind(t *testing.T) {
t.Parallel()

tests := []struct {
name string
action audit.Action
want growthsignals.McpKind
}{
{name: "hosted", action: audit.ActionMcpServerCreate, want: growthsignals.McpKindHosted},
{name: "remote", action: audit.ActionRemoteMcpServerCreate, want: growthsignals.McpKindRemote},
{name: "tunneled", action: audit.ActionTunneledMcpServerCreate, want: growthsignals.McpKindTunneled},
{name: "unproxied", action: audit.ActionUnproxiedMcpServerCreate, want: growthsignals.McpKindUnproxied},
{name: "meta", action: audit.ActionMetaMcpServerCreate, want: growthsignals.McpKindMeta},
}

for _, tt := range tests {
mapping := growthsignals.ActivityForAction(tt.action)

require.Equal(t, growthsignals.ActivityMcpServerCreated, mapping.Activity, "activity for %s mcp create", tt.name)
require.Equal(t, map[string]string{
growthsignals.PropertyMcpKind: string(tt.want),
}, mapping.Extra, "extras for %s mcp create", tt.name)
}
}

func TestActivityForActionPassesThroughUncuratedActions(t *testing.T) {
t.Parallel()

tests := []struct {
name string
action audit.Action
want growthsignals.Activity
}{
{name: "colon separator", action: audit.ActionToolsetCreate, want: "toolset_create"},
{name: "hyphenated subject", action: audit.ActionMcpEndpointCreate, want: "mcp_endpoint_create"},
{name: "already underscored", action: audit.ActionEnvironmentCreate, want: "environment_create"},
{name: "mixed separators", action: audit.ActionRemoteSessionClientAttachJsonWebKeySet, want: "remote_session_client_attach_json_web_key_set"},
{name: "deleted mcp server", action: audit.ActionMcpServerDelete, want: "mcp_server_delete"},
{name: "unknown action", action: audit.Action("widget:frobnicate"), want: "widget_frobnicate"},
{name: "repeated separators", action: audit.Action("widget::--frobnicate"), want: "widget_frobnicate"},
{name: "leading and trailing separators", action: audit.Action(":widget:"), want: "widget"},
{name: "uppercase", action: audit.Action("Widget:Frobnicate"), want: "widget_frobnicate"},
}

for _, tt := range tests {
mapping := growthsignals.ActivityForAction(tt.action)

require.Equal(t, tt.want, mapping.Activity, "activity for %s (%s)", tt.name, tt.action)
require.Empty(t, mapping.Extra, "extras for %s (%s)", tt.name, tt.action)
}
}

// An action with nothing to name is skipped rather than emitted as a blank
// activity, which would be indistinguishable from a bug in the taxonomy.
func TestActivityForActionSkipsUnnamableActions(t *testing.T) {
t.Parallel()

tests := []audit.Action{"", ":", "---", "::--"}

for _, action := range tests {
mapping := growthsignals.ActivityForAction(action)

require.Equal(t, growthsignals.ActivitySkip, mapping.Activity, "activity for %q", action)
}
}

func TestActivityForActionSkipsHighVolumeNoise(t *testing.T) {
t.Parallel()

tests := []struct {
name string
action audit.Action
}{
{name: "assistant tool call", action: audit.ActionAssistantToolCall},
{name: "assistant wake scheduled", action: audit.ActionWakeScheduled},
{name: "assistant wake fired", action: audit.ActionWakeFired},
{name: "assistant wake cancelled", action: audit.ActionWakeCancelled},
{name: "chat session access", action: audit.ActionChatSessionAccess},
{name: "platform mcp diagnostics read", action: audit.ActionPlatformMcpDiagnosticsUserStatusRead},
}

for _, tt := range tests {
mapping := growthsignals.ActivityForAction(tt.action)

require.Equal(t, growthsignals.ActivitySkip, mapping.Activity, "activity for %s (%s)", tt.name, tt.action)
require.Empty(t, mapping.Extra, "extras for %s (%s)", tt.name, tt.action)
}
}

// Callers merge their own per-event extras into the returned map, so it must
// not be the map the package keeps.
func TestActivityForActionReturnsFreshExtras(t *testing.T) {
t.Parallel()

first := growthsignals.ActivityForAction(audit.ActionMcpServerCreate)
first.Extra[growthsignals.PropertyMcpKind] = "tampered"
first.Extra["added"] = "by caller"

second := growthsignals.ActivityForAction(audit.ActionMcpServerCreate)

require.Equal(t, map[string]string{
growthsignals.PropertyMcpKind: string(growthsignals.McpKindHosted),
}, second.Extra)
}
77 changes: 77 additions & 0 deletions server/internal/growthsignals/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Package growthsignals turns notable moments in Gram — projects created, MCP
// servers deployed, members joining, security policies written — into a single
// PostHog event that internal Slack destinations render as ops signal.
//
// Everything is reported as one event name with a stable property shape, so
// which moments count as significant is a filter on the PostHog destination
// rather than a deploy. The taxonomy of what happened lives here; the judgement
// of what matters does not.
//
// Nothing in this package sits on a request's critical path. An activity whose
// lookups fail still ships with the properties that did resolve, and an
// activity that cannot be captured is logged and dropped: analytics must never
// fail the work that produced it.
package growthsignals

// EventName is the single PostHog event every activity is captured as. One
// name with a stable property shape keeps destination filters expressible as
// property matches instead of a growing list of event names.
const EventName = "gram_activity"

// Activity is the taxonomy name an event carries in its `activity` property.
// It answers what happened in terms Growth reads, rather than in terms of the
// audit action or table that produced it.
type Activity string

const (
// ActivityOrganizationCreated is a new Gram organization being provisioned.
ActivityOrganizationCreated Activity = "organization_created"

// ActivityUserSignedUp is a person's first Gram user record being created.
// It carries a signup_source distinguishing an invited arrival from an
// organic one.
ActivityUserSignedUp Activity = "user_signed_up"

// ActivityMemberJoinedOrganization is a pending invitation being accepted.
// It carries the role the new member was granted.
ActivityMemberJoinedOrganization Activity = "member_joined_organization"

// ActivityProjectCreated is a new project inside an organization.
ActivityProjectCreated Activity = "project_created"

// ActivityMcpServerCreated is any flavour of MCP server being created. The
// flavour survives as the mcp_kind property, so the five creation paths
// read as one moment.
ActivityMcpServerCreated Activity = "mcp_server_created"

// ActivitySecurityPolicyCreated is a new risk policy.
ActivitySecurityPolicyCreated Activity = "security_policy_created"

// ActivitySecurityPolicyUpdated is a change to an existing risk policy.
ActivitySecurityPolicyUpdated Activity = "security_policy_updated"

// ActivityMcpServerUpdated is a change to an MCP server, its metadata, or
// its tool metadata.
ActivityMcpServerUpdated Activity = "mcp_server_updated"

// ActivityMemberInvited is an invitation being sent to join an
// organization. The join itself is ActivityMemberJoinedOrganization.
ActivityMemberInvited Activity = "member_invited"

// ActivityDeviceFirstSeen is a device appearing in an organization's fleet
// for the first time.
ActivityDeviceFirstSeen Activity = "device_first_seen"

// ActivityAgentFirstDetected is an AI agent being attributed to an
// organization for the first time.
ActivityAgentFirstDetected Activity = "agent_first_detected"
)

// ActivitySkip marks an audit action that carries no ops value and must not be
// emitted. It is a decision, not a taxonomy name: it never reaches PostHog, and
// the emitter drops any activity carrying it.
//
// Excluding at the source rather than at the destination filter matters because
// the excluded actions are the high-volume ones — an assistant's tool calls
// alone would dwarf everything else on the topic.
const ActivitySkip Activity = "skip"
Loading
Loading