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(/ 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).