File size: 6,865 Bytes
9d29c62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# domain/telemetry.py - V7.2 Observability Layer
"""
BuddyMath V7.2 Runtime Telemetry
Emits structured log-based metrics compatible with Datadog/Grafana log pipelines.

All metrics are emitted as structured JSON log lines on the logger named "buddymath.metrics".
DevOps should configure a log-based metric parser for lines containing "METRIC_EVENT".

Metric naming convention: <component>.<event_name>
"""
import logging
import json
import time
from datetime import datetime, timezone
from typing import Optional

metrics_logger = logging.getLogger("buddymath.metrics")

# ==================== METRIC KEY CONSTANTS ====================
# Use these constants everywhere to prevent typos and enable grep-ability.

class M:
    """V7.2 Metric Key Registry"""

    # Core runtime outcome (feeds the Pie Chart)
    RUNTIME_OUTCOME         = "runtime.outcome"        # values: success | hint_mode | planner_retry | leakage_fail | placeholder_fail | crs_block

    # Fail Closed Rate (derived from RUNTIME_OUTCOME != success)
    FAIL_CLOSED             = "fail_closed"            # emitted on any non-success outcome

    # Planner (LLM #1)
    PLANNER_RETRY           = "planner.retry.count"    # emitted on each retry attempt
    PLANNER_ENUM_VIOLATION  = "planner.enum_violation" # emitted when ComputeAction enum is violated
    PLANNER_JSON_ERROR      = "planner.json_error"     # emitted on JSON parse / boundary failure
    PLANNER_STRATEGY_DIST   = "planner.strategy_distribution"  # which Enum actions are chosen (drift detection)

    # Renderer (LLM #2)
    RENDERER_LEAKAGE_FAIL       = "renderer.leakage_fail"          # Whitelist scan failed
    RENDERER_PLACEHOLDER_FAIL   = "renderer.placeholder_violation"  # Missing or invented placeholder
    RENDERER_LEAKAGE_CHARS      = "renderer.leakage_chars"          # Offending chars (for slicing)

    # Solver / Server Determinism
    SOLVER_EXECUTION_MS     = "solver.execution_time_ms"    # Math engine wall-clock time
    SIGNATURE_HASH_COLLISION = "signature.hash_collision"   # MUST always be 0

    # CRS Pre-Flight
    CRS_BLOCK               = "crs.preflight_block"     # Emitted when CRS > 0.7 blocks LLM call
    CRS_VALUE               = "crs.value"               # Raw CRS score for histogram

    # Security
    SUSPICIOUS_INPUT        = "suspicious.input_pattern"  # Potential prompt injection detected

    # Pedagogical Diversity (V7.2.5)
    PEDAGOGICAL_DRIFT       = "pedagogical.drift_score"   # Narrative laziness: > 0.35 = same template always chosen


# ==================== EMITTER ====================

def emit(metric: str, value, tags: Optional[dict] = None):
    """
    Emit a single metric event as a structured JSON log line.
    Datadog/Grafana log-based metric pipelines should parse lines with 'METRIC_EVENT'.
    
    Format:
    {"event": "METRIC_EVENT", "metric": "<name>", "value": <v>, "tags": {...}, "timestamp": "..."}
    """
    payload = {
        "event":     "METRIC_EVENT",
        "metric":    metric,
        "value":     value,
        "tags":      tags or {},
        "timestamp": datetime.now(timezone.utc).isoformat()
    }
    metrics_logger.info(json.dumps(payload, ensure_ascii=False))


def emit_runtime_outcome(outcome: str, problem_id: str = "unknown", grade: str = "unknown"):
    """Feeds the main Pie Chart and Fail Closed Rate gauge."""
    emit(M.RUNTIME_OUTCOME, outcome, {"problem_id": problem_id, "grade": grade})
    if outcome != "success":
        emit(M.FAIL_CLOSED, 1, {"reason": outcome})


def emit_planner_retry(attempt: int, reason: str):
    emit(M.PLANNER_RETRY, attempt, {"reason": reason})


def emit_planner_error(error_type: str, details: str = ""):
    """error_type: 'enum_violation' | 'json_error'"""
    key = M.PLANNER_ENUM_VIOLATION if error_type == "enum_violation" else M.PLANNER_JSON_ERROR
    emit(key, 1, {"details": details[:120]})


def emit_planner_strategy_distribution(actions: list):
    """
    Phase 1 Live: Emit one metric event per chosen Enum action.
    Feeds the planner.strategy_distribution dashboard panel.
    A sudden shift in distribution signals Model Drift.
    Example: [SOLVE_EQUATION, SIMPLIFY, SOLVE_EQUATION] → 3 events
    """
    for action in actions:
        emit(M.PLANNER_STRATEGY_DIST, 1, {"action": str(action)})


def emit_renderer_leakage(offending_chars: str):
    emit(M.RENDERER_LEAKAGE_FAIL, 1, {"offending_chars": offending_chars[:50]})
    emit(M.RENDERER_LEAKAGE_CHARS, offending_chars[:50])


def emit_renderer_placeholder_violation(violation_type: str, missing_id: str = ""):
    """violation_type: 'missing' | 'invented'"""
    emit(M.RENDERER_PLACEHOLDER_FAIL, 1, {"type": violation_type, "id": missing_id})


def emit_solver_timing(start_time: float):
    """Call with time.time() snapshot taken BEFORE solver runs."""
    elapsed_ms = round((time.time() - start_time) * 1000, 2)
    emit(M.SOLVER_EXECUTION_MS, elapsed_ms)
    return elapsed_ms


def emit_hash_collision(step_id: str, problem_id: str):
    """
    CRITICAL: This MUST never be emitted in a correct system.
    If it fires, it means two different expressions produced the same hash → P0 alert.
    """
    metrics_logger.critical(
        json.dumps({
            "event":      "METRIC_EVENT",
            "metric":     M.SIGNATURE_HASH_COLLISION,
            "value":      1,
            "tags":       {"step_id": step_id, "problem_id": problem_id},
            "timestamp":  datetime.now(timezone.utc).isoformat(),
            "severity":   "P0_CRITICAL"
        })
    )


def emit_crs_block(crs_value: float, problem_id: str = "unknown"):
    emit(M.CRS_BLOCK, 1, {"crs": crs_value, "problem_id": problem_id})
    emit(M.CRS_VALUE, crs_value)


def emit_crs_value(crs_value: float):
    emit(M.CRS_VALUE, crs_value)


def emit_suspicious_input(pattern: str, problem_id: str = "unknown"):
    emit(M.SUSPICIOUS_INPUT, 1, {"pattern": pattern[:80], "problem_id": problem_id})


def emit_pedagogical_drift(concept_tag: str, drift_score: float):
    """
    V7.2.5: Emitted when DiversityEngine detects narrative laziness.
    drift_score > 0.35 means one template variant is dominating selection.
    Alert DevOps: Renderer is repeating the same pedagogical phrasing — student experience degrades.
    """
    emit(M.PEDAGOGICAL_DRIFT, round(drift_score, 3), {"concept": concept_tag})


# ==================== TIMER CONTEXT MANAGER ====================

class SolverTimer:
    """
    Context manager for measuring Math Engine execution time.
    Usage:
        with SolverTimer() as t:
            result = sympy_solve(...)
        print(t.elapsed_ms)
    """
    def __enter__(self):
        self._start = time.time()
        return self

    def __exit__(self, *args):
        self.elapsed_ms = round((time.time() - self._start) * 1000, 2)
        emit(M.SOLVER_EXECUTION_MS, self.elapsed_ms)