🤖 From Claude:
AhoyTrackable (included in ApplicationRecord) tracks create/update/destroy for every model, but track_lifecycle_event opens with:
return unless Current.user || Current.source
Current.user is only set for a signed-in request (ApplicationController#set_current_user), and Current.source is set in exactly two places (public_forms_controller, events/public_registrations_controller). Everything else that writes without a signed-in user leaves no trace at all — no event, no error, no log line.
Verified
Calling scholarship.accept_agreement!(by: "recipient") with no actor set:
| Actor |
Buffered events |
| none |
[] |
Current.user set |
update.scholarship, create.scholarship_agreement_response |
Current.source set |
update.scholarship, create.scholarship_agreement_response |
Where this bites today
- Scholarship accept/decline on the public callout.
Events::CalloutsController does skip_before_action :authenticate_user! and never sets Current.source, so a logged-out recipient accepting or declining their agreement produces no Ahoy event. The bespoke scholarship_agreement_responses log is currently the only record of it.
- Anything outside a request — jobs, rake tasks, console, webhooks (Stripe). These are dropped twice over: the actor guard, and
Analytics::LifecycleBuffer only flushing in ApplicationController's after_action, so a buffered event outside a request is never written.
Recommendation
Close the request-shaped hole narrowly rather than removing the guard.
- Default a source for every actorless request — one line in
ApplicationController: set Current.source when there's no signed-in user, so any public flow (present or future) is covered without each controller having to remember. Named per controller where it matters, generic otherwise.
- Opt non-request contexts in explicitly — jobs, webhooks, and rake tasks set their own
Current.source ("stripe_webhook", "job:x"), and LifecycleBuffer needs a flush path that doesn't depend on a controller.
- Don't just delete the guard. Seeds, imports, and the test suite would start emitting events in volume, which is presumably why it's there.
Acceptance
- A logged-out recipient accepting a scholarship agreement produces an Ahoy event attributed to a named source.
- A model write inside a job produces an event (or is a documented, deliberate exclusion).
- Specs cover both, so the guard can't silently re-open the hole.
🤖 From Claude:
AhoyTrackable(included inApplicationRecord) tracks create/update/destroy for every model, buttrack_lifecycle_eventopens with:Current.useris only set for a signed-in request (ApplicationController#set_current_user), andCurrent.sourceis set in exactly two places (public_forms_controller,events/public_registrations_controller). Everything else that writes without a signed-in user leaves no trace at all — no event, no error, no log line.Verified
Calling
scholarship.accept_agreement!(by: "recipient")with no actor set:[]Current.usersetupdate.scholarship,create.scholarship_agreement_responseCurrent.sourcesetupdate.scholarship,create.scholarship_agreement_responseWhere this bites today
Events::CalloutsControllerdoesskip_before_action :authenticate_user!and never setsCurrent.source, so a logged-out recipient accepting or declining their agreement produces no Ahoy event. The bespokescholarship_agreement_responseslog is currently the only record of it.Analytics::LifecycleBufferonly flushing inApplicationController'safter_action, so a buffered event outside a request is never written.Recommendation
Close the request-shaped hole narrowly rather than removing the guard.
ApplicationController: setCurrent.sourcewhen there's no signed-in user, so any public flow (present or future) is covered without each controller having to remember. Named per controller where it matters, generic otherwise.Current.source("stripe_webhook","job:x"), andLifecycleBufferneeds a flush path that doesn't depend on a controller.Acceptance