Files
flights_web/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md
T

18 KiB

Phase 1G-analytics — Analytics Facade + Stub Adapters Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the analytics facade — a test-observable event sink, four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace), a createAnalytics() factory that fans out track/page calls to enabled adapters with consent gating, plus the <AnalyticsLoader> component and useAnalytics() hook — so that 1F-layout and all downstream features can emit analytics events with analytics.track("search.submit", { query }).

Architecture: types.ts is already seeded (1A-1) with AnalyticsProviders, AnalyticsProps, AnalyticsEvent, Analytics, and AnalyticsAdapter. Each stub adapter emits AnalyticsEvent records to a shared sink (sink.ts) for test observability. facade.ts accepts enabled providers + consent flags and fans out to adapters. In production, the sink is a no-op ring buffer; in test, getRecordedEvents() / resetEvents() allow assertions. Real vendor scripts replace the stubs in Phase 2A after A7 resolves.

Tech Stack: No new dependencies. Stubs use no vendor SDKs.

Prerequisites: 1A-1 (skeleton + types.ts seeded), 1A-3 (ESLint boundaries), 1G-logger (Logger types for facade logging).


File structure

File Responsibility Task
src/observability/analytics/sink.ts Test-observable event sink 1
src/observability/analytics/sink.test.ts Tests 1
src/observability/analytics/adapters/metrica.ts Yandex.Metrica stub adapter 2
src/observability/analytics/adapters/ctm.ts CTM stub adapter 2
src/observability/analytics/adapters/variocube.ts Variocube stub adapter 2
src/observability/analytics/adapters/dynatrace.ts Dynatrace stub adapter 2
src/observability/analytics/facade.ts createAnalytics() factory 3
src/observability/analytics/facade.test.ts Tests 3
src/observability/analytics/loader.tsx <AnalyticsLoader> component 4
src/observability/analytics/provider.tsx useAnalytics() hook 5

Task 1 — TDD sink.ts

Files:

  • Create: src/observability/analytics/sink.ts

  • Create: src/observability/analytics/sink.test.ts

  • Step 1: Write failing tests

Create src/observability/analytics/sink.test.ts:

import { describe, expect, it, beforeEach } from "vitest";
import { emitEvent, getRecordedEvents, resetEvents } from "./sink.js";
import type { AnalyticsEvent } from "./types.js";

describe("analytics sink", () => {
  beforeEach(() => {
    resetEvents();
  });

  it("records emitted events", () => {
    const event: AnalyticsEvent = {
      kind: "track",
      name: "test.click",
      props: { button: "cta" },
      provider: "metrica",
      ts: new Date().toISOString(),
    };

    emitEvent(event);
    expect(getRecordedEvents()).toHaveLength(1);
    expect(getRecordedEvents()[0]).toEqual(event);
  });

  it("records multiple events in order", () => {
    emitEvent({ kind: "track", name: "a", props: {}, provider: "ctm", ts: "t1" });
    emitEvent({ kind: "page", name: "/home", props: {}, provider: "dynatrace", ts: "t2" });

    const events = getRecordedEvents();
    expect(events).toHaveLength(2);
    expect(events[0]?.name).toBe("a");
    expect(events[1]?.name).toBe("/home");
  });

  it("resetEvents clears all recorded events", () => {
    emitEvent({ kind: "track", name: "x", props: {}, provider: "variocube", ts: "t" });
    expect(getRecordedEvents()).toHaveLength(1);
    resetEvents();
    expect(getRecordedEvents()).toHaveLength(0);
  });
});
  • Step 2: Run — MUST FAIL
pnpm test src/observability/analytics/sink
  • Step 3: Write implementation

Create src/observability/analytics/sink.ts:

import type { AnalyticsEvent } from "./types.js";

let events: AnalyticsEvent[] = [];

/**
 * Emit an analytics event to the test-observable sink.
 * In production, this is a no-op ring buffer (capped to prevent memory leaks).
 * In test, events are retained for assertion via getRecordedEvents().
 */
export function emitEvent(event: AnalyticsEvent): void {
  events.push(event);

  // Ring buffer: cap at 1000 events to prevent unbounded growth
  if (events.length > 1000) {
    events = events.slice(-500);
  }
}

/** Returns all recorded events (for test assertions). */
export function getRecordedEvents(): readonly AnalyticsEvent[] {
  return events;
}

/** Clears all recorded events (for test teardown). */
export function resetEvents(): void {
  events = [];
}
  • Step 4: Run — ALL MUST PASS
pnpm test src/observability/analytics/sink
  • Step 5: Typecheck + lint, commit
pnpm typecheck && pnpm lint
git add src/observability/analytics/sink.ts src/observability/analytics/sink.test.ts
git commit -m "Add test-observable analytics event sink"

Task 2 — Create 4 stub adapters (no TDD)

Files:

  • Create: src/observability/analytics/adapters/metrica.ts

  • Create: src/observability/analytics/adapters/ctm.ts

  • Create: src/observability/analytics/adapters/variocube.ts

  • Create: src/observability/analytics/adapters/dynatrace.ts

  • Step 1: Write all four adapters

Each adapter follows the same pattern. Create src/observability/analytics/adapters/metrica.ts:

import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";

export class MetricaAdapter implements AnalyticsAdapter {
  readonly name = "metrica";

  async load(): Promise<void> {
    // Stub: real Yandex.Metrica script loads in Phase 2A (after A7 resolves)
  }

  track(event: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
  }

  page(url: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
  }
}

Create src/observability/analytics/adapters/ctm.ts:

import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";

export class CtmAdapter implements AnalyticsAdapter {
  readonly name = "ctm";

  async load(): Promise<void> {
    // Stub: real CTM script loads in Phase 2A
  }

  track(event: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
  }

  page(url: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
  }
}

Create src/observability/analytics/adapters/variocube.ts:

import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";

export class VariocubeAdapter implements AnalyticsAdapter {
  readonly name = "variocube";

  async load(): Promise<void> {
    // Stub: real Variocube script loads in Phase 2A
  }

  track(event: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
  }

  page(url: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
  }
}

Create src/observability/analytics/adapters/dynatrace.ts:

import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";

export class DynatraceAdapter implements AnalyticsAdapter {
  readonly name = "dynatrace";

  async load(): Promise<void> {
    // Stub: real Dynatrace (Key-Astrom) script loads in Phase 2A
  }

  track(event: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
  }

  page(url: string, props: AnalyticsProps = {}): void {
    emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
  }
}
  • Step 2: Typecheck + lint, commit
pnpm typecheck && pnpm lint
git add src/observability/analytics/adapters/
git commit -m "Add four stub analytics adapters (metrica, ctm, variocube, dynatrace)"

Task 3 — TDD facade.ts

Files:

  • Create: src/observability/analytics/facade.ts

  • Create: src/observability/analytics/facade.test.ts

  • Step 1: Write failing tests

Create src/observability/analytics/facade.test.ts:

import { describe, expect, it, beforeEach, vi } from "vitest";
import { createAnalytics } from "./facade.js";
import { getRecordedEvents, resetEvents } from "./sink.js";
import type { Logger } from "@/observability/logger/types";

function mockLogger(): Logger {
  return {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    child: vi.fn(() => mockLogger()),
  };
}

describe("createAnalytics", () => {
  beforeEach(() => {
    resetEvents();
  });

  it("fans out track() to all 4 enabled adapters", () => {
    const analytics = createAnalytics({
      enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
      consent: { analytics: true, telemetry: true },
      logger: mockLogger(),
    });

    analytics.track("test.event", { key: "value" });

    const events = getRecordedEvents();
    expect(events).toHaveLength(4);

    const providers = events.map((e) => e.provider).sort();
    expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]);

    for (const event of events) {
      expect(event.kind).toBe("track");
      expect(event.name).toBe("test.event");
      expect(event.props).toEqual({ key: "value" });
    }
  });

  it("fans out page() to all 4 enabled adapters", () => {
    const analytics = createAnalytics({
      enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
      consent: { analytics: true, telemetry: true },
      logger: mockLogger(),
    });

    analytics.page("/ru/online-board");

    const events = getRecordedEvents();
    expect(events).toHaveLength(4);

    for (const event of events) {
      expect(event.kind).toBe("page");
      expect(event.name).toBe("/ru/online-board");
    }
  });

  it("consent.analytics = false short-circuits before any adapter is invoked", () => {
    const analytics = createAnalytics({
      enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
      consent: { analytics: false, telemetry: true },
      logger: mockLogger(),
    });

    analytics.track("should.not.emit");
    analytics.page("/should/not/emit");

    expect(getRecordedEvents()).toHaveLength(0);
  });

  it("disabled adapter is not invoked", () => {
    const analytics = createAnalytics({
      enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true },
      consent: { analytics: true, telemetry: true },
      logger: mockLogger(),
    });

    analytics.track("partial.event");

    const events = getRecordedEvents();
    expect(events).toHaveLength(2);

    const providers = events.map((e) => e.provider).sort();
    expect(providers).toEqual(["dynatrace", "metrica"]);
  });
});
  • Step 2: Run — MUST FAIL
pnpm test src/observability/analytics/facade
  • Step 3: Write implementation

Create src/observability/analytics/facade.ts:

import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js";
import type { Logger } from "@/observability/logger/types";
import { MetricaAdapter } from "./adapters/metrica.js";
import { CtmAdapter } from "./adapters/ctm.js";
import { VariocubeAdapter } from "./adapters/variocube.js";
import { DynatraceAdapter } from "./adapters/dynatrace.js";

export interface CreateAnalyticsOptions {
  enabled: AnalyticsProviders;
  consent: { analytics: boolean; telemetry: boolean };
  logger: Logger;
}

const NOOP_ANALYTICS: Analytics = {
  track() {},
  page() {},
};

/**
 * Creates an Analytics instance that fans out track/page calls to enabled adapters.
 * If consent.analytics is false, returns a no-op (short-circuit before any adapter).
 */
export function createAnalytics(options: CreateAnalyticsOptions): Analytics {
  const { enabled, consent, logger } = options;

  if (!consent.analytics) {
    logger.debug("analytics consent denied, returning no-op");
    return NOOP_ANALYTICS;
  }

  const adapters: AnalyticsAdapter[] = [];
  if (enabled.metrica) adapters.push(new MetricaAdapter());
  if (enabled.ctm) adapters.push(new CtmAdapter());
  if (enabled.variocube) adapters.push(new VariocubeAdapter());
  if (enabled.dynatrace) adapters.push(new DynatraceAdapter());

  if (adapters.length === 0) {
    logger.debug("no analytics adapters enabled, returning no-op");
    return NOOP_ANALYTICS;
  }

  return {
    track(event: string, props: AnalyticsProps = {}): void {
      for (const adapter of adapters) {
        try {
          adapter.track(event, props);
        } catch (err) {
          logger.error("analytics adapter track failed", { provider: adapter.name, err: err as Error });
        }
      }
    },

    page(url: string, props: AnalyticsProps = {}): void {
      for (const adapter of adapters) {
        try {
          adapter.page(url, props);
        } catch (err) {
          logger.error("analytics adapter page failed", { provider: adapter.name, err: err as Error });
        }
      }
    },
  };
}
  • Step 4: Run — ALL MUST PASS
pnpm test src/observability/analytics/facade
  • Step 5: Typecheck + lint, commit
pnpm typecheck && pnpm lint
git add src/observability/analytics/facade.ts src/observability/analytics/facade.test.ts
git commit -m "Add analytics facade with adapter fan-out and consent gating"

Task 4 — Create loader.tsx (no TDD)

Files:

  • Create: src/observability/analytics/loader.tsx

  • Step 1: Write implementation

Create src/observability/analytics/loader.tsx:

"use client";

import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import type { Analytics, AnalyticsProviders } from "./types.js";
import type { Logger } from "@/observability/logger/types";
import { createAnalytics } from "./facade.js";
import { AnalyticsContext } from "./provider.js";

export interface AnalyticsLoaderProps {
  enabled: AnalyticsProviders;
  consent: { analytics: boolean; telemetry: boolean };
  logger: Logger;
  children: ReactNode;
}

/**
 * Mounts in the root layout. Waits for idle callback, then initializes
 * analytics adapters and provides the Analytics instance to the tree.
 */
export function AnalyticsLoader({
  enabled,
  consent,
  logger,
  children,
}: AnalyticsLoaderProps): JSX.Element {
  const [analytics, setAnalytics] = useState<Analytics | null>(null);
  const initRef = useRef(false);

  useEffect(() => {
    if (initRef.current) return;
    initRef.current = true;

    const init = () => {
      const instance = createAnalytics({ enabled, consent, logger });
      setAnalytics(instance);
    };

    if (typeof window !== "undefined" && "requestIdleCallback" in window) {
      (window as any).requestIdleCallback(init);
    } else {
      // Fallback for environments without requestIdleCallback
      setTimeout(init, 1);
    }
  }, [enabled, consent, logger]);

  return (
    <AnalyticsContext.Provider value={analytics}>
      {children}
    </AnalyticsContext.Provider>
  );
}
  • Step 2: Typecheck + lint, commit
pnpm typecheck && pnpm lint
git add src/observability/analytics/loader.tsx
git commit -m "Add AnalyticsLoader component with idle-callback initialization"

Task 5 — Create provider.tsx (no TDD)

Files:

  • Create: src/observability/analytics/provider.tsx

  • Step 1: Write implementation

Create src/observability/analytics/provider.tsx:

import { createContext, useContext } from "react";
import type { Analytics } from "./types.js";

const NOOP_ANALYTICS: Analytics = {
  track() {},
  page() {},
};

/**
 * React context for the Analytics instance.
 * Exported for use by AnalyticsLoader (which sets the provider value).
 */
export const AnalyticsContext = createContext<Analytics | null>(null);

/**
 * Returns the Analytics instance from context.
 * Server-side and before AnalyticsLoader initializes: returns NoopAnalytics.
 * Client-side after init: returns the real facade instance.
 */
export function useAnalytics(): Analytics {
  const analytics = useContext(AnalyticsContext);
  return analytics ?? NOOP_ANALYTICS;
}
  • Step 2: Typecheck + lint, commit
pnpm typecheck && pnpm lint
git add src/observability/analytics/provider.tsx
git commit -m "Add useAnalytics hook with server-safe NoopAnalytics fallback"

Task 6 — Exit-gate verification

  • Step 1: All gates
pnpm typecheck && pnpm lint && pnpm test

Expected: all pass. Sink + facade tests verify adapter fan-out, consent short-circuit, and disabled-adapter exclusion.

  • Step 2: Git status clean
git status

Self-review

Spec coverage. Master plan §1G-analytics:

  • types.ts (already seeded in 1A-1) — AnalyticsProviders, AnalyticsProps, AnalyticsEvent, Analytics, AnalyticsAdapter
  • sink.ts with emitEvent, getRecordedEvents, resetEvents → Task 1
  • Four stub adapters (metrica, ctm, variocube, dynatrace) → Task 2
  • createAnalytics() with consent gating and adapter fan-out → Task 3
  • <AnalyticsLoader> with requestIdleCallback → Task 4
  • useAnalytics() with NoopAnalytics server fallback → Task 5
  • Consent short-circuit verified in facade tests → Task 3

Exit gate alignment:

  • "all four stub adapters emit exactly one AnalyticsEvent to the sink" — facade test, Task 3
  • "consent.analytics = false short-circuits" — facade test, Task 3
  • "adapter load failure emits flights.analytics.load_failed counter" — deferred to 1F-layout integration (loader wraps load() in try/catch and increments the metric from 1G-metrics)

No new dependencies. Stubs are pure TypeScript with no vendor SDKs.