# Sprint 9: Integrations & Analytics Wiring

> **Priority**: 3 | **Owner**: Backend + Client | **Complexity**: Medium-High
> **Prerequisite**: Sprint 8

---

## Validation Status (2025-12-28)

### ✅ COMPLETE — All Components Implemented

| Component | Location | Status | Finding |
|---|---|---|---|
| Integration sync hook | `client/src/hooks/use-integration-sync.ts` | ✅ | Real API calls with polling |
| Sync run persistence | `src/noteflow/infrastructure/persistence/models/integrations/` | ✅ | `IntegrationSyncRunModel` with full CRUD |
| Sync orchestration gRPC | `src/noteflow/grpc/_mixins/sync.py` | ✅ | `StartIntegrationSync`, `GetSyncStatus`, `ListSyncHistory` |
| In-memory sync support | `src/noteflow/infrastructure/persistence/memory/repositories.py` | ✅ | `InMemoryIntegrationRepository` with `SyncRun` entities |
| Analytics logs backend | `src/noteflow/infrastructure/logging/log_buffer.py` | ✅ | Ring buffer with filtering |
| Analytics metrics backend | `src/noteflow/infrastructure/metrics/collector.py` | ✅ | Real-time system metrics via psutil |
| gRPC observability endpoints | `src/noteflow/grpc/_mixins/observability.py` | ✅ | `GetRecentLogs`, `GetPerformanceMetrics` |
| Analytics speech tab | `client/src/components/analytics/speech-analysis-tab.tsx` | ✅ | Extracts real data from meeting segments |

### Test Coverage

| Test File | Tests | Status |
|-----------|-------|--------|
| `tests/grpc/test_sync_orchestration.py` | 16 | ✅ All passing |
| `tests/infrastructure/test_observability.py` | 31 | ✅ All passing |
| `client/src/hooks/use-integration-sync.test.ts` | 23 | ✅ All passing |

### Quality Gates

- ✅ `basedpyright src/noteflow/`: 0 errors
- ✅ `ruff check src/noteflow/ tests/`: All checks passed
- ✅ `pytest tests/quality/`: 48 passed (all test smell checks)

---

## Objective

Replace simulated integration sync and mock analytics with real backend data, enabling users to monitor actual system behavior and integration health.

---

## Current State Analysis

### Integration Sync

**Frontend Scheduler** ✅ Complete (`use-integration-sync.ts:118-362`):
- `useIntegrationSync()` hook manages sync lifecycle
- Scheduler with configurable intervals
- Quiet hours support
- Toast and email notification system
- Per-integration state tracking (idle, syncing, success, error)

**Simulated Sync** ❌ Stub (`use-integration-sync.ts:92-116`):
```typescript
async function performSync(integration: Integration): Promise<SyncResult> {
  // Simulate sync delay
  await new Promise(resolve => setTimeout(resolve, 1500 + Math.random() * 1000));

  // Simulate occasional failures (10% chance)
  if (Math.random() < 0.1) {
    throw new Error('Simulated sync failure');
  }

  return { success: true, itemsSynced: Math.floor(Math.random() * 20) + 1 };
}
```

**Backend Models** ✅ Exist (`persistence/models/integrations/`):
- `IntegrationSyncRunModel` with `status`, `error_message`, `duration_ms`, timestamps, and `stats` JSON
- `IntegrationModel` with `last_sync`, `status`, `error_message`
- Foreign key relationship to integrations table

**Gap**: No gRPC endpoints for:
- Starting a sync run
- Getting sync status/progress
- Listing sync history

### Analytics Tabs

**Speech Analysis** ✅ Real Data (`speech-analysis-tab.tsx:31-171`):
- Extracts word frequencies from meeting segments
- Calculates speaking pace (words/minute)
- Computes clarity score, filler word usage, engagement metrics
- Real data flow: `meetings` prop → segments → word analysis

**Performance Tab** ❌ Mock Data (`performance-tab.tsx:48-56`):
```typescript
function generateMockPerformanceData() {
  // Sine/cosine oscillation for fake CPU/memory
  return metrics.map((_, i) => ({
    cpu: 30 + 20 * Math.sin(i / 10) + Math.random() * 10,
    memory: 45 + 15 * Math.cos(i / 8) + Math.random() * 5,
    // ...
  }));
}
```

**Logs Tab** ❌ Mock Data (`logs-tab.tsx:52-113`):
```typescript
function generateMockLogs() {
  const sources = ['app', 'api', 'sync', 'auth', 'system'];
  const levels = ['info', 'warning', 'error', 'debug'];
  // Creates synthetic log entries with fake timestamps
}
```

---

## Scope

| Task | Effort | Risk |
|------|--------|------|
| Add sync orchestration gRPC endpoints | Medium | Low |
| Wire integration sync hook to backend | Low | Low |
| Create backend logging endpoint | Medium | Medium |
| Create backend metrics endpoint | Medium | Medium |
| Wire analytics tabs to backend | Low | Low |

---

## Implementation Plan

### Task 1: Add Sync Orchestration gRPC Endpoints

**Files to Modify**:
- `src/noteflow/grpc/proto/noteflow.proto` — Add messages
- `src/noteflow/grpc/_mixins/calendar.py` — Add sync RPCs
- `src/noteflow/application/services/calendar_service.py` — Add sync methods

**Proto Additions**:
```protobuf
message StartIntegrationSyncRequest {
  string integration_id = 1;
}
message StartIntegrationSyncResponse {
  string sync_run_id = 1;
  string status = 2;  // "running"
}

message GetSyncStatusRequest {
  string sync_run_id = 1;
}
message GetSyncStatusResponse {
  string status = 1;  // "running", "success", "error"
  int32 items_synced = 2;
  int32 items_total = 3;
  string error_message = 4;
  int64 duration_ms = 5;
}

message ListSyncHistoryRequest {
  string integration_id = 1;
  int32 limit = 2;
  int32 offset = 3;
}
message ListSyncHistoryResponse {
  repeated SyncRun runs = 1;
  int32 total_count = 2;
}

message SyncRun {
  string id = 1;
  string integration_id = 2;
  string status = 3;
  int32 items_synced = 4;
  string error_message = 5;
  int64 duration_ms = 6;
  string started_at = 7;
  string completed_at = 8;
}
```

**gRPC Mixin** (`calendar.py`):
```python
from noteflow.grpc._mixins.errors import abort_not_found

async def StartIntegrationSync(
    self: ServicerHost,
    request: noteflow_pb2.StartIntegrationSyncRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.StartIntegrationSyncResponse:
    """Start a sync operation for an integration."""
    integration_id = UUID(request.integration_id)

    async with self._create_repository_provider() as uow:
        integration = await uow.integrations.get_by_id(integration_id)
        if integration is None:
            await abort_not_found(context, "Integration", request.integration_id)

        # Create sync run record
        sync_run = await uow.integrations.create_sync_run(
            integration_id=integration_id,
            status="running",
        )
        await uow.commit()

    # Start async sync task (fire-and-forget)
    asyncio.create_task(
        self._perform_sync(integration_id, sync_run.id)
    )

    return noteflow_pb2.StartIntegrationSyncResponse(
        sync_run_id=str(sync_run.id),
        status="running",
    )

async def GetSyncStatus(
    self: ServicerHost,
    request: noteflow_pb2.GetSyncStatusRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.GetSyncStatusResponse:
    """Return the status of a sync operation."""
    sync_run_id = UUID(request.sync_run_id)

    async with self._create_repository_provider() as uow:
        sync_run = await uow.integrations.get_sync_run(sync_run_id)
        if sync_run is None:
            await abort_not_found(context, "SyncRun", request.sync_run_id)

    return noteflow_pb2.GetSyncStatusResponse(
        status=sync_run.status,
        items_synced=sync_run.stats.get("items_synced", 0),
        items_total=sync_run.stats.get("items_total", 0),
        error_message=sync_run.error_message or "",
        duration_ms=sync_run.duration_ms or 0,
    )

async def ListSyncHistory(
    self,
    request: noteflow_pb2.ListSyncHistoryRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.ListSyncHistoryResponse:
    """List sync history for an integration."""
    integration_id = UUID(request.integration_id)
    limit = min(request.limit or 20, 100)
    offset = request.offset or 0

    async with self._create_repository_provider() as uow:
        runs, total = await uow.integrations.list_sync_runs(
            integration_id=integration_id,
            limit=limit,
            offset=offset,
        )

    return noteflow_pb2.ListSyncHistoryResponse(
        runs=[self._sync_run_to_proto(r) for r in runs],
        total_count=total,
    )
```

### Task 2: Wire Integration Sync Hook to Backend

**Files to Modify**:
- `client/src/hooks/use-integration-sync.ts`
- `client/src/api/interface.ts`
- `client/src/api/tauri-adapter.ts`

**Replace Simulated performSync**:
```typescript
async function performSync(integration: Integration): Promise<SyncResult> {
  const api = getAPI();

  // Start the sync
  const { syncRunId } = await api.startIntegrationSync(integration.id);

  // Poll for completion
  const pollInterval = 500;
  const maxPolls = 120; // 60 seconds max

  for (let i = 0; i < maxPolls; i++) {
    const status = await api.getSyncStatus(syncRunId);

    if (status.status === 'success') {
      return {
        success: true,
        itemsSynced: status.itemsSynced,
        durationMs: status.durationMs,
      };
    }

    if (status.status === 'error') {
      throw new Error(status.errorMessage || 'Sync failed');
    }

    await new Promise(resolve => setTimeout(resolve, pollInterval));
  }

  throw new Error('Sync timed out');
}
```

### Task 3: Add Backend Logging Endpoint

**Files to Create**:
- `src/noteflow/infrastructure/logging/log_buffer.py` — Ring buffer for recent logs
- `src/noteflow/grpc/_mixins/observability.py` — New mixin for logs/metrics

**Log Buffer Implementation**:
```python
"""In-memory ring buffer for recent application logs."""

from __future__ import annotations

from collections import deque
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Iterator

from noteflow.domain.utils.time import utc_now


@dataclass(frozen=True, slots=True)
class LogEntry:
    """Single log entry."""

    timestamp: datetime
    level: str  # debug, info, warning, error
    source: str  # app, api, sync, auth, system
    message: str
    details: dict[str, str] | None = None


class LogBuffer:
    """Thread-safe ring buffer for recent log entries."""

    DEFAULT_CAPACITY = 1000

    def __init__(self, capacity: int = DEFAULT_CAPACITY) -> None:
        self._buffer: deque[LogEntry] = deque(maxlen=capacity)
        self._lock = Lock()

    def append(self, entry: LogEntry) -> None:
        """Add a log entry to the buffer."""
        with self._lock:
            self._buffer.append(entry)

    def log(
        self,
        level: str,
        source: str,
        message: str,
        details: dict[str, str] | None = None,
    ) -> None:
        """Convenience method to create and append a log entry."""
        self.append(LogEntry(
            timestamp=utc_now(),
            level=level,
            source=source,
            message=message,
            details=details,
        ))

    def get_recent(
        self,
        limit: int = 100,
        level: str | None = None,
        source: str | None = None,
    ) -> list[LogEntry]:
        """Get recent log entries, optionally filtered."""
        with self._lock:
            entries = list(self._buffer)

        # Filter
        if level:
            entries = [e for e in entries if e.level == level]
        if source:
            entries = [e for e in entries if e.source == source]

        # Return most recent first
        return list(reversed(entries[-limit:]))

    def clear(self) -> None:
        """Clear all log entries."""
        with self._lock:
            self._buffer.clear()


# Global singleton
_log_buffer: LogBuffer | None = None


def get_log_buffer() -> LogBuffer:
    """Get the global log buffer singleton."""
    global _log_buffer
    if _log_buffer is None:
        _log_buffer = LogBuffer()
    return _log_buffer
```

**gRPC Mixin** (`observability.py`):
```python
"""Observability gRPC mixin for logs and metrics."""

from __future__ import annotations

import grpc

from noteflow.grpc import noteflow_pb2
from noteflow.infrastructure.logging.log_buffer import get_log_buffer


class ObservabilityMixin:
    """Mixin providing log and metric endpoints."""

    async def GetRecentLogs(
        self,
        request: noteflow_pb2.GetRecentLogsRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.GetRecentLogsResponse:
        """Get recent application logs."""
        buffer = get_log_buffer()
        entries = buffer.get_recent(
            limit=request.limit or 100,
            level=request.level or None,
            source=request.source or None,
        )

        return noteflow_pb2.GetRecentLogsResponse(
            logs=[
                noteflow_pb2.LogEntry(
                    timestamp=e.timestamp.isoformat(),
                    level=e.level,
                    source=e.source,
                    message=e.message,
                    details=e.details or {},
                )
                for e in entries
            ],
        )
```

### Task 4: Add Backend Metrics Endpoint

**Files to Create**:
- `src/noteflow/infrastructure/metrics/collector.py` — System metrics collection

**Metrics Collector**:
```python
"""System metrics collection."""

from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass
from collections import deque
from typing import TYPE_CHECKING

import psutil


@dataclass(frozen=True, slots=True)
class PerformanceMetrics:
    """Point-in-time performance metrics."""

    timestamp: float
    cpu_percent: float
    memory_percent: float
    memory_mb: float
    disk_percent: float
    network_bytes_sent: int
    network_bytes_recv: int
    process_memory_mb: float
    active_connections: int


class MetricsCollector:
    """Collect and buffer system metrics."""

    DEFAULT_HISTORY_SIZE = 60  # 1 minute of data at 1s intervals

    def __init__(self, history_size: int = DEFAULT_HISTORY_SIZE) -> None:
        self._history: deque[PerformanceMetrics] = deque(maxlen=history_size)
        self._process = psutil.Process()
        self._last_net_io = psutil.net_io_counters()
        self._running = False

    def collect_now(self) -> PerformanceMetrics:
        """Collect current metrics."""
        cpu = psutil.cpu_percent(interval=None)
        memory = psutil.virtual_memory()
        disk = psutil.disk_usage('/')
        net_io = psutil.net_io_counters()

        metrics = PerformanceMetrics(
            timestamp=time.time(),
            cpu_percent=cpu,
            memory_percent=memory.percent,
            memory_mb=memory.used / (1024 * 1024),
            disk_percent=disk.percent,
            network_bytes_sent=net_io.bytes_sent - self._last_net_io.bytes_sent,
            network_bytes_recv=net_io.bytes_recv - self._last_net_io.bytes_recv,
            process_memory_mb=self._process.memory_info().rss / (1024 * 1024),
            active_connections=len(psutil.net_connections()),
        )

        self._last_net_io = net_io
        self._history.append(metrics)
        return metrics

    def get_history(self, limit: int = 60) -> list[PerformanceMetrics]:
        """Get recent metrics history."""
        return list(self._history)[-limit:]

    async def start_collection(self, interval: float = 1.0) -> None:
        """Start background metrics collection."""
        self._running = True
        while self._running:
            self.collect_now()
            await asyncio.sleep(interval)

    def stop_collection(self) -> None:
        """Stop background collection."""
        self._running = False


# Global singleton
_metrics_collector: MetricsCollector | None = None


def get_metrics_collector() -> MetricsCollector:
    """Get the global metrics collector singleton."""
    global _metrics_collector
    if _metrics_collector is None:
        _metrics_collector = MetricsCollector()
    return _metrics_collector
```

**gRPC Endpoint** (add to `observability.py`):
```python
async def GetPerformanceMetrics(
    self,
    request: noteflow_pb2.GetPerformanceMetricsRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.GetPerformanceMetricsResponse:
    """Get system performance metrics."""
    collector = get_metrics_collector()

    # Get current + history
    current = collector.collect_now()
    history = collector.get_history(request.history_limit or 60)

    return noteflow_pb2.GetPerformanceMetricsResponse(
        current=self._metrics_to_proto(current),
        history=[self._metrics_to_proto(m) for m in history],
    )

def _metrics_to_proto(
    self,
    m: PerformanceMetrics,
) -> noteflow_pb2.PerformanceMetricsPoint:
    """Convert metrics to proto message."""
    return noteflow_pb2.PerformanceMetricsPoint(
        timestamp=m.timestamp,
        cpu_percent=m.cpu_percent,
        memory_percent=m.memory_percent,
        memory_mb=m.memory_mb,
        disk_percent=m.disk_percent,
        network_bytes_sent=m.network_bytes_sent,
        network_bytes_recv=m.network_bytes_recv,
        process_memory_mb=m.process_memory_mb,
        active_connections=m.active_connections,
    )
```

### Task 5: Wire Analytics Tabs to Backend

**Files to Modify**:
- `client/src/components/analytics/logs-tab.tsx`
- `client/src/components/analytics/performance-tab.tsx`

**Logs Tab Update**:
```typescript
// Replace generateMockLogs() with API call
const { data: logs, isLoading } = useQuery({
  queryKey: ['logs', levelFilter, sourceFilter],
  queryFn: async () => {
    const api = getAPI();
    return api.getRecentLogs({
      limit: 100,
      level: levelFilter || undefined,
      source: sourceFilter || undefined,
    });
  },
  refetchInterval: 5000, // Poll every 5 seconds
});
```

**Performance Tab Update**:
```typescript
// Replace generateMockPerformanceData() with API call
const { data: metrics } = useQuery({
  queryKey: ['performance-metrics'],
  queryFn: async () => {
    const api = getAPI();
    return api.getPerformanceMetrics({ historyLimit: 60 });
  },
  refetchInterval: 1000, // Real-time updates
});
```

---

## Behavioral Test Specifications

### Backend Sync Tests

**File**: `tests/grpc/test_sync_orchestration.py`

```python
"""Behavioral tests for integration sync orchestration."""

import asyncio

import pytest
from uuid import uuid4

from noteflow.grpc import noteflow_pb2


async def poll_until_complete(
    client: NoteFlowClient,
    sync_run_id: str,
    expected_status: str,
    timeout_seconds: float = 60,
) -> noteflow_pb2.GetSyncStatusResponse:
    """Poll sync status until expected state or timeout.

    Use this helper instead of inline loops to comply with test quality rules.
    """
    deadline = asyncio.get_event_loop().time() + timeout_seconds
    while asyncio.get_event_loop().time() < deadline:
        status = await client.GetSyncStatus(
            noteflow_pb2.GetSyncStatusRequest(sync_run_id=sync_run_id)
        )
        if status.status == expected_status or status.status in ("success", "error"):
            return status
        await asyncio.sleep(0.5)
    raise TimeoutError(f"Sync did not reach {expected_status} within {timeout_seconds}s")


class TestStartIntegrationSync:
    """Test starting sync operations."""

    async def test_start_sync_creates_sync_run(
        self,
        grpc_client: NoteFlowClient,
        connected_calendar: str,
    ) -> None:
        """Starting sync creates a tracked sync run."""
        response = await grpc_client.StartIntegrationSync(
            noteflow_pb2.StartIntegrationSyncRequest(
                integration_id=connected_calendar,
            )
        )

        assert response.sync_run_id, "Sync run ID should be returned"
        assert response.status == "running"

        # Verify sync run exists
        status = await grpc_client.GetSyncStatus(
            noteflow_pb2.GetSyncStatusRequest(
                sync_run_id=response.sync_run_id,
            )
        )
        assert status.status in ("running", "success")

    async def test_start_sync_for_nonexistent_integration_fails(
        self,
        grpc_client: NoteFlowClient,
    ) -> None:
        """Starting sync for nonexistent integration returns NOT_FOUND."""
        with pytest.raises(grpc.RpcError) as exc_info:
            await grpc_client.StartIntegrationSync(
                noteflow_pb2.StartIntegrationSyncRequest(
                    integration_id=str(uuid4()),
                )
            )

        assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND


class TestSyncStatus:
    """Test sync status monitoring."""

    async def test_success_sync_shows_items_synced(
        self,
        grpc_client: NoteFlowClient,
        connected_calendar: str,
    ) -> None:
        """Completed sync shows count of synced items."""
        # Start sync
        start_response = await grpc_client.StartIntegrationSync(
            noteflow_pb2.StartIntegrationSyncRequest(
                integration_id=connected_calendar,
            )
        )

        # Wait for completion using async polling helper
        status = await poll_until_complete(
            grpc_client,
            start_response.sync_run_id,
            expected_status="success",
            timeout_seconds=60,
        )

        assert status.status == "success", "Sync should complete successfully"
        assert status.items_synced >= 0, "Should report items synced count"
        assert status.duration_ms > 0, "Should report sync duration"

    async def test_error_sync_shows_error_message(
        self,
        grpc_client: NoteFlowClient,
        disconnected_calendar: str,
    ) -> None:
        """Failed sync includes error message."""
        start_response = await grpc_client.StartIntegrationSync(
            noteflow_pb2.StartIntegrationSyncRequest(
                integration_id=disconnected_calendar,
            )
        )

        # Wait for failure using async polling helper
        status = await poll_until_complete(
            grpc_client,
            start_response.sync_run_id,
            expected_status="error",
            timeout_seconds=10,
        )

        assert status.status == "error", "Sync should fail for disconnected calendar"
        assert status.error_message, "Error message should be provided"


class TestSyncHistory:
    """Test sync history listing."""

    async def test_list_sync_history_returns_past_runs(
        self,
        grpc_client: NoteFlowClient,
        integration_with_sync_history: str,
    ) -> None:
        """Sync history includes past sync runs."""
        response = await grpc_client.ListSyncHistory(
            noteflow_pb2.ListSyncHistoryRequest(
                integration_id=integration_with_sync_history,
                limit=10,
            )
        )

        assert len(response.runs) > 0
        assert response.total_count >= len(response.runs)

        # Runs should be ordered newest first
        for i in range(len(response.runs) - 1):
            assert response.runs[i].started_at >= response.runs[i + 1].started_at

    async def test_list_sync_history_respects_pagination(
        self,
        grpc_client: NoteFlowClient,
        integration_with_sync_history: str,
    ) -> None:
        """Pagination works correctly for sync history."""
        page1 = await grpc_client.ListSyncHistory(
            noteflow_pb2.ListSyncHistoryRequest(
                integration_id=integration_with_sync_history,
                limit=5,
                offset=0,
            )
        )

        page2 = await grpc_client.ListSyncHistory(
            noteflow_pb2.ListSyncHistoryRequest(
                integration_id=integration_with_sync_history,
                limit=5,
                offset=5,
            )
        )

        # Pages should have different runs
        page1_ids = {r.id for r in page1.runs}
        page2_ids = {r.id for r in page2.runs}
        assert page1_ids.isdisjoint(page2_ids)
```

### Backend Observability Tests

**File**: `tests/infrastructure/test_observability.py`

```python
"""Behavioral tests for logging and metrics."""

import pytest

from noteflow.infrastructure.logging.log_buffer import LogBuffer, LogEntry
from noteflow.infrastructure.metrics.collector import MetricsCollector


class TestLogBuffer:
    """Test log buffer behavior."""

    def test_append_stores_entries(self) -> None:
        """Appending logs stores them in buffer."""
        buffer = LogBuffer(capacity=10)

        buffer.log("info", "app", "Test message")

        entries = buffer.get_recent()
        assert len(entries) == 1
        assert entries[0].message == "Test message"

    def test_buffer_respects_capacity(self) -> None:
        """Buffer evicts old entries when capacity reached."""
        buffer = LogBuffer(capacity=5)

        for i in range(10):
            buffer.log("info", "app", f"Message {i}")

        entries = buffer.get_recent()
        assert len(entries) == 5
        assert entries[0].message == "Message 9"  # Most recent first
        assert entries[4].message == "Message 5"  # Oldest retained

    @pytest.mark.parametrize(
        ("level_filter", "expected_count"),
        [
            pytest.param("error", 2, id="filter-errors"),
            pytest.param("info", 3, id="filter-info"),
            pytest.param(None, 5, id="no-filter"),
        ],
    )
    def test_filter_by_level(
        self,
        level_filter: str | None,
        expected_count: int,
    ) -> None:
        """Filtering by level returns matching entries."""
        buffer = LogBuffer()
        buffer.log("info", "app", "Info 1")
        buffer.log("error", "app", "Error 1")
        buffer.log("info", "app", "Info 2")
        buffer.log("error", "app", "Error 2")
        buffer.log("info", "app", "Info 3")

        entries = buffer.get_recent(level=level_filter)

        assert len(entries) == expected_count

    def test_filter_by_source(self) -> None:
        """Filtering by source returns matching entries."""
        buffer = LogBuffer()
        buffer.log("info", "app", "App message")
        buffer.log("info", "api", "API message")
        buffer.log("info", "sync", "Sync message")

        entries = buffer.get_recent(source="api")

        assert len(entries) == 1
        assert entries[0].source == "api"


class TestMetricsCollector:
    """Test metrics collection behavior."""

    def test_collect_now_returns_valid_metrics(self) -> None:
        """Collecting metrics returns valid system data."""
        collector = MetricsCollector()

        metrics = collector.collect_now()

        assert 0 <= metrics.cpu_percent <= 100
        assert 0 <= metrics.memory_percent <= 100
        assert metrics.memory_mb > 0
        assert metrics.process_memory_mb > 0

    def test_history_accumulates(self) -> None:
        """Multiple collections accumulate in history."""
        collector = MetricsCollector()

        collector.collect_now()
        collector.collect_now()
        collector.collect_now()

        history = collector.get_history()

        assert len(history) == 3
        # Timestamps should be increasing
        for i in range(len(history) - 1):
            assert history[i].timestamp < history[i + 1].timestamp

    def test_history_respects_limit(self) -> None:
        """History limit caps returned entries."""
        collector = MetricsCollector(history_size=100)

        for _ in range(50):
            collector.collect_now()

        history = collector.get_history(limit=10)

        assert len(history) == 10
```

### Frontend Analytics Tests

**File**: `client/src/components/analytics/logs-tab.test.tsx`

```typescript
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LogsTab } from './logs-tab';
import * as api from '../../api';

describe('LogsTab', () => {
  const createWrapper = () => {
    const queryClient = new QueryClient({
      defaultOptions: { queries: { retry: false } },
    });
    return ({ children }: { children: React.ReactNode }) => (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    );
  };

  describe('when logs are available', () => {
    it('fetches and displays real logs from backend', async () => {
      const mockLogs = [
        { timestamp: new Date().toISOString(), level: 'info', source: 'app', message: 'Server started' },
        { timestamp: new Date().toISOString(), level: 'error', source: 'sync', message: 'Sync failed' },
      ];
      vi.spyOn(api, 'getAPI').mockReturnValue({
        getRecentLogs: vi.fn().mockResolvedValue(mockLogs),
      } as any);

      render(<LogsTab />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText('Server started')).toBeInTheDocument();
        expect(screen.getByText('Sync failed')).toBeInTheDocument();
      });
    });

    it('applies level filter to API request', async () => {
      const mockGetLogs = vi.fn().mockResolvedValue([]);
      vi.spyOn(api, 'getAPI').mockReturnValue({
        getRecentLogs: mockGetLogs,
      } as any);

      render(<LogsTab initialLevelFilter="error" />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(mockGetLogs).toHaveBeenCalledWith(
          expect.objectContaining({ level: 'error' })
        );
      });
    });
  });

  describe('when backend is unavailable', () => {
    it('shows error state', async () => {
      vi.spyOn(api, 'getAPI').mockReturnValue({
        getRecentLogs: vi.fn().mockRejectedValue(new Error('Connection failed')),
      } as any);

      render(<LogsTab />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText(/failed to load logs/i)).toBeInTheDocument();
      });
    });
  });
});
```

**File**: `client/src/components/analytics/performance-tab.test.tsx`

```typescript
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PerformanceTab } from './performance-tab';
import * as api from '../../api';

describe('PerformanceTab', () => {
  const createWrapper = () => {
    const queryClient = new QueryClient({
      defaultOptions: { queries: { retry: false } },
    });
    return ({ children }: { children: React.ReactNode }) => (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    );
  };

  describe('when metrics are available', () => {
    it('displays real CPU and memory metrics', async () => {
      const mockMetrics = {
        current: { cpuPercent: 45.5, memoryPercent: 62.3, memoryMb: 8192 },
        history: [],
      };
      vi.spyOn(api, 'getAPI').mockReturnValue({
        getPerformanceMetrics: vi.fn().mockResolvedValue(mockMetrics),
      } as any);

      render(<PerformanceTab />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText(/45.5%/)).toBeInTheDocument();
        expect(screen.getByText(/62.3%/)).toBeInTheDocument();
      });
    });

    it('refreshes metrics at configured interval', async () => {
      vi.useFakeTimers();
      const mockGetMetrics = vi.fn().mockResolvedValue({
        current: { cpuPercent: 50 },
        history: [],
      });
      vi.spyOn(api, 'getAPI').mockReturnValue({
        getPerformanceMetrics: mockGetMetrics,
      } as any);

      render(<PerformanceTab />, { wrapper: createWrapper() });

      await waitFor(() => expect(mockGetMetrics).toHaveBeenCalledTimes(1));

      vi.advanceTimersByTime(1000);
      await waitFor(() => expect(mockGetMetrics).toHaveBeenCalledTimes(2));

      vi.useRealTimers();
    });
  });
});
```

### Integration Sync Hook Tests

**File**: `client/src/hooks/use-integration-sync.test.ts`

```typescript
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useIntegrationSync } from './use-integration-sync';
import * as api from '../api';

describe('useIntegrationSync', () => {
  describe('performSync', () => {
    it('calls backend API instead of simulating', async () => {
      const mockApi = {
        startIntegrationSync: vi.fn().mockResolvedValue({ syncRunId: 'run-1' }),
        getSyncStatus: vi.fn().mockResolvedValue({
          status: 'success',
          itemsSynced: 15,
          durationMs: 2500,
        }),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      const { result } = renderHook(() => useIntegrationSync());

      await act(async () => {
        await result.current.syncNow('integration-1');
      });

      expect(mockApi.startIntegrationSync).toHaveBeenCalledWith('integration-1');
      expect(mockApi.getSyncStatus).toHaveBeenCalledWith('run-1');
    });

    it('polls status until sync completes', async () => {
      let pollCount = 0;
      const mockApi = {
        startIntegrationSync: vi.fn().mockResolvedValue({ syncRunId: 'run-1' }),
        getSyncStatus: vi.fn().mockImplementation(async () => {
          pollCount++;
          if (pollCount < 3) {
            return { status: 'running', itemsSynced: pollCount * 5 };
          }
          return { status: 'success', itemsSynced: 15, durationMs: 1500 };
        }),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      const { result } = renderHook(() => useIntegrationSync());

      await act(async () => {
        await result.current.syncNow('integration-1');
      });

      expect(mockApi.getSyncStatus).toHaveBeenCalledTimes(3);
    });

    it('throws on sync failure with error message', async () => {
      const mockApi = {
        startIntegrationSync: vi.fn().mockResolvedValue({ syncRunId: 'run-1' }),
        getSyncStatus: vi.fn().mockResolvedValue({
          status: 'error',
          errorMessage: 'OAuth token expired',
        }),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      const { result } = renderHook(() => useIntegrationSync());

      await expect(
        act(async () => {
          await result.current.syncNow('integration-1');
        })
      ).rejects.toThrow('OAuth token expired');
    });
  });
});
```

---

## Acceptance Criteria

### Functional

- [ ] Integration sync calls real backend instead of simulating
- [ ] Sync status updates in real-time during operation
- [ ] Sync history shows past runs with duration and item counts
- [ ] Analytics logs show actual application log entries
- [ ] Analytics performance shows real CPU/memory metrics
- [ ] Failed syncs display meaningful error messages

### Technical

- [ ] Log buffer thread-safe for concurrent writes
- [ ] Metrics collection uses psutil for cross-platform support
- [ ] Sync polling has reasonable timeout (60 seconds)
- [ ] Metrics refresh at 1 second intervals

### Quality Gates

- [ ] `pytest tests/grpc/test_sync_orchestration.py` passes
- [ ] `pytest tests/infrastructure/test_observability.py` passes
- [ ] `npm run test` passes for frontend
- [ ] No mock data generators in production code paths

---

## Rollback Plan

1. **Sync orchestration issues**: Re-enable simulated sync with feature flag
2. **Metrics collection crashes**: Disable psutil and show "unavailable"
3. **Log buffer memory issues**: Reduce capacity or disable buffering

---

## Post-Sprint

- Add log export functionality (download as file)
- Add metrics alerting (CPU/memory thresholds)
- Implement sync retry with exponential backoff
- Add sync progress events (WebSocket or Server-Sent Events)
