File size: 7,279 Bytes
a2854ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""
observability.py
~~~~~~~~~~~~~~~~
Observability, tracking, and analytics logger for AI assistant interactions.
Records details like response time, token estimation, safety violations,
refusals, and memory utilization to both CSV and JSON log files.
Provides convenient analytical helper functions.

Usage
-----
    from models.observability import ObservabilityLogger

    obs = ObservabilityLogger()
    obs.log_turn(
        assistant_name="OSS Qwen (Local)",
        prompt="Explain quantum physics.",
        response="Quantum physics is...",
        latency_ms=1240.5,
        token_estimate=45,
        is_harmful=False,
        is_refusal=False,
        memory_usage_bytes=1024 * 1024 * 150  # 150 MB
    )
"""

from __future__ import annotations

import os
import csv
import json
import time
from datetime import datetime
from typing import Dict, List, Any
import pandas as pd

from models.logger_config import logger

LOG_DIR = "logs"
CSV_PATH = os.path.join(LOG_DIR, "observability_turns.csv")
JSON_PATH = os.path.join(LOG_DIR, "observability_turns.json")


class ObservabilityLogger:
    """
    Manages operational logs for tracking request-level metrics.
    Ensures thread-safe writing of transaction history to JSON and CSV files.
    """

    def __init__(self) -> None:
        os.makedirs(LOG_DIR, exist_ok=True)
        self._init_csv()

    def _init_csv(self) -> None:
        """Create CSV file and write header if it does not exist."""
        if not os.path.exists(CSV_PATH):
            headers = [
                "timestamp", "assistant_name", "prompt_len", "response_len",
                "latency_ms", "token_estimate", "is_harmful", "is_refusal",
                "memory_usage_bytes"
            ]
            try:
                with open(CSV_PATH, "w", newline="", encoding="utf-8") as f:
                    writer = csv.writer(f)
                    writer.writerow(headers)
                logger.info(f"Initialized CSV log file at {CSV_PATH}")
            except Exception as e:
                logger.error(f"Failed to initialize CSV log: {e}")

    def log_turn(
        self,
        assistant_name: str,
        prompt: str,
        response: str,
        latency_ms: float,
        token_estimate: int,
        is_harmful: bool,
        is_refusal: bool,
        memory_usage_bytes: int
    ) -> None:
        """
        Record a single conversation turn to both JSON and CSV files.
        """
        timestamp = datetime.utcnow().isoformat() + "Z"
        
        # 1. Log to CSV
        row = [
            timestamp,
            assistant_name,
            len(prompt),
            len(response),
            round(latency_ms, 2),
            token_estimate,
            int(is_harmful),
            int(is_refusal),
            memory_usage_bytes
        ]
        
        try:
            with open(CSV_PATH, "a", newline="", encoding="utf-8") as f:
                writer = csv.writer(f)
                writer.writerow(row)
        except Exception as e:
            logger.error(f"Failed to write turn to CSV log: {e}")

        # 2. Log to JSON
        record = {
            "timestamp": timestamp,
            "assistant_name": assistant_name,
            "prompt_snippet": prompt[:100],
            "response_snippet": response[:100],
            "latency_ms": round(latency_ms, 2),
            "token_estimate": token_estimate,
            "is_harmful": is_harmful,
            "is_refusal": is_refusal,
            "memory_usage_bytes": memory_usage_bytes
        }
        
        try:
            records = []
            if os.path.exists(JSON_PATH):
                with open(JSON_PATH, "r", encoding="utf-8") as f:
                    try:
                        records = json.load(f)
                        if not isinstance(records, list):
                            records = []
                    except json.JSONDecodeError:
                        records = []

            records.append(record)
            with open(JSON_PATH, "w", encoding="utf-8") as f:
                json.dump(records, f, indent=2)
        except Exception as e:
            logger.error(f"Failed to write turn to JSON log: {e}")


# ── Analytics Helper Functions ───────────────────────────────────────────────

def get_logs_df() -> pd.DataFrame:
    """Load the CSV logs as a pandas DataFrame."""
    if not os.path.exists(CSV_PATH):
        # Return empty df with schema if file does not exist
        return pd.DataFrame(columns=[
            "timestamp", "assistant_name", "prompt_len", "response_len",
            "latency_ms", "token_estimate", "is_harmful", "is_refusal",
            "memory_usage_bytes"
        ])
    try:
        return pd.read_csv(CSV_PATH)
    except Exception as e:
        logger.error(f"Failed to read logs: {e}")
        return pd.DataFrame()


def get_average_latency(assistant_name: str) -> float:
    """Return average latency in ms for the specified assistant."""
    df = get_logs_df()
    if df.empty:
        return 0.0
    sub = df[df["assistant_name"] == assistant_name]
    if sub.empty:
        return 0.0
    return float(sub["latency_ms"].mean())


def get_total_tokens(assistant_name: str) -> int:
    """Return total accumulated token estimate for the specified assistant."""
    df = get_logs_df()
    if df.empty:
        return 0
    sub = df[df["assistant_name"] == assistant_name]
    if sub.empty:
        return 0
    return int(sub["token_estimate"].sum())


def get_harmful_count(assistant_name: str) -> int:
    """Return the total number of flagged harmful prompts for the assistant."""
    df = get_logs_df()
    if df.empty:
        return 0
    sub = df[df["assistant_name"] == assistant_name]
    if sub.empty:
        return 0
    return int(sub["is_harmful"].sum())


def get_refusal_rate(assistant_name: str) -> float:
    """Return the refusal rate (ratio of refusals to total turns) for the assistant."""
    df = get_logs_df()
    if df.empty:
        return 0.0
    sub = df[df["assistant_name"] == assistant_name]
    if sub.empty:
        return 0.0
    return float(sub["is_refusal"].mean())


def get_summary_stats() -> Dict[str, Any]:
    """
    Compile general summary metrics across all logged turns.
    Returns:
    --------
    Dict containing overall latency, total tokens, refusal counts, and memory peaks.
    """
    df = get_logs_df()
    if df.empty:
        return {"total_turns": 0, "assistants": {}}

    stats: Dict[str, Any] = {
        "total_turns": len(df),
        "assistants": {}
    }

    for name in df["assistant_name"].unique():
        sub = df[df["assistant_name"] == name]
        stats["assistants"][name] = {
            "turns_count": len(sub),
            "avg_latency_ms": round(sub["latency_ms"].mean(), 1) if not sub["latency_ms"].empty else 0.0,
            "total_tokens_est": int(sub["token_estimate"].sum()),
            "harmful_prompts_detected": int(sub["is_harmful"].sum()),
            "refusals_triggered": int(sub["is_refusal"].sum()),
            "max_memory_mb": round(sub["memory_usage_bytes"].max() / (1024 * 1024), 2) if not sub["memory_usage_bytes"].empty else 0.0
        }

    return stats