# 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`:

```typescript
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**

```bash
pnpm test src/observability/analytics/sink
```

- [ ] **Step 3: Write implementation**

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

```typescript
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**

```bash
pnpm test src/observability/analytics/sink
```

- [ ] **Step 5: Typecheck + lint, commit**

```bash
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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**

```bash
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`:

```typescript
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**

```bash
pnpm test src/observability/analytics/facade
```

- [ ] **Step 3: Write implementation**

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

```typescript
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**

```bash
pnpm test src/observability/analytics/facade
```

- [ ] **Step 5: Typecheck + lint, commit**

```bash
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`:

```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**

```bash
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`:

```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**

```bash
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**

```bash
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**

```bash
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.
