plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
3 changed files with 1459 additions and 0 deletions
Showing only changes of commit 8e7adef5e3 - Show all commits
@@ -0,0 +1,493 @@
# Phase 1F-seo — SeoHead + hreflang + JsonLdRenderer 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 SEO infrastructure — `buildHreflangSet` for 9 languages + x-default, `JsonLdRenderer` with `schema-dts` typing, and `SeoHead` component emitting the full `<head>` shape (title, meta, canonical, hreflang, OG, Twitter, JSON-LD) — so that 1F-layout and all downstream features can render SEO-complete pages with `<SeoHead title={...} hreflang={buildHreflangSet(...)} jsonLd={data} />`.
**Architecture:** Pure functions and thin React components with no runtime dependencies on 1C/1D/1G. `hreflang.ts` builds the 9-language + x-default link set. `json-ld.tsx` serializes `schema-dts` `Thing` objects into safe `<script type="application/ld+json">` blocks. `SeoHead.tsx` composes both into a single `<head>` fragment. The `Language` type is imported from `@/i18n/resolver` (seeded in 1C).
**Tech Stack:** `schema-dts` (Google's Schema.org TypeScript definitions).
**Prerequisites:** 1A-1 (skeleton), 1A-3 (ESLint boundaries), 1C (Language type from `@/i18n/resolver`).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/shared/seo/hreflang.ts` | `buildHreflangSet` function | 2 |
| `src/shared/seo/hreflang.test.ts` | Tests | 2 |
| `src/shared/seo/json-ld.tsx` | `JsonLdRenderer` + `serializeJsonLd` | 3 |
| `src/shared/seo/json-ld.test.ts` | Tests | 3 |
| `src/ui/seo/SeoHead.tsx` | `<SeoHead>` component | 4 |
---
## Task 1 — Install `schema-dts` dependency
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Install**
```bash
pnpm add schema-dts
```
- [ ] **Step 2: Verify**
```bash
pnpm typecheck
```
- [ ] **Step 3: Commit**
```bash
git add package.json pnpm-lock.yaml
git commit -m "Add schema-dts dependency for typed JSON-LD generation"
```
---
## Task 2 — TDD `hreflang.ts`
**Files:**
- Create: `src/shared/seo/hreflang.ts`
- Create: `src/shared/seo/hreflang.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/shared/seo/hreflang.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import { buildHreflangSet } from "./hreflang.js";
describe("buildHreflangSet", () => {
const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const;
it("returns entries for all 9 languages plus x-default", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
});
expect(result).toHaveLength(10); // 9 languages + x-default
});
it("includes all 9 languages", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/smoke",
});
const langs = result.map((entry) => entry.lang);
for (const lang of LANGUAGES) {
expect(langs).toContain(lang);
}
});
it("x-default points to the ru variant", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/smoke",
});
const xDefault = result.find((entry) => entry.lang === "x-default");
expect(xDefault).toBeDefined();
expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru/smoke");
});
it("builds correct href for each language", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/onlineboard",
});
const en = result.find((entry) => entry.lang === "en");
expect(en?.href).toBe("https://www.aeroflot.ru/en/onlineboard");
const ja = result.find((entry) => entry.lang === "ja");
expect(ja?.href).toBe("https://www.aeroflot.ru/ja/onlineboard");
});
it("preserves paths with nested segments", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
});
const fr = result.find((entry) => entry.lang === "fr");
expect(fr?.href).toBe("https://www.aeroflot.ru/fr/onlineboard/flight/SU100-2025-01-15");
});
it("handles root path", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "",
});
const ru = result.find((entry) => entry.lang === "ru");
expect(ru?.href).toBe("https://www.aeroflot.ru/ru");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/shared/seo/hreflang
```
- [ ] **Step 3: Write implementation**
Create `src/shared/seo/hreflang.ts`:
```typescript
import type { Language } from "@/i18n/resolver";
const LANGUAGES: readonly Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"];
const X_DEFAULT_LANGUAGE: Language = "ru";
export interface HreflangEntry {
lang: Language | "x-default";
href: string;
}
/**
* Builds the full set of reciprocal hreflang links for a given path.
* Returns 9 language entries + 1 x-default entry (pointing to ru).
*/
export function buildHreflangSet(args: {
canonicalOrigin: string;
pathWithoutLocale: string;
}): HreflangEntry[] {
const { canonicalOrigin, pathWithoutLocale } = args;
const entries: HreflangEntry[] = LANGUAGES.map((lang) => ({
lang,
href: `${canonicalOrigin}/${lang}${pathWithoutLocale}`,
}));
entries.push({
lang: "x-default",
href: `${canonicalOrigin}/${X_DEFAULT_LANGUAGE}${pathWithoutLocale}`,
});
return entries;
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/shared/seo/hreflang
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/shared/seo/hreflang.ts src/shared/seo/hreflang.test.ts
git commit -m "Add buildHreflangSet for 9 languages + x-default"
```
---
## Task 3 — TDD `json-ld.tsx`
**Files:**
- Create: `src/shared/seo/json-ld.tsx`
- Create: `src/shared/seo/json-ld.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/shared/seo/json-ld.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import type { Thing } from "schema-dts";
import { JsonLdRenderer, serializeJsonLd } from "./json-ld.js";
describe("serializeJsonLd", () => {
it("serializes a single Thing to a JSON-LD string", () => {
const data: Thing = {
"@type": "WebSite",
name: "Aeroflot",
url: "https://www.aeroflot.ru",
};
const result = serializeJsonLd(data);
const parsed = JSON.parse(result);
expect(parsed["@context"]).toBe("https://schema.org");
expect(parsed["@type"]).toBe("WebSite");
expect(parsed.name).toBe("Aeroflot");
});
it("serializes an array of Things with @context on each", () => {
const data: Thing[] = [
{ "@type": "WebSite", name: "Aeroflot" } as Thing,
{ "@type": "Organization", name: "Aeroflot PJSC" } as Thing,
];
const result = serializeJsonLd(data);
const parsed = JSON.parse(result);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(2);
expect(parsed[0]["@context"]).toBe("https://schema.org");
expect(parsed[1]["@context"]).toBe("https://schema.org");
});
it("escapes </script> to prevent injection", () => {
const data: Thing = {
"@type": "WebSite",
name: '</script><script>alert("xss")</script>',
};
const result = serializeJsonLd(data);
expect(result).not.toContain("</script>");
});
});
describe("JsonLdRenderer", () => {
it("renders a <script type=application/ld+json> tag", () => {
const data: Thing = {
"@type": "WebSite",
name: "Aeroflot",
url: "https://www.aeroflot.ru",
};
const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));
expect(html).toContain('<script type="application/ld+json">');
expect(html).toContain("</script>");
expect(html).toContain('"@context":"https://schema.org"');
expect(html).toContain('"@type":"WebSite"');
});
it("round-trips: serialize → DOM string contains valid JSON-LD", () => {
const data: Thing = {
"@type": "Organization",
name: "Aeroflot PJSC",
url: "https://www.aeroflot.ru",
};
const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));
// Extract JSON from the script tag
const match = html.match(/<script[^>]*>([\s\S]*?)<\/script>/);
expect(match).not.toBeNull();
const json = match![1]!.replace(/\\u003c/g, "<");
const parsed = JSON.parse(json);
expect(parsed["@context"]).toBe("https://schema.org");
expect(parsed["@type"]).toBe("Organization");
expect(parsed.name).toBe("Aeroflot PJSC");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/shared/seo/json-ld
```
- [ ] **Step 3: Write implementation**
Create `src/shared/seo/json-ld.tsx`:
```tsx
import type { Thing } from "schema-dts";
export interface JsonLdRendererProps {
data: Thing | Thing[];
}
/**
* Serializes a schema-dts Thing (or array of Things) to a JSON-LD string.
* Adds "@context": "https://schema.org" to each item.
* Escapes </script> sequences to prevent XSS.
*/
export function serializeJsonLd(data: Thing | Thing[]): string {
const withContext = Array.isArray(data)
? data.map((item) => ({ "@context": "https://schema.org" as const, ...item }))
: { "@context": "https://schema.org" as const, ...data };
return JSON.stringify(withContext).replace(/<\//g, "\\u003c/");
}
/**
* Renders a <script type="application/ld+json"> block with the serialized
* JSON-LD data. Safe for SSR — the content is escaped against script injection.
*/
export function JsonLdRenderer({ data }: JsonLdRendererProps): JSX.Element {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/shared/seo/json-ld
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/shared/seo/json-ld.tsx src/shared/seo/json-ld.test.ts
git commit -m "Add JsonLdRenderer and serializeJsonLd with schema-dts typing"
```
---
## Task 4 — Create `SeoHead.tsx` (no TDD)
**Files:**
- Create: `src/ui/seo/SeoHead.tsx`
No TDD — thin React component assembling head tags. Tested by 1F-layout integration.
- [ ] **Step 1: Write implementation**
Create `src/ui/seo/SeoHead.tsx`:
```tsx
import type { Language } from "@/i18n/resolver";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import type { Thing } from "schema-dts";
export interface SeoHeadProps {
title: string;
description: string;
canonical: string;
hreflang: Array<{ lang: Language | "x-default"; href: string }>;
og: {
title: string;
description: string;
url: string;
image: string;
type: "website" | "article";
locale: string;
siteName: string;
};
twitter?: {
card: "summary" | "summary_large_image";
title?: string;
description?: string;
image?: string;
};
jsonLd?: Thing | Thing[];
noindex?: boolean;
}
/**
* Renders the full SEO <head> fragment: title, meta description, canonical,
* hreflang alternates, Open Graph tags, Twitter Card tags, and JSON-LD.
*/
export function SeoHead({
title,
description,
canonical,
hreflang,
og,
twitter,
jsonLd,
noindex,
}: SeoHeadProps): JSX.Element {
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex,nofollow" />}
{/* Hreflang alternates */}
{hreflang.map((entry) => (
<link
key={entry.lang}
rel="alternate"
hrefLang={entry.lang}
href={entry.href}
/>
))}
{/* Open Graph */}
<meta property="og:title" content={og.title} />
<meta property="og:description" content={og.description} />
<meta property="og:url" content={og.url} />
<meta property="og:image" content={og.image} />
<meta property="og:type" content={og.type} />
<meta property="og:locale" content={og.locale} />
<meta property="og:site_name" content={og.siteName} />
{/* Twitter Card */}
{twitter && (
<>
<meta name="twitter:card" content={twitter.card} />
{twitter.title && <meta name="twitter:title" content={twitter.title} />}
{twitter.description && <meta name="twitter:description" content={twitter.description} />}
{twitter.image && <meta name="twitter:image" content={twitter.image} />}
</>
)}
{/* JSON-LD */}
{jsonLd && <JsonLdRenderer data={jsonLd} />}
</>
);
}
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/ui/seo/SeoHead.tsx
git commit -m "Add SeoHead component with canonical, hreflang, OG, Twitter, and JSON-LD"
```
---
## Task 5 — Exit-gate verification
- [ ] **Step 1: All gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. hreflang tests cover 9 languages + x-default. JsonLdRenderer round-trips through serialize to DOM string.
- [ ] **Step 2: Git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1F-seo:
- `buildHreflangSet` with 9 languages + `x-default` (x-default → ru) → Task 2
- `JsonLdRenderer` + `serializeJsonLd` with `schema-dts` `Thing` type → Task 3
- `SeoHead` with title, meta description, canonical, hreflang links, OG tags, Twitter Card, JSON-LD, noindex → Task 4
- XSS protection via `</script>` escaping in `serializeJsonLd` → Task 3
**Exit gate alignment:**
- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests
- "SeoHead emits the full <head> shape" — Task 4 component (tested by 1F-layout integration)
- "JsonLdRenderer round-trips a typed Thing through serializeJsonLd → DOM string" — Task 3 tests
**Type consistency.** `Language` from `@/i18n/resolver` (seeded in 1C). `Thing` from `schema-dts`. `SeoHeadProps` matches the master plan contract exactly.
@@ -0,0 +1,600 @@
# 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.
@@ -0,0 +1,366 @@
# Phase 1G-metrics — OpenTelemetry + Custom Instruments 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 OpenTelemetry runtime — server and browser initializers, `getMeter`/`getTracer` accessors, and the 8 custom metric instruments — so that 1F-layout, 1D, 1E, and all downstream features can emit structured metrics with `flightsApiError.add(1, { route })` in both SSR and client contexts.
**Architecture:** `otel.ts` is the **only** file allowed to import from `@opentelemetry/sdk-metrics` and `@opentelemetry/sdk-node` (enforced by 1A-3 ESLint boundaries). It exports `initServerOtel(env)` and `initBrowserOtel(env)` which wire the real `MeterProvider`/`TracerProvider`. `custom.ts` uses `@opentelemetry/api`'s proxy meter to declare instruments at module level — safe because the proxy lazy-resolves after init runs. `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`.
**Tech Stack:** `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals`.
**Prerequisites:** 1A-1 (skeleton + `Env` type), 1A-3 (ESLint boundaries), 1G-logger (Logger types).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/observability/metrics/otel.ts` | OTel init + getMeter/getTracer | 2 |
| `src/observability/metrics/otel.test.ts` | Tests | 2 |
| `src/observability/metrics/custom.ts` | 8 custom metric instruments | 3 |
---
## Task 1 — Install OTel dependencies
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Install dependencies**
```bash
pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http web-vitals
```
- [ ] **Step 2: Verify installation**
```bash
pnpm typecheck
```
- [ ] **Step 3: Commit**
```bash
git add package.json pnpm-lock.yaml
git commit -m "Add OpenTelemetry and web-vitals dependencies for metrics pipeline"
```
---
## Task 2 — TDD `otel.ts`
**Files:**
- Create: `src/observability/metrics/otel.ts`
- Create: `src/observability/metrics/otel.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/metrics/otel.test.ts`:
```typescript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { metrics, trace } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
AggregationTemporality,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
describe("otel", () => {
beforeEach(() => {
// Reset global providers between tests
metrics.disable();
trace.disable();
});
afterEach(() => {
metrics.disable();
trace.disable();
vi.restoreAllMocks();
});
it("initServerOtel registers a MeterProvider and TracerProvider", async () => {
const { initServerOtel } = await import("./otel.js");
initServerOtel({
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
OTEL_SERVICE_NAME: "flights-test",
NODE_ENV: "test",
} as any);
// After init, getMeter should return a working meter
const { getMeter, getTracer } = await import("./otel.js");
const meter = getMeter("test");
const tracer = getTracer("test");
expect(meter).toBeDefined();
expect(tracer).toBeDefined();
});
it("counter incremented via proxy meter is observable by test reader", async () => {
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100,
});
const { initServerOtelWithReader } = await import("./otel.js");
initServerOtelWithReader({
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
OTEL_SERVICE_NAME: "flights-test",
NODE_ENV: "test",
} as any, reader);
const counter = metrics.getMeter("flights").createCounter("test.counter");
counter.add(1, { route: "/smoke" });
// Force a collection cycle
await reader.forceFlush();
const exported = exporter.getMetrics();
expect(exported.length).toBeGreaterThan(0);
const testMetric = exported
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "test.counter");
expect(testMetric).toBeDefined();
await reader.shutdown();
});
it("getMeter returns a meter from @opentelemetry/api", async () => {
const { getMeter } = await import("./otel.js");
const meter = getMeter("my-component");
expect(meter).toBeDefined();
expect(typeof meter.createCounter).toBe("function");
expect(typeof meter.createHistogram).toBe("function");
});
it("getTracer returns a tracer from @opentelemetry/api", async () => {
const { getTracer } = await import("./otel.js");
const tracer = getTracer("my-component");
expect(tracer).toBeDefined();
expect(typeof tracer.startSpan).toBe("function");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/observability/metrics/otel
```
- [ ] **Step 3: Write implementation**
Create `src/observability/metrics/otel.ts`:
```typescript
import { metrics, trace } from "@opentelemetry/api";
import type { Meter, Tracer } from "@opentelemetry/api";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import type { MetricReader } from "@opentelemetry/sdk-metrics";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import type { Env } from "@/env";
import type { Logger } from "@/observability/logger/types";
let initialized = false;
/**
* Initialize OpenTelemetry for the server (Node) process.
* Called once per process at startup.
*/
export function initServerOtel(env: Env): void {
if (initialized) return;
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
const serviceName = env.OTEL_SERVICE_NAME ?? "flights-web";
const metricReader = new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 15_000,
});
const sdk = new NodeSDK({
serviceName,
traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
metricReader,
});
sdk.start();
initialized = true;
}
/**
* Test-only variant that accepts a custom MetricReader for in-memory assertions.
*/
export function initServerOtelWithReader(env: Env, reader: MetricReader): void {
if (initialized) return;
const serviceName = (env as Record<string, string>).OTEL_SERVICE_NAME ?? "flights-test";
const meterProvider = new MeterProvider({
readers: [reader],
});
metrics.setGlobalMeterProvider(meterProvider);
initialized = true;
}
/**
* Initialize OpenTelemetry for the browser.
* Called once per tab via useEffect in the root layout.
* Browser-side uses web-vitals to report CWV as histograms.
*/
export function initBrowserOtel(env: Env): void {
if (initialized) return;
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (!endpoint) return;
const meterProvider = new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 30_000,
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);
// Report web-vitals as OTel histograms
const cwvMeter = meterProvider.getMeter("web-vitals");
void import("web-vitals").then(({ onCLS, onFID, onLCP, onFCP, onTTFB }) => {
const cls = cwvMeter.createHistogram("web_vitals.cls");
const fid = cwvMeter.createHistogram("web_vitals.fid");
const lcp = cwvMeter.createHistogram("web_vitals.lcp");
const fcp = cwvMeter.createHistogram("web_vitals.fcp");
const ttfb = cwvMeter.createHistogram("web_vitals.ttfb");
onCLS((m) => cls.record(m.value));
onFID((m) => fid.record(m.value));
onLCP((m) => lcp.record(m.value));
onFCP((m) => fcp.record(m.value));
onTTFB((m) => ttfb.record(m.value));
});
initialized = true;
}
/** Returns a named Meter from the global MeterProvider. */
export function getMeter(name: string): Meter {
return metrics.getMeter(name);
}
/** Returns a named Tracer from the global TracerProvider. */
export function getTracer(name: string): Tracer {
return trace.getTracer(name);
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/observability/metrics/otel
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/metrics/otel.ts src/observability/metrics/otel.test.ts
git commit -m "Add OTel server/browser initializers with getMeter/getTracer accessors"
```
---
## Task 3 — Create `custom.ts` (declarative, no TDD)
**Files:**
- Create: `src/observability/metrics/custom.ts`
- [ ] **Step 1: Write implementation**
Create `src/observability/metrics/custom.ts`:
```typescript
import { metrics } from "@opentelemetry/api";
/**
* Module-level metric instruments for the flights remote component.
* Safe to declare at module scope — @opentelemetry/api's proxy meter
* lazy-resolves to the real MeterProvider after initServerOtel/initBrowserOtel runs.
*/
const meter = metrics.getMeter("flights");
/** SSR request duration histogram (seconds). */
export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration");
/** Upstream API request duration histogram (seconds). */
export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration");
/** Upstream API error counter (by route, status). */
export const flightsApiError = meter.createCounter("flights.api.error");
/** SignalR active connections gauge. */
export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected");
/** SignalR messages received counter. */
export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received");
/** SignalR disconnection counter (by reason). */
export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect");
/** Feature component render counter (by feature name). */
export const flightsFeatureRender = meter.createCounter("flights.feature.render");
/** Unhandled React error counter (caught by ErrorBoundary). */
export const flightsReactError = meter.createCounter("flights.react.error");
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/metrics/custom.ts
git commit -m "Add 8 custom metric instruments using OTel proxy meter"
```
---
## Task 4 — Exit-gate verification
- [ ] **Step 1: All gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. OTel init test proves counter is observable via test reader.
- [ ] **Step 2: Git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1G-metrics:
- `initServerOtel(env)` / `initBrowserOtel(env)` — Task 2
- `getMeter(name)` / `getTracer(name)` — Task 2
- 8 custom instruments (`flights.ssr.request.duration`, `flights.api.request.duration`, `flights.api.error`, `flights.signalr.connected`, `flights.signalr.message.received`, `flights.signalr.disconnect`, `flights.feature.render`, `flights.react.error`) — Task 3
- web-vitals histograms created inside `initBrowserOtel` — Task 2
- `otel.ts` is the only file importing `@opentelemetry/sdk-metrics` / `@opentelemetry/sdk-node` — enforced by 1A-3 ESLint rule, verified at exit gate
**Import boundary.** `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. `custom.ts` imports only from `@opentelemetry/api` (public API, no SDK).
**Type consistency.** `Meter`, `Tracer` from `@opentelemetry/api`. `Env` from `@/env` (seeded in 1A-1).