-
Notifications
You must be signed in to change notification settings - Fork 31
feat: add the growthsignals activity taxonomy and emitter #6057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
95b6668
chore: design for revamped PostHog Slack notifications (GRW-66)
simplesagar 5ee235b
feat(growthsignals): add the core activity taxonomy and emitter
simplesagar 4a0e68e
fix(growthsignals): keep extras off reserved keys and blank links out
simplesagar 3b2a612
fix(growthsignals): stop cache lookups outliving their caller
simplesagar 2a19984
fix(growthsignals): stop one caller cancelling a shared lookup
simplesagar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
396 changes: 396 additions & 0 deletions
396
docs/superpowers/specs/2026-09-03-posthog-slack-notifications-design.md
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| } | ||
|
|
||
| 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()) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.