All files / src/hooks useSearchAnalytics.ts

100% Statements 55/55
100% Branches 14/14
100% Functions 2/2
100% Lines 55/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 921x 1x 1x   1x                         1x 8x 8x 8x   8x 8x 8x 8x 8x 8x 8x 8x   1x                                           1x 1x 9x 9x 9x 9x 9x 9x 8x 8x 8x 9x 4x 4x 1x 1x 1x 3x 3x 9x 2x 2x 2x 2x 2x 9x 2x 2x 2x 2x 2x 2x 9x 9x 9x 9x 9x 9x  
import { onSearchEvent, SearchEvent, type SearchOutcomeDetail } from '@/events/searchEvents';
import { trackEvent } from '@/helpers/analytics';
import { useEffect } from 'react';
 
/**
 * PostHog properties describing how a search run turned out, derived from a
 * terminal search event's {@link SearchOutcomeDetail}.
 * @param outcome - The terminal event's outcome detail.
 * @param searchTerm - The term the search was started with.
 * @returns Non-PII event properties, ready for {@link trackEvent}.
 * @example
 * ```ts
 * outcomeParams({ count: 3, durationMs: 1240, suppliersQueried: 8, suppliersCompleted: 2 }, "acetone");
 * // => { search_term: "acetone", result_count: 3, duration_ms: 1240,
 * //      suppliers_queried: 8, suppliers_completed: 2 }
 * ```
 * @source
 */
function outcomeParams(
  outcome: SearchOutcomeDetail,
  searchTerm: string,
): Record<string, string | number> {
  return {
    search_term: searchTerm,
    result_count: outcome.count,
    duration_ms: outcome.durationMs,
    suppliers_queried: outcome.suppliersQueried,
    suppliers_completed: outcome.suppliersCompleted,
  };
}
 
/**
 * Subscribes to the search-lifecycle event bus and forwards it to PostHog, keeping
 * analytics out of the search hook itself (the producers stay decoupled from
 * consumers). Mirrors the `useBadgeController` hook.
 *
 * - `SearchEvent.STARTED` → `search_query` (with the search term)
 * - `SearchEvent.COMPLETED` → `search_results`, or `search_terminated` when the
 *   stream only drained because the search was aborted
 * - `SearchEvent.ABORTED` / `SearchEvent.FAILED` → `search_terminated`
 *
 * A search that ended early reports `search_terminated` *instead of*
 * `search_results`, so the duration and result-count distributions on
 * `search_results` describe complete runs only. Mount once, near the app root.
 * @returns Nothing.
 * @example
 * ```tsx
 * function App() {
 *   useSearchAnalytics();
 *   // ...
 * }
 * ```
 * @source
 */
export function useSearchAnalytics(): void {
  useEffect(() => {
    // Remember the most recent started query so the terminal events (which carry
    // only the outcome) can attach the term to their event.
    let lastQuery = '';
    const unsubscribers = [
      onSearchEvent(SearchEvent.STARTED, ({ query }) => {
        lastQuery = query.trim();
        void trackEvent('search_query', { search_term: lastQuery });
      }),
      onSearchEvent(SearchEvent.COMPLETED, ({ abortReason, ...outcome }) => {
        const params = outcomeParams(outcome, lastQuery);
        if (abortReason === undefined) {
          void trackEvent('search_results', params);
          return;
        }
        void trackEvent('search_terminated', { ...params, reason: abortReason });
      }),
      onSearchEvent(SearchEvent.ABORTED, ({ reason, ...outcome }) => {
        void trackEvent('search_terminated', {
          ...outcomeParams(outcome, lastQuery),
          reason: reason ?? 'unknown',
        });
      }),
      onSearchEvent(SearchEvent.FAILED, ({ error, ...outcome }) => {
        void trackEvent('search_terminated', {
          ...outcomeParams(outcome, lastQuery),
          reason: 'error',
          ...(error ? { error_message: error } : {}),
        });
      }),
    ];
    return () => {
      for (const unsubscribe of unsubscribers) unsubscribe();
    };
  }, []);
}