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
13 changes: 13 additions & 0 deletions docs/HOST_APP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,18 @@ CoPlan.configure do |config|

# Notifications (optional)
config.notification_handler = ->(event, payload) { ... }

# Analytics (optional)
#
# Fires for every event the engine instruments (page_view, plan_created,
# plan_published, comment_created, thread_resolved, ...). Called inline on
# the request thread; if your handler is slow, enqueue a job from inside it.
# Exceptions raised by the handler are swallowed and reported via
# `error_reporter`, so a broken sink will never break a user request.
#
# Payload always contains: :event, :timestamp, :user_id, :properties (Hash).
config.track_event = ->(event, payload) {
AnalyticsEvent.create!(name: event, payload: payload)
}
end
```
10 changes: 10 additions & 0 deletions engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ def update
actor_type: api_author_type, actor_id: api_actor_id
)
Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: permitted[:status], triggered_by: current_user)
if @plan.status == "considering" && old_status != "considering"
CoPlan::Analytics.track(
"plan_published",
user: current_user,
plan_id: @plan.id,
plan_type_id: @plan.plan_type_id,
previous_status: old_status,
via: "api"
)
end
end

if params.key?(:tags)
Expand Down
21 changes: 21 additions & 0 deletions engine/app/controllers/coplan/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def self.controller_path
before_action :authenticate_coplan_user!
before_action :set_coplan_current
after_action :set_agent_instructions_header
after_action :track_page_view

helper_method :current_user, :signed_in?, :show_api_tokens?

Expand Down Expand Up @@ -71,6 +72,26 @@ def authorize!(record, action)
end
end

# Fires once per successful, signed-in HTML GET. Skips Turbo Frame
# requests (those are partial reloads within an already-counted page),
# non-2xx responses, agent/API traffic, and anything that isn't HTML.
def track_page_view
return unless current_user
return unless request.get?
return unless response.media_type == "text/html"
return unless response.status >= 200 && response.status < 300
return if turbo_frame_request?
return if agent_request?

CoPlan::Analytics.track(
"page_view",
user: current_user,
path: request.path,
controller: controller_path,
action: action_name
)
end

def set_agent_instructions_header
response.headers["X-Agent-Instructions"] = coplan.agent_instructions_path
end
Expand Down
10 changes: 10 additions & 0 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ def update_status
after: new_status
)
Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: new_status, triggered_by: current_user)
if new_status == "considering" && old_status != "considering"
CoPlan::Analytics.track(
"plan_published",
Comment on lines +84 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add plan_published tracking to API status updates

When a plan is published through the documented API metadata endpoint rather than this web-only update_status action, no plan_published event is emitted; I checked engine/app/controllers/coplan/api/v1/plans_controller.rb and its status-change path only logs the event and triggers reviews at lines 95-101. Agents are explicitly instructed to update status via the API, so publishing a brainstorm plan that way will undercount the core publish KPI even though the status transition succeeds.

Useful? React with 👍 / 👎.

user: current_user,
plan_id: @plan.id,
plan_type_id: @plan.plan_type_id,
previous_status: old_status,
via: "web"
)
end
end
redirect_to plan_path(@plan), notice: "Status updated to #{new_status}."
else
Expand Down
21 changes: 21 additions & 0 deletions engine/app/models/coplan/comment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Comment < ApplicationRecord

before_save :rewrite_plain_mentions, if: :body_markdown_changed?
after_create_commit :notify_plan_author, if: :first_comment_in_thread?
after_create_commit :track_comment_created
# Runs on save (not just create) so adding a mention via edit also
# notifies. ProcessMentions uses find_or_create_by to dedupe.
after_save_commit :process_mentions, if: :saved_change_to_body_markdown?
Expand Down Expand Up @@ -40,6 +41,26 @@ def notify_plan_author
CoPlan::NotificationJob.perform_later("comment_created", { comment_thread_id: comment_thread_id })
end

def track_comment_created
# NOTE: We deliberately don't reuse `first_comment_in_thread?` here —
# that helper compares UUIDs with `id < ?`, which is not insertion-
# ordered. After-create-commit guarantees the row is persisted, so a
# total count of 1 is the reliable signal for "this comment opened
# the thread."
is_first = comment_thread.comments.count == 1

CoPlan::Analytics.track(
"comment_created",
user: author,
plan_id: comment_thread.plan_id,
comment_thread_id: comment_thread_id,
comment_id: id,
author_type: author_type,
is_first_in_thread: is_first,
body_length: body_markdown.to_s.length
)
end

def process_mentions
CoPlan::Comments::ProcessMentions.call(self)
end
Expand Down
10 changes: 10 additions & 0 deletions engine/app/models/coplan/comment_thread.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,17 @@ def anchor_preview(max_length: 80)
end

def resolve!(user)
previous_status = status
update!(status: "resolved", resolved_by_user: user)
CoPlan::Analytics.track(
"thread_resolved",
user: user,
plan_id: plan_id,
comment_thread_id: id,
previous_status: previous_status,
comment_count: comments.count,
anchored: anchored?
)
end

def accept!(user)
Expand Down
13 changes: 12 additions & 1 deletion engine/app/services/coplan/plans/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def initialize(title:, content:, user:, plan_type_id: nil)
end

def call
ActiveRecord::Base.transaction do
plan = ActiveRecord::Base.transaction do
plan = Plan.create!(title: @title, created_by_user: @user, plan_type_id: @plan_type_id)
version = PlanVersion.create!(
plan: plan,
Expand All @@ -25,6 +25,17 @@ def call
plan.update!(current_plan_version: version, current_revision: 1)
plan
end

CoPlan::Analytics.track(
"plan_created",
user: @user,
plan_id: plan.id,
plan_type_id: plan.plan_type_id,
status: plan.status,
content_length: @content.to_s.length
)

plan
end
end
end
Expand Down
1 change: 1 addition & 0 deletions engine/lib/coplan.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require "commonmarker"
require "diffy"
require "coplan/configuration"
require "coplan/analytics"
require "coplan/engine"

module CoPlan
Expand Down
30 changes: 30 additions & 0 deletions engine/lib/coplan/analytics.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module CoPlan
# Thin facade for the configurable analytics hook. Call sites use
# `CoPlan::Analytics.track("event_name", user:, **props)`;
# the host wires `CoPlan.configuration.track_event` to a destination
# (a MySQL events table, Snowflake, Datadog, etc.). Default is no-op.
#
# Errors in the host handler are swallowed and reported via
# `CoPlan.configuration.error_reporter` so a broken analytics sink
# never breaks the user request that triggered the event.
module Analytics
def self.track(event, user: nil, **properties)
handler = CoPlan.configuration.track_event
return unless handler

event_name = event.to_s
payload = {
event: event_name,
timestamp: Time.current.iso8601,
user_id: user&.id,
properties: properties
}

handler.call(event_name, payload)
rescue => e
reporter = CoPlan.configuration.error_reporter
reporter&.call(e, { coplan_analytics_event: event.to_s })
nil
end
end
end
20 changes: 20 additions & 0 deletions engine/lib/coplan/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ class Configuration
attr_accessor :ai_base_url, :ai_api_key, :ai_model
attr_accessor :error_reporter
attr_accessor :notification_handler

# Lambda invoked for every analytics event tracked via
# `CoPlan::Analytics.track`. Receives (event_name, payload_hash).
# No-op by default; hosts wire this to write to a destination
# (MySQL events table, Snowflake, Datadog, etc.).
#
# The handler is called inline on the request thread and must not
# raise — any exception is swallowed and reported via `error_reporter`
# so a broken sink never breaks user requests. Hosts that need
# heavyweight writes should enqueue a job from inside the handler.
#
# Payload always includes:
# :event, :timestamp, :user_id, :properties (Hash)
#
# Example:
# config.track_event = ->(event, payload) {
# AnalyticsEvent.create!(name: event, payload: payload)
# }
attr_accessor :track_event
attr_accessor :onboarding_banner
attr_accessor :agent_auth_instructions
attr_accessor :agent_curl_prefix
Expand Down Expand Up @@ -58,6 +77,7 @@ def initialize
@ai_model = "gpt-4o"
@error_reporter = ->(exception, context) { Rails.error.report(exception, context: context) }
@notification_handler = nil
@track_event = nil
@onboarding_banner = 'Want to upload Agentic plans? Give your agent <a href="/agent-instructions">these instructions</a>.'
@agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"'
@seed_plan_types = []
Expand Down
63 changes: 63 additions & 0 deletions spec/lib/coplan/analytics_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
require "rails_helper"

RSpec.describe CoPlan::Analytics do
let(:received) { [] }
let(:handler) { ->(event, payload) { received << [event, payload] } }
let(:user) { create(:coplan_user) }

around do |example|
previous = CoPlan.configuration.track_event
CoPlan.configuration.track_event = handler
example.run
CoPlan.configuration.track_event = previous
end

it "is a no-op when no handler is configured" do
CoPlan.configuration.track_event = nil
expect { described_class.track("foo") }.not_to raise_error
expect(received).to be_empty
end

it "invokes the configured handler with event name and payload" do
freeze_time do
described_class.track("plan_created", user: user, plan_id: "abc", custom: "value")

expect(received.length).to eq(1)
event_name, payload = received.first
expect(event_name).to eq("plan_created")
expect(payload).to include(
event: "plan_created",
timestamp: Time.current.iso8601,
user_id: user.id,
properties: { plan_id: "abc", custom: "value" }
)
end
end

it "accepts symbol event names and stringifies them" do
described_class.track(:plan_published, user: user)
expect(received.first.first).to eq("plan_published")
expect(received.first.last[:event]).to eq("plan_published")
end

it "sends nil user_id when no user given" do
described_class.track("page_view")
expect(received.first.last[:user_id]).to be_nil
end

it "swallows handler errors and reports them via error_reporter" do
reported = []
previous_reporter = CoPlan.configuration.error_reporter
CoPlan.configuration.error_reporter = ->(exception, context) { reported << [exception, context] }
CoPlan.configuration.track_event = ->(_event, _payload) { raise "boom" }

expect { described_class.track("plan_created", user: user) }.not_to raise_error

expect(reported.length).to eq(1)
exception, context = reported.first
expect(exception.message).to eq("boom")
expect(context).to eq(coplan_analytics_event: "plan_created")
ensure
CoPlan.configuration.error_reporter = previous_reporter
end
end
69 changes: 69 additions & 0 deletions spec/models/coplan/comment_analytics_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
require "rails_helper"

RSpec.describe CoPlan::Comment, "analytics" do
let(:user) { create(:coplan_user) }
let(:thread) { create(:comment_thread, created_by_user: user) }

it "tracks a comment_created event when a comment is created" do
events = capture_analytics_events do
thread.comments.create!(
author_type: "human",
author_id: user.id,
body_markdown: "first comment"
)
end

comment_events = events.select { |name, _| name == "comment_created" }
expect(comment_events.length).to eq(1)

_, payload = comment_events.first
expect(payload[:user_id]).to eq(user.id)
expect(payload[:properties]).to include(
plan_id: thread.plan_id,
comment_thread_id: thread.id,
author_type: "human",
is_first_in_thread: true,
body_length: "first comment".length
)
expect(payload[:properties][:comment_id]).to be_present
end

it "marks subsequent comments as not the first in thread" do
create(:comment, comment_thread: thread, body_markdown: "first")

events = capture_analytics_events do
thread.comments.create!(
author_type: "human",
author_id: user.id,
body_markdown: "second"
)
end

_, payload = events.find { |name, _| name == "comment_created" }
expect(payload[:properties][:is_first_in_thread]).to be(false)
end

# `first_comment_in_thread?` (used by notify_plan_author) compares UUID
# strings with `id < ?`, which is not insertion-ordered. The analytics
# path uses a total-count check instead, so a reply whose UUID happens
# to sort before the existing first comment still records is_first=false.
it "is not fooled by a reply whose UUID sorts before earlier comments" do
high_id = "ffffffff-ffff-ffff-ffff-ffffffffffff"
low_id = "00000000-0000-0000-0000-000000000001"

create(:comment, comment_thread: thread, body_markdown: "first", id: high_id)

events = capture_analytics_events do
thread.comments.create!(
author_type: "human",
author_id: user.id,
body_markdown: "reply",
id: low_id
)
end

reply_event = events.find { |name, payload| name == "comment_created" && payload[:properties][:comment_id] == low_id }
expect(reply_event).not_to be_nil
expect(reply_event.last[:properties][:is_first_in_thread]).to be(false)
end
end
Loading
Loading