analytics.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. 'use client'
  2. import posthog from 'posthog-js'
  3. /**
  4. * Product analytics events, and the only way the app sends them.
  5. *
  6. * PostHog runs in cloud mode and on the demo instance; a self-hosted install
  7. * never starts it, and then nothing here sends anything.
  8. *
  9. * Calls made before PostHog has started are held and sent once it has. The
  10. * provider starts it in an effect in the root layout, and React runs a page's
  11. * effects before its layout's, so an event fired when a page mounts would
  12. * otherwise be dropped on every full page load.
  13. *
  14. * Names follow PostHog's guide: lowercase snake case, fixed strings,
  15. * `category:object_action` with a present-tense verb. Anything that varies
  16. * goes in a property, never in the name.
  17. */
  18. export type AnalyticsEvent =
  19. /** A work order opened. */
  20. | 'work_order:page_view'
  21. /** An account made with email and password. Google sign-ups show as workshop:create. */
  22. | 'account:sign_up'
  23. /** The onboarding form made the person's first workshop, with or without sample data. */
  24. | 'workshop:create'
  25. /** The guided setup opened, with what the browser suggested: country, languages, time zone. */
  26. | 'workshop:setup_start'
  27. /**
  28. * The setup moved between steps: `action` is continue, skip, back or jump
  29. * (the step bar or an Edit link), with the seconds spent on the step left.
  30. */
  31. | 'workshop:setup_step'
  32. /** Continue pressed while the step was incomplete; `reason` names what was missing. */
  33. | 'workshop:setup_blocked'
  34. /** The language changed: `via` the language field, a country, or the customer language. */
  35. | 'workshop:setup_language_change'
  36. /** The owner picked a country, replacing the detected one or another pick. */
  37. | 'workshop:setup_country_change'
  38. /** A suggested regional value opened for editing; `field` says which. */
  39. | 'workshop:setup_suggestion_edit'
  40. /** The invoice preview opened at full size. */
  41. | 'workshop:setup_preview_open'
  42. /** Create workshop failed; the error dialog opened. */
  43. | 'workshop:setup_create_fail'
  44. /** A customer saved from the customer form. */
  45. | 'customer:create'
  46. /** An invoice, quote or inspection emailed to the customer. */
  47. | 'document:email_send'
  48. /** An action the plan refused; the upgrade dialog opened. */
  49. | 'plan:gate_show'
  50. /** The upgrade dialog's button to the plans. */
  51. | 'plan:upgrade_click'
  52. /**
  53. * A fault code opened, on its page or in the sheet beside a job: `kind` obd or make,
  54. * `make` for a make's own code, and `found`, whether we had a meaning for it. The misses
  55. * say which codes and makes to add next.
  56. */
  57. | 'fault_code:page_view'
  58. /** The lookup box submitted: `kind` code, make_code or words. The words are never sent. */
  59. | 'fault_code:search'
  60. /** A make's own meaning opened from a standard code's page (Ford's P1000 from P1000). */
  61. | 'fault_code:make_code_open'
  62. /** Support asked about the code on screen. */
  63. | 'fault_code:support_open'
  64. /** A wrong meaning reported, with its `reason`. */
  65. | 'fault_code:report_send'
  66. /** A fix shared for review, with how many photos and whether any came from a phone. */
  67. | 'fault_code:fix_share'
  68. /** Research a fix pressed. */
  69. | 'fault_code:research_start'
  70. /** What research came back with: `outcome` pending, published, missed, busy, limited or failed. */
  71. | 'fault_code:research_result'
  72. /** A researched fix rated, `helpful` or not. */
  73. | 'fault_code:research_rate'
  74. /** A shared waveform opened full size, `kind` good or faulty. */
  75. | 'fault_code:waveform_open'
  76. /** Another waveform drawn under the open one to compare. */
  77. | 'fault_code:waveform_compare'
  78. /** A waveform's file downloaded: `role` psdata, csv or pssettings. */
  79. | 'fault_code:waveform_download'
  80. /** A waveform shared for review, with which files it carried. */
  81. | 'fault_code:waveform_share'
  82. /** Help us improve opened, and sent with its `topic`. */
  83. | 'fault_code:improve_open'
  84. | 'fault_code:improve_send'
  85. /** The import dialog opened. */
  86. | 'fault_code:import_open'
  87. /** An example file downloaded: `format` xlsx, csv or json. */
  88. | 'fault_code:import_example_download'
  89. /** A file checked: its `make`, `file_type` and how many rows were new, changes, the same or problems. */
  90. | 'fault_code:import_check'
  91. /** An import sent for review: its `make` and how many `codes`. */
  92. | 'fault_code:import_send'
  93. type Properties = Record<string, string | number | boolean | null>
  94. type Pending =
  95. | { kind: 'capture'; event: AnalyticsEvent; properties?: Properties }
  96. | { kind: 'exception'; error: unknown; properties?: Properties }
  97. | { kind: 'register'; properties: Properties }
  98. | { kind: 'unregister'; names: string[] }
  99. | { kind: 'identify'; userId: string; organizationId: string }
  100. | { kind: 'reset' }
  101. /** A page opened and closed many times before PostHog starts, or on an install that never does. */
  102. const MAX_PENDING = 20
  103. let ready = false
  104. const pending: Pending[] = []
  105. function hold(item: Pending) {
  106. if (pending.length < MAX_PENDING) pending.push(item)
  107. }
  108. /** Sends one event, or holds it until PostHog has started. */
  109. export function track(event: AnalyticsEvent, properties?: Properties): void {
  110. if (ready) posthog.capture(event, properties)
  111. else hold({ kind: 'capture', event, properties })
  112. }
  113. /**
  114. * Sends an error a person was shown, with its stack, or holds it until
  115. * PostHog has started.
  116. *
  117. * For errors the app catches itself, the error pages above all. PostHog's own
  118. * exception capture listens for uncaught errors, and an error page is React
  119. * catching one, so without this nobody learns which page broke or why.
  120. */
  121. export function reportError(error: unknown, properties?: Properties): void {
  122. if (ready) posthog.captureException(error, properties)
  123. else hold({ kind: 'exception', error, properties })
  124. }
  125. /**
  126. * Attaches properties to every later event from this browser, autocaptured
  127. * clicks and page views included (PostHog's super properties). For facts
  128. * about the browser rather than about one action: the setup step it is on, say.
  129. */
  130. export function registerAnalyticsProperties(properties: Properties): void {
  131. if (ready) posthog.register(properties)
  132. else hold({ kind: 'register', properties })
  133. }
  134. /** Stops attaching properties registered with registerAnalyticsProperties. */
  135. export function unregisterAnalyticsProperties(names: string[]): void {
  136. if (ready) for (const name of names) posthog.unregister(name)
  137. else hold({ kind: 'unregister', names })
  138. }
  139. /**
  140. * Ties this browser to the signed-in account and its workshop.
  141. *
  142. * Without it every browser was a separate person, so someone who signed up
  143. * on a laptop and came back on a phone counted as one person who left and
  144. * another who arrived. Only ids go to PostHog: no name, no email. The
  145. * workshop is a group, so a workshop's activity can be counted whoever in it
  146. * did the work.
  147. */
  148. export function identifyUser(userId: string, organizationId: string): void {
  149. if (ready) sendIdentify(userId, organizationId)
  150. else hold({ kind: 'identify', userId, organizationId })
  151. }
  152. /** Forgets the account on sign-out, so the next person on this browser is not merged into it. */
  153. export function resetAnalytics(): void {
  154. if (ready) posthog.reset()
  155. else hold({ kind: 'reset' })
  156. }
  157. function sendIdentify(userId: string, organizationId: string) {
  158. // Already this person: identify again would only add a request per page.
  159. if (posthog.get_distinct_id?.() !== userId) posthog.identify(userId)
  160. posthog.group('organization', organizationId)
  161. }
  162. /** Called by the provider once PostHog has started: sends what was held, in order. */
  163. export function analyticsStarted(): void {
  164. ready = true
  165. for (const item of pending.splice(0)) {
  166. if (item.kind === 'register') posthog.register(item.properties)
  167. else if (item.kind === 'unregister') for (const name of item.names) posthog.unregister(name)
  168. else if (item.kind === 'identify') sendIdentify(item.userId, item.organizationId)
  169. else if (item.kind === 'reset') posthog.reset()
  170. else if (item.kind === 'exception') posthog.captureException(item.error, item.properties)
  171. else posthog.capture(item.event, item.properties)
  172. }
  173. }