File size: 7,157 Bytes
2e818da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""Control Room observability summary.

Projects the live :class:`~app.observability.local_store.LocalObservationStore`
-- the canonical, ordinary-product-traffic observation log built in Tasks 1-4
-- into the compact shape the Control Room endpoint returns under the
``observability`` key. This module only reads already-recorded observations
and aggregates them; it never recomputes timings from ``ProjectActivity`` and
it never emits a span, metric, log, or telemetry write of its own.

Status vocabulary (checked in this priority order, matching the plan's
"never silently convert missing data to zero" constraint):

1. ``not_instrumented`` -- telemetry itself is off (`bootstrap` mode is
   ``"disabled"``); there is no store to read at all.
2. ``not_observed`` -- telemetry is on but nothing has been recorded yet
   (store missing or empty).
3. ``degraded`` -- telemetry itself had trouble (one or more suppressed
   export/store failures were counted), even though some data did land.
4. ``observed`` -- real data present, nothing degraded.
"""

from __future__ import annotations

from typing import Any

from app.observability import bootstrap

# Maps a recorded `operation` name (see call sites across app/rag,
# app/websockets/handlers.py, app/agents/cerebras_client.py, and
# app/services/student_memory.py) to the one canonical stage-duration name
# from the plan preamble's "Operational measures" table. More than one
# operation name can share a canonical stage (e.g. both "embedding.query" and
# "embedding.batch" measure `embedding_ms`, and all three memory operations
# measure `memory_ms`); their durations are pooled before computing
# percentiles so the Control Room projection stays keyed by the one canonical
# name, not by the internal call-site name.
_OPERATION_TO_STAGE: dict[str, str] = {
    "pdf.page_extraction": "page_extraction_ms",
    "rag.chunking": "chunking_ms",
    "rag.ingestion": "ingestion_ms",
    "embedding.query": "embedding_ms",
    "embedding.batch": "embedding_ms",
    "chroma.collection_lookup": "collection_lookup_ms",
    "chroma.vector_search": "vector_search_ms",
    "rag.retrieval": "retrieval_ms",
    "context.assembly": "context_assembly_ms",
    "memory.push": "memory_ms",
    "memory.flush": "memory_ms",
    "memory.recall": "memory_ms",
    "llm.generate": "llm_ms",
    "citation.attach": "citation_attachment_ms",
}


def _percentile(sorted_values: list[float], fraction: float) -> float:
    """Nearest-rank-with-interpolation percentile over an already-sorted list."""
    if len(sorted_values) == 1:
        return sorted_values[0]
    index = fraction * (len(sorted_values) - 1)
    lower = int(index)
    upper = min(lower + 1, len(sorted_values) - 1)
    weight = index - lower
    return sorted_values[lower] + (sorted_values[upper] - sorted_values[lower]) * weight


def _stage_summary(rows: list[dict[str, Any]]) -> dict[str, dict[str, float | int]]:
    """p50/p95/count per canonical stage, over rows with a real duration only.

    A row whose `duration_ms` is `None` is skipped entirely for that stage --
    never treated as a zero-duration sample.
    """
    durations_by_stage: dict[str, list[float]] = {}
    for row in rows:
        stage = _OPERATION_TO_STAGE.get(row.get("operation"))
        if stage is None:
            continue
        duration = row.get("duration_ms")
        if duration is None:
            continue
        durations_by_stage.setdefault(stage, []).append(float(duration))

    summary: dict[str, dict[str, float | int]] = {}
    for stage, durations in durations_by_stage.items():
        ordered = sorted(durations)
        summary[stage] = {
            "p50": round(_percentile(ordered, 0.50), 3),
            "p95": round(_percentile(ordered, 0.95), 3),
            "count": len(ordered),
        }
    return summary


def _retrieval_summary(rows: list[dict[str, Any]]) -> dict[str, int]:
    """success/empty/error counts from every row carrying a retrieval outcome.

    Only rows where the call site attached a `RetrievalOutcome` (currently
    `rag.retrieval`, plus the benchmark harness's `rag.benchmark_query`) carry
    a `retrieval` field; every other row is silently skipped, not counted as
    a zero.
    """
    success = 0
    empty = 0
    error = 0
    for row in rows:
        retrieval = row.get("retrieval")
        if not retrieval:
            continue
        status = retrieval.get("status")
        if status == "success":
            success += 1
        elif status == "success_empty":
            empty += 1
        elif status == "error_fallback":
            error += 1
    return {"success_count": success, "empty_count": empty, "error_count": error}


def _latest_trace_id(rows: list[dict[str, Any]]) -> str | None:
    for row in reversed(rows):
        trace_id = row.get("trace_id")
        if trace_id:
            return trace_id
    return None


def _latest_pipeline_version(rows: list[dict[str, Any]]) -> str | None:
    for row in reversed(rows):
        experiment = row.get("experiment")
        if experiment and experiment.get("pipeline_version"):
            return experiment["pipeline_version"]
    return None


def _empty_projection() -> dict[str, Any]:
    return {
        "latest_trace_id": None,
        "pipeline_version": None,
        "stages": {},
        "retrieval": {"success_count": 0, "empty_count": 0, "error_count": 0},
    }


def observability_summary() -> dict[str, Any]:
    """Build the Control Room `observability` projection from live state.

    Always returns the same top-level shape regardless of status, so the
    frontend never has to branch on which keys exist:

    ```json
    {
      "status": "observed",
      "latest_trace_id": "...",
      "pipeline_version": "rag-naive-v1",
      "stages": {"retrieval_ms": {"p50": 1.0, "p95": 2.0, "count": 3}},
      "retrieval": {"success_count": 1, "empty_count": 0, "error_count": 0},
      "exporter": {"mode": "local", "suppressed_failures": 0},
      "signoz_url": null
    }
    ```
    """
    state = bootstrap.get_state()
    suppressed_failures = bootstrap.get_suppressed_failure_count()
    exporter = {"mode": state.mode, "suppressed_failures": suppressed_failures}
    signoz_url = state.config.signoz_ui_url if state.mode == "full" else None

    if state.mode == "disabled":
        return {
            "status": "not_instrumented",
            "exporter": exporter,
            "signoz_url": signoz_url,
            **_empty_projection(),
        }

    store = state.local_store
    rows = store.all() if store is not None else []

    if not rows:
        return {
            "status": "not_observed",
            "exporter": exporter,
            "signoz_url": signoz_url,
            **_empty_projection(),
        }

    status = "degraded" if suppressed_failures > 0 else "observed"

    return {
        "status": status,
        "latest_trace_id": _latest_trace_id(rows),
        "pipeline_version": _latest_pipeline_version(rows),
        "stages": _stage_summary(rows),
        "retrieval": _retrieval_summary(rows),
        "exporter": exporter,
        "signoz_url": signoz_url,
    }