diff --git a/docs/superpowers/plans/2026-04-14-phase-1f-seo.md b/docs/superpowers/plans/2026-04-14-phase-1f-seo.md new file mode 100644 index 00000000..aef5639f --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1f-seo.md @@ -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 `` shape (title, meta, canonical, hreflang, OG, Twitter, JSON-LD) — so that 1F-layout and all downstream features can render SEO-complete pages with ``. + +**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 ` to prevent injection", () => { + const data: Thing = { + "@type": "WebSite", + name: '', + }; + + const result = serializeJsonLd(data); + expect(result).not.toContain(""); + }); +}); + +describe("JsonLdRenderer", () => { + it("renders a "); + 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(/]*>([\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 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 ` escaping in `serializeJsonLd` → Task 3 + +**Exit gate alignment:** +- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests +- "SeoHead emits the full 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. diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md b/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md new file mode 100644 index 00000000..2d1e3d26 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-analytics.md @@ -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 `` 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` | `` 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 { + // 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 { + // 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 { + // 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 { + // 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(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 ( + + {children} + + ); +} +``` + +- [ ] **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(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 +- `` 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. diff --git a/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md b/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md new file mode 100644 index 00000000..2d1e1080 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1g-metrics.md @@ -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).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).