Guide

Feedback

The people who use your app can tell you about it from inside it. A shake, or a control of your own, opens a form the SDK draws — Report a problem, Suggest an improvement, Ask a question — with a picture of the screen at the moment they asked, pictures they add, and a way to reply. Each report becomes a numbered issue beside your crashes. Reports from a browser are the report page & widget.

swiftMyApp.swift
import PharenSDK

// Grant content beside the purposes you already pass…
Pharen.start(granting: [.telemetry, .behavioral, .content])

// …or alone, with the crash handlers off, for feedback and nothing else (the last section explains the block).
Pharen.start(granting: [.content]) { config in
    config.crashReporting.enabled = false
}

Opening the sheet

A shake. By default, a device shake presents the sheet: a compact picker asking what kind of report this is, then the form for the kind chosen, localized in the language the reader uses. The picture of the screen is taken before any of it appears, so it never shows the sheet itself. shake.enabled is the switch, set in the start block. It is compiled into the build, and the served configuration can only narrow it: a remote false stops the gesture at the next shake, and a remote true never turns it on for a build that compiled it off. An app that wants shake only in its internal builds sets it from its own build configuration.

swift
Pharen.start(granting: [.content]) { config in
    config.shake.enabled = false   // you will open the sheet from your own control
}

Your own control. A settings row, a Help button: presentFeedbackSheet() opens the same flow, whether or not shake is on. It is the same call as feedback.presentSheet() on the module.

swift
Pharen.shared?.presentFeedbackSheet()                  // the picker, then the form
Pharen.shared?.presentFeedbackSheet(category: .bug)    // straight to the form for one kind

One kind directly. presentSheet(category:) skips the picker, for a control whose label has already asked the question — a Report a problem row has no use for a menu offering two other things. The kinds are .bug, .idea and .question; a fourth, .other, is a report nobody classified, which the picker never offers. The kind rides the report as its category, which is what the console groups reports by.

A report about one thing. presentReport(category:context:) on the module, or presentFeedbackReport(category:context:) on Pharen, opens the form for one kind with context from your app above the text field — read-only and selectable, under the heading Sent with your report — and sends it with the report: a result your app computed, an item it flagged. The context is at most 2,000 characters and is refused whole; longer, empty or only whitespace opens nothing. Put no identifiers, file paths or absolute dates in it, because it is stored as the person's own words are.

swift
@MainActor func flag(_ item: Item) {
    guard let pharen = Pharen.shared else { return }
    let details = "engine 4, strategy " + item.strategy
    switch pharen.presentFeedbackReport(category: .bug, context: details) {
    case .presented, .refused:
        break                  // the form is up, or says itself why nothing can be sent
    case .unavailable, .invalidContext:
        showCouldNotOpen()     // nothing opened
    case .ineligible:
        break                  // answered only by the survey calls, never by this one
    @unknown default:
        break                  // an answer a later SDK adds
    }
}

Every call that presents a form is main-actor only, presents in your call's turn, and answers what happened: .presented (the form is up), .refused(reason) (the form opened on a refusal and says why in place of its field, so your app has nothing to add), .unavailable (nothing opened: the flow is already on screen, another of the SDK's surfaces is, or there is no window to present from) or .invalidContext (nothing opened: the context was refused); .ineligible(reason) is answered only by the survey calls below. None of them says a report was sent. The sheet is the only way a report reaches Pharen — there is no call that submits from a form your app draws — and it shows Sent itself, only once the report is accepted, so your app shows no confirmation of its own.

On a plan without white-label, every step of the sheet ends in a plain-text Powered by Pharen footnote, drawn only once a fetched configuration says so; before the first configuration arrives, it shows none.

What a report carries

  • The kind — the category the person chose, or the one your call named.
  • The words — stored verbatim, behind an authenticated read, and never logged. Disclose their handling to your users.
  • A picture of the screen at the moment of the trigger, taken before the sheet appears. It shows in the form as a thumbnail with its own Remove control, so the person decides whether it goes. Mark a view that must never appear in one with .pharenPrivacyMask() in SwiftUI or pharenPrivacyMask = .blockCapture on a UIView: while a marked view is on screen no picture is taken, and the report records why.
  • Pictures the person adds from their library — up to five pictures in all, the picture of the screen among them (fewer if you lower the limit), each held to a size the SDK bounds, each with its own Remove.
  • A way to reach them — the optional address, below.
  • Diagnostics the SDK gathers, when telemetry is granted as well: the recent network requests — the SDK's own calls, labeled, and the ones your app routed to it — breadcrumbs and app context. These ride telemetry, not content, so a report from a feedback-only app carries none of them.

The reporter's address

The form asks for an email address so you can answer, and this is on by default: feedback.contact.mode is .optional, which asks and accepts a blank. .required will not send a report without one; .hidden does not ask, does not pre-fill and sends nothing. Set it in the start block, or as the PharenFeedbackContact Info.plist key — optional, required or hidden — for an app configured without code.

swift
Pharen.start(granting: [.content]) { config in
    config.feedback.contact.mode = .hidden   // the report form does not ask for an address
}

Taking the default means your app collects an email address, stored beside the install id: the SDK's privacy manifest declares it, and your App Privacy answers name it. The address never rides the event stream. It travels by its own call once the report has been accepted, and a report is never delayed or lost because that call failed. The field pre-fills from the email trait you passed to identify, while identity is granted, and otherwise from the last address typed on that device by the same person, kept locally; clearing the field is what forgets it. The served configuration can move an asking app between optional and required, and can hide the field. It can never widen a build that compiled .hidden back into asking.

The content purpose

A report rides the content purpose, and so do the pictures on it and a survey answer. Like every purpose it is opt-in — Privacy & consent — and granting it is a line in your code: at start, as above, or at runtime from your own consent flow with Pharen.shared?.consent.grant(.content). Withdrawing it forgets the address the install remembered.

Without it the sheet still opens, and says so in place of the form: Feedback isn't collected in this app — check the app's privacy settings. A shake opens that same sheet. Read feedback.reportRefusal before offering a way to report, so your own UI can say reports are off instead of showing a button that cannot send. It is nil when a report would be accepted. Otherwise it is read in a fixed order, and a missing purpose comes last: .collectionOff when the SDK is switched off, by its compiled kill switch or a remote one, or when Pharen has stopped serving this install — your organization's service stopped or paused, its ingest key revoked, or the SDK version it runs no longer supported; .withheld when reports are not being provided to this install right now; and only then .consentMissing when content is not granted — so an install that is off or withheld answers that, whether or not content is granted. Each refusal carries the sentence the sheet shows, in the reader's language, as message.

Surveys

The SDK also draws two surveys, presentNPS() and presentCSAT() on feedback, shown only under a policy you serve (feedback.nps, feedback.csat), whose enabled is the only switch. Answers ride content; the form's show and dismissal ride behavioral. A call answers .ineligible(reason) with the first rule of the policy that said no. The policy's fields are in the Feedback guide that ships with the package.

Feedback alongside another crash reporter

An app can take the report sheet and nothing else, with its crash and observability stack untouched. Two settings and two facts about the SDK's own behavior make that true:

swiftMyApp.swift
import PharenSDK

Pharen.start(granting: [.content]) { config in
    config.crashReporting.enabled = false   // installs no crash or error handlers
    config.shake.enabled = true             // the default; false if you open the sheet yourself
}
  • crashReporting.enabled = false leaves the crash and error handlers uninstalled and uploads no pending report. It is compiled only — a handler installed before any configuration is fetched cannot be switched by one — so no served document turns it back on. An app that keeps the handlers on beside another reporter starts that reporter first: Pharen's handlers chain to whatever was installed before them.
  • shake.enabled decides whether a shake opens the sheet and nothing else. Leave it on, or turn it off and open the sheet from your own control.
  • The network collector observes; it never intercepts. The SDK does not hook URLSession. Its recent-request window holds the SDK's own calls to Pharen, labeled as such, and otherwise only what your app hands to diagnostics.recordNetwork(metrics:) or routes through PharenURLSessionDelegate; hand it nothing and the window holds the SDK's own calls alone. The trace rides telemetry, so a feedback-only app sends none of it either way.
  • The system diagnostics subscriber has no switch. The SDK subscribes to the system's diagnostic reports (MetricKit) at start, for hangs and for the deaths no handler sees. What it would report rides telemetry, so without that purpose it emits nothing.

Where a report lands

Each report becomes an issue with a number, in the same list as your crash issues, with its pictures and diagnostics attached. The console lists them under Feedback. Over MCP, pharen_issues lists them and pharen_issue reads one, with the words only on a key minted with the report_text grant — Connect your agent.