feat: DAH-4291 invite-to logging improvement: add teardown beacon, env snapshot, structured events - #3031
feat: DAH-4291 invite-to logging improvement: add teardown beacon, env snapshot, structured events#3031fwextensions wants to merge 8 commits into
Conversation
…vents
Improves the shadow-mode human-detection signal and makes the invite-to logs
directly queryable.
Client:
- Beacon a `teardown` signal on pagehide when the page armed (painted + visible)
but unloaded before the interaction/dwell gate completed. This is the common
in-app-webview case (Gmail/Outlook/Facebook), where a real person clicks and
the webview is torn down in well under the 2s dwell - previously
indistinguishable from a scanner prefetch. Uses sendBeacon, which survives
unload.
- Attach a passive env snapshot (webdriver, ua, pointer coarseness, cores,
screen, tz) to every human-verified log. Never used to gate a click - only to
classify the ambiguous "no signal" bucket after the fact. snapshotEnv is fully
defensive so a telemetry read can never throw away the signal it reports.
- Disarm on visibilitychange->hidden so an unload from a non-visible page does
not report a teardown.
Server:
- New InviteToEventLogging concern emits `invite_to.response {json}`: a
greppable prefix plus a JSON payload Papertrail can parse for structured
querying.
- Name the suppression cause (no_action | deadline_passed | language_change |
test_link) instead of collapsing four causes into one message, so "how many
real yeses did we drop?" is a group-by rather than an investigation.
- For deadline_passed, log the comparison in resolved local terms
(deadline_date/today/late_by) instead of a UTC timestamp that has to be
mentally converted through config.time_zone.
- Log request.referrer when language_change fires - it is the one suppression
that can silently eat a legitimate first click, and nothing previously
recorded it.
- Include request_id on every line so the three log sites correlate directly
rather than by timestamp adjacency on a shared dyno.
- Log ok=true/false after the send, since send_invite_to_response returns nil on
both invalid-action and any rescued error - "recording" previously meant
attempted, not recorded.
Deprecated JWT keys (response, applicationNumber) are intentionally omitted from
the new event payloads.
scripts/parse-invite-to-logs.js parses the structured events (including
suppression reasons and env) while keeping the legacy k=v matchers so older
exports still work - verified identical aggregates on the existing export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces structured, JSON-based event logging for “invite-to” response flows (server GET recording and client shadow human-verification), and adds client-side environment snapshots plus a teardown beacon signal to improve post-hoc classification.
Changes:
- Added a shared
InviteToEventLoggingconcern and migrated existing invite-to logs to structuredinvite_to.response {json}lines. - Enhanced client shadow logging with an environment snapshot (
snapshotEnv) and teardown-time delivery viasendBeacon. - Expanded RSpec/Jest coverage to assert new structured log payloads and teardown behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/controllers/invite_to_controller_spec.rb | Adds expectations for new structured logging and suppression reasons. |
| spec/controllers/api/v1/invite_to_response_controller_spec.rb | Updates log assertions to structured event format and includes env snapshot checks. |
| app/javascript/hooks/useAutoRecordInviteToResponse.ts | Attaches env snapshots to logs and sends teardown beacons on pagehide. |
| app/javascript/api/inviteToApiService.ts | Introduces env snapshot collection and sendBeacon logging API. |
| app/javascript/tests/pages/invite-to-apply.test.tsx | Updates mocks for new invite-to API exports. |
| app/javascript/tests/hooks/useAutoRecordInviteToResponse.test.tsx | Adds tests for env payload attachment and teardown beacon behavior. |
| app/javascript/tests/api/inviteToApiService.test.ts | Adds tests for snapshotEnv and beaconHumanVerifiedClick. |
| app/controllers/invite_to_controller.rb | Replaces ad-hoc strings with structured logging; adds suppression/deadline helper logic. |
| app/controllers/concerns/invite_to_event_logging.rb | New concern that emits stable-prefix JSON logs for invite-to events. |
| app/controllers/api/v1/invite_to_response_controller.rb | Migrates shadow log endpoint to structured logging and logs env snapshot. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Allow-list the client `env` snapshot keys instead of permitting a free-form hash. The endpoint is unauthenticated, so an open `env` let anyone write arbitrary keys and unbounded values into the structured event. Values are now restricted to the nine known keys, scalars only, truncated to 256 chars, and the key is omitted entirely when nothing recognized remains. - Handle an unparseable deadline. Time.zone.parse returns nil (rather than raising) for input like "not-a-date", so `deadline_has_passed?` raised NoMethodError and 500'd the page before deadline_terms was ever reached. An unverifiable deadline is now treated as passed - suppressing is the conservative choice - and the structured log carries deadline_raw so the bad value stays visible. - Cover the new branches: unparseable deadline, test_link suppression, each format_duration unit, the parse-raises rescue paths, and the per-field fallback in snapshotEnv when a property getter throws. Line endings on the new concern normalized to CRLF to match the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
app/controllers/invite_to_controller.rb:106
- Logging the full
request.referrercan inadvertently capture query strings/fragments (including tokens/PII, depending on upstream URLs). To reduce exposure, consider logging a scrubbed referrer (e.g., scheme/host/path only, or path + locale segment), or explicitly stripping known sensitive params (liket) before emitting it.
# The one suppression that can silently eat a legitimate first click; capture what
# request.referrer actually was so language_change? can be audited.
referrer: (reason == 'language_change' ? request.referrer : nil),
app/controllers/invite_to_controller.rb:179
- These helper methods are currently public. In Rails controllers, public instance methods can be treated as actions unless hidden, which increases the risk of accidental routing exposure and expands the controller's public surface area. Mark
suppression_reason,deadline_terms, andformat_durationasprivate(or move them below an existingprivatedeclaration) to keep them as internal helpers.
# Names the single reason a GET is not recorded instead of collapsing four
# causes into one branch. Order matches the original `||` precedence: a preview
# link (act blank) is `no_action`.
def suppression_reason(invite_action, response, deadline, is_test)
if invite_action.blank? && response.blank? then 'no_action'
elsif deadline && deadline_has_passed?(deadline) then 'deadline_passed'
elsif language_change? then 'language_change'
elsif is_test then 'test_link'
end
end
# Emits what the code actually compared, in resolved local terms, with a
# near-miss delta: late_by under an hour is a product conversation, under a
# minute is likely a clock/UX issue.
def deadline_terms(deadline, reason)
return {} unless reason == 'deadline_passed' && deadline.present?
# Time.zone.parse returns nil (rather than raising) for input it cannot make
# sense of, e.g. "not-a-date", so nil must be handled explicitly - the rescue
# below would not catch the resulting NoMethodError.
deadline_time = Time.zone.parse(deadline)
return { deadline_raw: deadline } if deadline_time.nil?
{
deadline_date: deadline_time.to_date.to_s,
today: Time.zone.today.to_s,
late_by: format_duration((Time.zone.now - deadline_time).to_i),
}
rescue ArgumentError, TypeError
{ deadline_raw: deadline } # unparseable - surface it rather than crash the log line
end
# Compact human duration ("2m", "3h", "5d") without pulling in a gem.
def format_duration(seconds)
seconds = seconds.abs
return "#{seconds}s" if seconds < 60
return "#{seconds / 60}m" if seconds < 3600
return "#{seconds / 3600}h" if seconds < 86_400
"#{seconds / 86_400}d"
end
app/controllers/invite_to_controller.rb:166
late_byis computed using a wall-clockTimedelta fromTime.zone.nowtodeadline_time. For date-only deadlines (common in this flow), this makes the logged value dependent on time-of-day and can be off by a day around DST transitions (and can make specs like the\"3d\"expectation flaky across DST). If the intent is day-scale reporting for date-only deadlines, compute the delta fromTime.zone.today - deadline_time.to_date(calendar days) and format that, while still preserving a seconds/minutes/hours delta only when the input actually includes a time component.
{
deadline_date: deadline_time.to_date.to_s,
today: Time.zone.today.to_s,
late_by: format_duration((Time.zone.now - deadline_time).to_i),
}
… values - Scrub the referrer before logging it. language_change? only fires when the referrer is another next-steps URL, and those URLs carry the invite JWT in `?t=` - so logging the raw referrer wrote live tokens into the logs. Keep only scheme/host/path, which is all the locale-prefix comparison needs. - Bound every logged value centrally in log_invite_event rather than per call site: strings truncate to 256 chars and non-scalars coerce to a bounded string. Several of these events carry values from unauthenticated endpoints, and doing this in the concern means a newly added field cannot reintroduce the gap. sanitized_env now only allow-lists keys and rejects non-scalars, since truncation is handled centrally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
app/controllers/invite_to_controller.rb:149
- Using
deadline && deadline_has_passed?(deadline)treats an empty-string deadline as present (truthy), which will now suppress asdeadline_passedbecausedeadline_has_passed?returns true on unparseable/nil parses. If blank deadlines should behave like “no deadline provided”, switch this check todeadline.present? && deadline_has_passed?(deadline)to avoid misclassifying empty values.
def suppression_reason(invite_action, response, deadline, is_test)
if invite_action.blank? && response.blank? then 'no_action'
elsif deadline && deadline_has_passed?(deadline) then 'deadline_passed'
elsif language_change? then 'language_change'
elsif is_test then 'test_link'
end
end
app/javascript/hooks/useAutoRecordInviteToResponse.ts:152
beaconHumanVerifiedClickreturns a boolean indicating whether the browser accepted the beacon, but the result is currently ignored. In browsers/environments withoutsendBeacon(or if queuing fails), the teardown signal is silently lost; consider checking the return value and adding a fallback (e.g., a best-effort request usingfetch(..., { keepalive: true })if available, or at minimum a debug-level log/metric) so this failure mode is observable.
const handlePageHide = () => {
if (fired || !armed) return
fired = true
cleanup()
beaconHumanVerifiedClick(buildRecord("teardown"))
}
| # | ||
| # Every recording, suppression, and shadow-mode human-verification emits a single | ||
| # line with a stable prefix and a JSON payload at the end, so Papertrail (and | ||
| # scripts/parse-invite-to-logs.js) can group by `outcome`/`reason` and correlate |
There was a problem hiding this comment.
This is a local script used for offline processing of logs to analyze the bot detection results.
- Use `deadline.present?` rather than truthiness in suppression_reason. Since deadline_has_passed? now reports unparseable input as passed, an empty-string deadline was being logged as `deadline_passed` - a wrong reason in the very logs that exist to make reasons trustworthy. Blank now falls through to the recording path, matching the pre-existing behavior for a nil deadline. - Fall back to the normal request when sendBeacon returns false (unavailable, or the browser refuses to queue). Previously the return value was ignored while `fired` was already set, so a refused beacon silently dropped the signal - the exact failure this feature exists to prevent. - Cover a nested structure sent under an allow-listed env key. The earlier test only nested under a disallowed key, where `slice` did the work, so the non-scalar filter itself was untested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every invite JWT is expected to carry a deadline claim; a missing one is legacy handling from the era when everything travelled in query params and should no longer occur. Behavior is deliberately unchanged - the response is still recorded, since dropping a real "yes" costs an applicant their spot. What changes is visibility. A missing deadline does not just mean we lack a value to log: it means the expiry check never ran at all. A blank deadline short-circuits suppression_reason, and nothing downstream re-checks it, because prepare_submission_fields_invite_to_response ignores the deadline argument whenever an action is present. So a link generator that stopped setting the claim would silently record every late response as on-time, invisibly. The recorded event now carries `deadline_missing: true`. log_invite_event compacts nils, so the key appears only in the anomalous case - it greps to nothing in a healthy system, and is a one-line alert condition otherwise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
There appears to be quite a few changes here, and no review instructions to boot. Also, the abundance of multi-line comments with semantic fluff are a slog to get through. I don't see this getting approved and merged quickly, unless reviewers dedicate a good chunk of time to focus on this PR. I strongly encourage you to strike a better balance of verbosity and clarity, whether that's through tuning your prompts, or editing the LLM output by hand. As a reviewer I want concise human-readable content. Amending and refining LLM output to align with that should be done before a PR is considered ready for review, not during the review process. 🧑 Written by @jimlin-sfgov |
Collapse the multi-line explanatory blocks in the invite-to logging changes to one or two lines each, keeping the reasoning and dropping the justification. Comments only; no logic changes.
|
@jimlin-sfgov shrank the comments by a third and streamlined the PR description. Items 1 and 2 in the review instructions are the more important changes, but the main thing is just making sure the new logging doesn't interfere with the existing server-side response recording. 62% of the PR is tests to get to the 100% qlty threshold. |
|
If I may, I want to jump in with a question. The associated Jira ticket describes the technical problem and the implementation, but not the impact or risk of leaving it unaddressed—and it is marked Critical in priority. Is this is a "drop what you're doing" situation? If it is, then I think this should be a team conversation rather than a side PR. I ask because this PR needs a more thorough review. The code is relatively self-contained, but it extends several controllers and introduces new logging logic, so the approach you've taken here should be validated against our existing patterns and architecture. That level of review takes focused time and attention, so it would displace other things currently being worked on. For next steps, it would be helpful to get alignment on priorities. If moving this forward is critical/time-sensitive, I recommend bringing it to the team and putting it through your standard process. The engineers own the code that goes into prod, and I want them to have the room to look at this properly. |
| @@ -0,0 +1,40 @@ | |||
| # Structured logging for invite-to (I2A/I2I) response events. Every recording, | |||
There was a problem hiding this comment.
thought: It would be nice to have structured logging throughout our Rails app, instead of building out a little module just to serve the needs of your feature. Options include:
|
|
||
| // Passive browser signals attached to every human-verified log. Spoofable, so evidence only - | ||
| // never a gate. Used to tell an in-app-webview human from a headless scanner after the fact. | ||
| export interface EnvSnapshot { |
There was a problem hiding this comment.
question: So my earlier slack comment about utilizing the navigator object was taken into consideration? Have you also considered the PII implications as well? If we combine this digital fingerprint with listing_id and application_id, does that become a privacy violation?
| "" | ||
| ), | ||
| lang: safe(() => navigator.languages?.[0] ?? navigator.language, ""), | ||
| tz: safe(() => Intl.DateTimeFormat().resolvedOptions().timeZone, ""), |
There was a problem hiding this comment.
question: How was this particular set of properties decided on? There are additional ways to get clued in on headless browsers, e.g. navigator.connection.rtt and eval.toString().length
|
|
||
| // Runs inside the fire path, so every lookup is guarded individually - losing the signal to a | ||
| // telemetry error would defeat the point. | ||
| export const snapshotEnv = (): EnvSnapshot => { |
There was a problem hiding this comment.
suggestion: Remove from file, this file is for functions that make API calls.
| type, | ||
| trigger, | ||
| elapsedMs: Date.now() - armedAt, | ||
| env: snapshotEnv(), |
There was a problem hiding this comment.
suggestion: Do not use env, could be confused with environment and environment variables.
|
|
||
| # to_unsafe_h converts nested ActionController::Parameters to HashWithIndifferentAccess, | ||
| # so the non-scalar filter catches them - assert that on an allow-listed key. | ||
| it 'drops a nested structure sent under an allow-listed env key' do |
There was a problem hiding this comment.
suggestion: Do not use env, could be confused with environment and environment variables.
| allow(Rails.logger).to receive(:info) { |msg| logged << msg.to_s } | ||
|
|
||
| post :log_human_verified, params: { | ||
| record: valid_record.merge(env: { ua: { nested: { deep: 'sneaky-value' } }, tz: 'UTC' }), |
There was a problem hiding this comment.
suggestion: Do not use env, could be confused with environment and environment variables.
| expect(event).to include('"tz":"UTC"') | ||
| end | ||
|
|
||
| it 'omits env entirely when no recognized keys are supplied' do |
There was a problem hiding this comment.
suggestion: Do not use env, could be confused with environment and environment variables.
|
|
||
| it 'omits env entirely when no recognized keys are supplied' do | ||
| post :log_human_verified, params: { | ||
| record: valid_record.merge(env: { evil: 'nope' }), |
There was a problem hiding this comment.
suggestion: Do not use env, could be confused with environment and environment variables.
| } | ||
|
|
||
| expect(response).to be_ok | ||
| expect(Rails.logger).not_to have_received(:info).with(a_string_including('"env":')) |
There was a problem hiding this comment.
suggestion: Do not use env, could be confused with environment and environment variables.
|
There are quite a few review comments. Thoughts on adding a |
|
Another thought: it would be helpful for engineers if you could illustrate what you've been seeing in the logs so far, and how those logs motivated you to make this new PR |
Description
Adds diagnostic logging for the invite-to bot detection shipped in #2987: a
pagehidebeacon so a human click in an email in-app webview isn't lost when the page is torn down before the interaction/dwell gate, a passive browser-env snapshot on every signal, and one structuredinvite_to.response {json}log line per event that names why a response was suppressed. No applicant-facing behavior change — recording still happens server-side on GET, and the shadow endpoint still records nothing to Salesforce.Jira ticket
https://sfgovdt.jira.com/browse/DAH-4291
Review instructions
Applies to any environment with the
temp.webapp.inviteToClientRecordingUnleash flag enabled (local or a review app). Flag off = no new client behavior, server logging only.Mint a link (rails console):
/listings/<LISTING_ID>/next-steps?t=<t>, move the mouse, and confirm one log lineinvite_to.response {"...","outcome":"recorded","source":"get","ok":true,...}plus a secondwith
"source":"client_shadow","trigger":"interaction"and anenvobject."trigger":"teardown"— this is the case the PR exists to capture.client_shadowline (a hidden unload is not evidence of a human).deadline: 1.day.ago.to_date.to_sand load it. Expect"outcome":"suppressed","reason":"deadline_passed"withdeadline_date/today/late_by."reason":"language_change"with areferrerthat hasno
?t=token in it.request_idon all lines from one page load matches.PA testing does not apply: there is no user-visible change. Both branches of the flag are exercised by the specs.
Before requesting eng review
Version Control
angularif it contains updates to Angular codetype: TICKET-NUMBER Descriptionformat, useDAH-000if it does not need a ticketurgent: Descriptionformat if it is urgent and does not need a ticketCode quality
Code conventions
.scssstylesheets andui-seedstokens, rather than inline styles or TailwindReview instructions
Request eng review
needs reviewlabelHousing Enggroup to automatically assign reviewers, and/or assign specific engineersBefore merging
Request product acceptance (PA) testing
needs product acceptancelabel)Details: what each change is for
Client
pagehidebefore interaction/dwell sendstrigger: "teardown"vianavigator.sendBeacon, which survives unload wherefetchis cancelled. Confidence ranksinteraction>dwell>teardown.visibilitychange→ hidden, so an unload from a hidden page does not report a teardown — indistinguishable from a background prefetch.webdriver,ua,coarse,cores,mem,screen,lang,tz) on every signal. Evidence only, never a gate: fingerprinting is spoofable and a false positive would reject a real applicant. It exists to classify the ambiguous bucket — ateardownwithwebdriver:falseand anOutlook-iOSUA is a confident webview human; one with a headless UA is not.Server
New
InviteToEventLoggingconcern emits one line per event:invite_to.response {json}, a greppable prefix plus a JSON tail Papertrail can post-process into fields. Values are bounded centrally (256 chars, non-scalars coerced) since these endpoints are unauthenticated, andenvis allow-listed to the nine keys the client sends.reason=names one ofno_action/deadline_passed/language_change/test_link*NOT* recordingmessagedeadline_date/today/late_bylate_byunder a minute is a clock/UX issue, not a late responsereferreronlanguage_changerequest_idon every lineok=true/falsesend_invite_to_responsereturns nil on both invalid-action and any rescued error, so "recording" previously meant attemptedDeprecated JWT keys (
response,applicationNumber) are intentionally omitted from the new payloads.Two things worth knowing
A bug this caught in test, not review.
window.matchMedia?.("…").matchesguards the call but still dereferencesundefined— it would have thrown in exactly the older/webview environments this feature exists to measure.snapshotEnvis now defensive per-property, so a telemetry read can never throw away the signal it reports.ok=is weaker than it reads. It's!result.nil?, which separates the rescued/invalid-action path from success, but on the happy pathsend_invite_to_responsereturns whateversend_messagereturns. Forok=trueto mean "Salesforce accepted it",send_messageneeds to return a real success value — a behavior change rather than logging, so it's not in this PR. Follow-up: DAH-XXXX.Testing
Jest 39/39 and RSpec 57/57 on the touched specs, ESLint and Rubocop clean. New coverage: teardown beacon fired and suppressed-when-hidden,
sendBeacon-unavailable fallback, a throwing env property, unparseable and blank deadlines,test_linksuppression, eachformat_durationunit, oversized field truncation, and a referrer assertion that the JWT never reaches the log.