de461c2cf8
* feat(support): report both channel outcomes on the feedback breadcrumb support_feedback_submitted recorded only whether the email delivered, so "did the PostHog ticket actually open?" was unanswerable from PostHog. The first time Support shipped, the only way to check was to reproduce the submission with devtools open. Both channels fail silently from the user's side, which is why this is worth instrumenting: email is the delivery guarantee, so the UI shows success even when the ticket failed, and a ticket that never opened leaves nothing in PostHog Support to look at either. Adds `email`, `ticket` and a derived `lost` to the event. `delivered` is kept as-is so any existing insight filtering on it keeps working. ticket: 'unavailable' is deliberately distinct from 'failed'. Unavailable is the expected steady state (Support disabled, analytics disabled, self-hosted); failed means conversations were live and the call still did not land. Collapsing them would make the useful signal unalertable. `lost` is true only when the message reached neither channel, which is the one property worth an alert. Still carries no message body: a test pins that free text never appears in event properties. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(support): run the ticket call concurrently and cap it Addresses CodeRabbit's three findings on #1252. The real one: submitFeedback awaited submitViaTicket AFTER the email, so a hung sendMessage would hold the confirmation dialog open for as long as it hung. The code comment claimed a slow ticket "must never delay" the user while the code did exactly that. Both channels now start together, so the user waits max(email, ticket) rather than the sum, and the ticket is additionally capped at 4s. On expiry it resolves to a new 'timeout' outcome rather than being rounded to 'failed', keeping "conversations were live but slow" distinguishable from "conversations errored". Email still decides ok either way. Also: renamed the lost-state test, which claimed both channels failed while configuring ticket: 'unavailable', and added the genuinely-failed case alongside it plus coverage for the hanging-call path. Reformatted the decision-log entry to the required [date] <decision>: <why> shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
import posthog from 'posthog-js'
|
|
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
|
|
|
export interface SubmitFeedbackInput {
|
|
message: string
|
|
subject?: string
|
|
}
|
|
|
|
/**
|
|
* Delivery channels.
|
|
*
|
|
* 'email' - Resend to the support inbox. The guarantee: it works with no
|
|
* third party beyond the mail provider and needs no analytics.
|
|
* 'ticket' - PostHog Support conversation, linked to the person and their
|
|
* session replay so we can see what they were doing.
|
|
*
|
|
* Recapt used to be the second channel and would report success on its own,
|
|
* masking a failing /api/support/contact. This does NOT repeat that: the
|
|
* result is `ok` only when email actually delivered. A ticket alone is not
|
|
* treated as delivery, because nobody is watching PostHog at 02:00.
|
|
*/
|
|
export type SupportChannel = 'email' | 'ticket'
|
|
|
|
export interface SubmitFeedbackResult {
|
|
ok: boolean
|
|
channels: SupportChannel[]
|
|
error?: string
|
|
}
|
|
|
|
async function submitViaEmail(
|
|
{ message, subject }: SubmitFeedbackInput
|
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
try {
|
|
const res = await fetch('/api/support/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ subject, message }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
|
|
}
|
|
return { ok: true }
|
|
} catch (err) {
|
|
return { ok: false, error: err instanceof Error ? err.message : 'Nätverksfel' }
|
|
}
|
|
}
|
|
|
|
/** Outcome of each channel, for the analytics breadcrumb. */
|
|
type ChannelOutcome = 'ok' | 'failed' | 'unavailable' | 'timeout'
|
|
|
|
/** How long the ticket call may run before we stop waiting on it. The user is
|
|
* waiting on this dialog, and the ticket is a complement, not the delivery. */
|
|
const TICKET_TIMEOUT_MS = 4000
|
|
|
|
/**
|
|
* Breadcrumb on the user's PostHog timeline so a support message is visible
|
|
* next to the session replay that led to it: the genuinely useful half of what
|
|
* the Recapt channel provided. NOT a delivery channel, and deliberately
|
|
* carries no message body: free text is user content and would be PII in an
|
|
* event property. Email remains the only thing that actually delivers.
|
|
*
|
|
* Both channels are reported, because both fail silently from the user's side.
|
|
* A ticket that never opened is invisible in the UI (email is the guarantee,
|
|
* so the user still sees success) and invisible in PostHog Support (no ticket
|
|
* exists to look at). Without `ticket` here, the only way to answer "did the
|
|
* ticket open?" is to reproduce it with devtools open, which is what happened
|
|
* the first time this shipped.
|
|
*
|
|
* 'unavailable' is kept distinct from 'failed' on purpose: unavailable is the
|
|
* expected steady state when Support is off or analytics is disabled, whereas
|
|
* failed means conversations were live and the call still did not land. Only
|
|
* the second is worth alerting on.
|
|
*/
|
|
function noteInAnalytics(
|
|
{ subject }: SubmitFeedbackInput,
|
|
outcomes: { email: boolean; ticket: ChannelOutcome }
|
|
): void {
|
|
if (!isAnalyticsEnabled()) return
|
|
try {
|
|
posthog.capture('support_feedback_submitted', {
|
|
subject: subject ?? null,
|
|
// Kept for continuity: existing insights filter on `delivered`.
|
|
delivered: outcomes.email,
|
|
email: outcomes.email ? 'ok' : 'failed',
|
|
ticket: outcomes.ticket,
|
|
// True only when the user's message reached neither channel. This is the
|
|
// one that deserves an alert.
|
|
lost: !outcomes.email && outcomes.ticket !== 'ok',
|
|
})
|
|
} catch {
|
|
// Telemetry must never affect whether the user's message went out.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a PostHog Support ticket alongside the email.
|
|
*
|
|
* Unlike the analytics breadcrumb this DOES carry the message body: a support
|
|
* ticket the user deliberately wrote is the one place their words are the
|
|
* point. That makes tickets a distinct processing purpose from analytics, so
|
|
* it is declared separately in .compliance/ropa.yaml and on the privacy page.
|
|
*
|
|
* Never throws and never blocks: if conversations are unavailable (support
|
|
* disabled, no analytics, older SDK) the user still gets the email path.
|
|
*/
|
|
async function submitViaTicket({ message, subject }: SubmitFeedbackInput): Promise<ChannelOutcome> {
|
|
if (!isAnalyticsEnabled()) return 'unavailable'
|
|
try {
|
|
const conversations = posthog.conversations
|
|
if (!conversations?.isAvailable?.()) return 'unavailable'
|
|
await conversations.sendMessage(composeTicketBody(message, subject))
|
|
return 'ok'
|
|
} catch {
|
|
return 'failed'
|
|
}
|
|
}
|
|
|
|
function composeTicketBody(message: string, subject?: string): string {
|
|
return subject ? `[${subject}]\n\n${message}` : message
|
|
}
|
|
|
|
/** Resolve to `fallback` if the promise has not settled in time. Never rejects:
|
|
* submitViaTicket already swallows its own errors. */
|
|
function withTimeout(
|
|
promise: Promise<ChannelOutcome>,
|
|
ms: number,
|
|
fallback: ChannelOutcome
|
|
): Promise<ChannelOutcome> {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => resolve(fallback), ms)
|
|
void promise.then((value) => {
|
|
clearTimeout(timer)
|
|
resolve(value)
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
|
|
// Both channels start together, so the user waits max(email, ticket) rather
|
|
// than the sum. Email is the delivery guarantee and decides `ok`; the ticket
|
|
// is a complement, so it is additionally capped: a hung sendMessage must
|
|
// never hold the confirmation dialog open. It resolves to 'timeout' instead,
|
|
// which is reported rather than silently rounded to 'failed'.
|
|
const ticketPromise = submitViaTicket(input)
|
|
const emailResult = await submitViaEmail(input)
|
|
const ticket = await withTimeout(ticketPromise, TICKET_TIMEOUT_MS, 'timeout')
|
|
|
|
noteInAnalytics(input, { email: emailResult.ok, ticket })
|
|
|
|
if (emailResult.ok) {
|
|
return { ok: true, channels: ticket === 'ok' ? ['email', 'ticket'] : ['email'] }
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
channels: ticket === 'ok' ? ['ticket'] : [],
|
|
error: emailResult.error,
|
|
}
|
|
}
|