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.tswithemitEvent,getRecordedEvents,resetEvents→ Task 1- Four stub adapters (metrica, ctm, variocube, dynatrace) → Task 2
createAnalytics()with consent gating and adapter fan-out → Task 3<AnalyticsLoader>withrequestIdleCallback→ Task 4useAnalytics()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.