diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md index 58b67b7..841d60a 100644 --- a/docs/HOST_APP_GUIDE.md +++ b/docs/HOST_APP_GUIDE.md @@ -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 ``` diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index 2cbe892..ae2a077 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -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) diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index e12a8be..bdbfaee 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -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? @@ -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 diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 19be2eb..4c17244 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -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", + 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 diff --git a/engine/app/models/coplan/comment.rb b/engine/app/models/coplan/comment.rb index 089af89..156546e 100644 --- a/engine/app/models/coplan/comment.rb +++ b/engine/app/models/coplan/comment.rb @@ -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? @@ -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 diff --git a/engine/app/models/coplan/comment_thread.rb b/engine/app/models/coplan/comment_thread.rb index 5270cc9..7ca40d1 100644 --- a/engine/app/models/coplan/comment_thread.rb +++ b/engine/app/models/coplan/comment_thread.rb @@ -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) diff --git a/engine/app/services/coplan/plans/create.rb b/engine/app/services/coplan/plans/create.rb index c5a736a..6145594 100644 --- a/engine/app/services/coplan/plans/create.rb +++ b/engine/app/services/coplan/plans/create.rb @@ -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, @@ -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 diff --git a/engine/lib/coplan.rb b/engine/lib/coplan.rb index 628da11..a8a7f62 100644 --- a/engine/lib/coplan.rb +++ b/engine/lib/coplan.rb @@ -1,6 +1,7 @@ require "commonmarker" require "diffy" require "coplan/configuration" +require "coplan/analytics" require "coplan/engine" module CoPlan diff --git a/engine/lib/coplan/analytics.rb b/engine/lib/coplan/analytics.rb new file mode 100644 index 0000000..331d7ae --- /dev/null +++ b/engine/lib/coplan/analytics.rb @@ -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 diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 514a779..7d94448 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -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 @@ -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 these instructions.' @agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"' @seed_plan_types = [] diff --git a/spec/lib/coplan/analytics_spec.rb b/spec/lib/coplan/analytics_spec.rb new file mode 100644 index 0000000..7ecf924 --- /dev/null +++ b/spec/lib/coplan/analytics_spec.rb @@ -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 diff --git a/spec/models/coplan/comment_analytics_spec.rb b/spec/models/coplan/comment_analytics_spec.rb new file mode 100644 index 0000000..fda5efe --- /dev/null +++ b/spec/models/coplan/comment_analytics_spec.rb @@ -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 diff --git a/spec/models/coplan/comment_thread_analytics_spec.rb b/spec/models/coplan/comment_thread_analytics_spec.rb new file mode 100644 index 0000000..b1a1bb0 --- /dev/null +++ b/spec/models/coplan/comment_thread_analytics_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe CoPlan::CommentThread, "analytics" do + let(:user) { create(:coplan_user) } + let(:thread) { create(:comment_thread) } + + it "tracks a thread_resolved event when resolve! is called" do + create(:comment, comment_thread: thread) + create(:comment, comment_thread: thread) + + events = capture_analytics_events { thread.resolve!(user) } + + expect(events.length).to eq(1) + event_name, payload = events.first + expect(event_name).to eq("thread_resolved") + expect(payload[:user_id]).to eq(user.id) + expect(payload[:properties]).to include( + plan_id: thread.plan_id, + comment_thread_id: thread.id, + previous_status: "pending", + comment_count: 2, + anchored: false + ) + end + + it "does not track when accept! or discard! are called" do + events = capture_analytics_events do + thread.accept!(user) + create(:comment_thread).discard!(user) + end + expect(events).to be_empty + end +end diff --git a/spec/requests/analytics_instrumentation_spec.rb b/spec/requests/analytics_instrumentation_spec.rb new file mode 100644 index 0000000..278c22f --- /dev/null +++ b/spec/requests/analytics_instrumentation_spec.rb @@ -0,0 +1,110 @@ +require "rails_helper" + +RSpec.describe "Analytics instrumentation", type: :request do + let(:user) { create(:coplan_user) } + + before { sign_in_as(user) } + + describe "page_view" do + it "tracks a page_view event on successful HTML GETs" do + plan = create(:plan, :considering, created_by_user: user) + + events = capture_analytics_events { get plan_path(plan) } + + page_views = events.select { |name, _| name == "page_view" } + expect(page_views.length).to eq(1) + _, payload = page_views.first + expect(payload[:user_id]).to eq(user.id) + expect(payload[:properties]).to include( + path: plan_path(plan), + controller: "coplan/plans", + action: "show" + ) + end + + it "does not track for turbo-frame requests" do + create(:plan, :considering, created_by_user: user) + + events = capture_analytics_events do + get plans_path, headers: { "Turbo-Frame" => "plan-list" } + end + + expect(events.select { |name, _| name == "page_view" }).to be_empty + end + + it "does not track agent (non-browser) requests" do + events = capture_analytics_events do + get plans_path, headers: { "User-Agent" => "curl/8" } + end + expect(events.select { |name, _| name == "page_view" }).to be_empty + end + + it "does not track non-2xx responses" do + events = capture_analytics_events { get plan_path("does-not-exist") } + + expect(response).to have_http_status(:not_found) + expect(events.select { |name, _| name == "page_view" }).to be_empty + end + end + + describe "plan_published" do + it "tracks plan_published when status crosses to considering" do + plan = create(:plan, :brainstorm, created_by_user: user) + + events = capture_analytics_events do + patch update_status_plan_path(plan), params: { status: "considering" } + end + + published = events.select { |name, _| name == "plan_published" } + expect(published.length).to eq(1) + _, payload = published.first + expect(payload[:user_id]).to eq(user.id) + expect(payload[:properties]).to include( + plan_id: plan.id, + previous_status: "brainstorm" + ) + end + + it "does not track plan_published when status changes between non-considering states" do + plan = create(:plan, :brainstorm, created_by_user: user) + + events = capture_analytics_events do + patch update_status_plan_path(plan), params: { status: "abandoned" } + end + + expect(events.select { |name, _| name == "plan_published" }).to be_empty + end + + it "does not track plan_published when re-saving status considering" do + plan = create(:plan, :considering, created_by_user: user) + + events = capture_analytics_events do + patch update_status_plan_path(plan), params: { status: "considering" } + end + + expect(events.select { |name, _| name == "plan_published" }).to be_empty + end + + it "tracks plan_published when the API publishes a plan" do + plan = create(:plan, :brainstorm, created_by_user: user) + token = create(:api_token, user: user, raw_token: "publish-token") + token # ensure persisted + + events = capture_analytics_events do + patch api_v1_plan_path(plan), + params: { status: "considering" }.to_json, + headers: { "Authorization" => "Bearer publish-token", "Content-Type" => "application/json" } + end + + published = events.select { |name, _| name == "plan_published" } + expect(published.length).to eq(1) + _, payload = published.first + expect(payload[:user_id]).to eq(user.id) + expect(payload[:properties]).to include( + plan_id: plan.id, + previous_status: "brainstorm", + via: "api" + ) + end + end +end diff --git a/spec/services/plans/create_spec.rb b/spec/services/plans/create_spec.rb index 21ec9e9..a953eed 100644 --- a/spec/services/plans/create_spec.rb +++ b/spec/services/plans/create_spec.rb @@ -49,4 +49,29 @@ expect(plan).to be_persisted expect(plan.plan_type_id).to be_nil end + + it "tracks a plan_created analytics event" do + user = create(:coplan_user) + plan_type = create(:plan_type) + + events = capture_analytics_events do + CoPlan::Plans::Create.call( + title: "Tracked", + content: "# Tracked\n\nbody", + user: user, + plan_type_id: plan_type.id + ) + end + + expect(events.length).to eq(1) + event_name, payload = events.first + expect(event_name).to eq("plan_created") + expect(payload[:user_id]).to eq(user.id) + expect(payload[:properties]).to include( + plan_type_id: plan_type.id, + status: "brainstorm", + content_length: "# Tracked\n\nbody".length + ) + expect(payload[:properties][:plan_id]).to be_present + end end diff --git a/spec/support/analytics_helpers.rb b/spec/support/analytics_helpers.rb new file mode 100644 index 0000000..6b51c36 --- /dev/null +++ b/spec/support/analytics_helpers.rb @@ -0,0 +1,20 @@ +module AnalyticsHelpers + # Captures all analytics events emitted during the block by swapping in a + # collecting handler on CoPlan.configuration.track_event. Returns an array of + # [event_name_string, payload_hash] tuples, in emission order. + # + # Restores the previous handler even if the block raises. + def capture_analytics_events + events = [] + previous = CoPlan.configuration.track_event + CoPlan.configuration.track_event = ->(event, payload) { events << [event, payload] } + yield + events + ensure + CoPlan.configuration.track_event = previous + end +end + +RSpec.configure do |config| + config.include AnalyticsHelpers +end