File size: 10,009 Bytes
fa8d021
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
"""
Custom utility logging functions for Langfuse integration.
This module provides specialized formatters for all @observe decorated functions
and a conditional observe decorator that only applies when Langfuse is configured.
"""

import datetime
from collections import OrderedDict
from collections.abc import Callable
from typing import ParamSpec, TypeVar, overload

from fastapi import Request
from langfuse import observe
from rich import box
from rich.console import Console, Group, RenderableType
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.tree import Tree

from src.config import settings
from src.telemetry.metrics_collector import append_metrics_to_file
from src.utils.representation import (
    Representation,
)

# Global console instance for consistent formatting
console = Console(markup=True)

COLLECT_METRICS_LOCAL = settings.COLLECT_METRICS_LOCAL

P = ParamSpec("P")
R = TypeVar("R")


@overload
def conditional_observe(
    func: Callable[P, R],
) -> Callable[P, R]: ...


@overload
def conditional_observe(
    *,
    name: str,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...


def conditional_observe(
    func: Callable[P, R] | None = None,
    *,
    name: str | None = None,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
    """
    Conditionally apply the @observe decorator only when LANGFUSE_PUBLIC_KEY is present.

    Can be used in two ways:
    1. As a decorator: @conditional_observe
    2. As a decorator factory: @conditional_observe(name="...")

    Args:
        func: The function to potentially decorate (when used as @conditional_observe)
        name: Optional name for the observation (when used as @conditional_observe(name="..."))

    Returns:
        The decorated function if Langfuse is configured, otherwise the original function
    """

    def decorator(f: Callable[P, R]) -> Callable[P, R]:
        if settings.LANGFUSE_PUBLIC_KEY:
            observe_name = name if name is not None else f.__name__
            return observe(name=observe_name)(f)
        else:
            return f

    if func is not None:
        # Used as @conditional_observe (without parentheses)
        return decorator(func)
    else:
        # Used as @conditional_observe(name="...") (with parentheses and keyword args)
        return decorator


# Bounded OrderedDict for accumulated metrics to prevent memory leaks.
# If an exception occurs between accumulate_metric() and log_performance_metrics(),
# the metrics would stay in memory forever. Using OrderedDict allows us to evict
# the oldest entries (FIFO) when exceeding MAX_ACCUMULATED_TASKS.
MAX_ACCUMULATED_TASKS = 1000

# OrderedDict[task_name, list[tuple[metric_name, metric_value, metric_unit]]]
accumulated_metrics: OrderedDict[str, list[tuple[str, str | int | float, str]]] = (
    OrderedDict()
)


def format_reasoning_inputs_as_markdown(
    representation: Representation,
    history: str,
    new_turn: str,
    message_created_at: datetime.datetime,
) -> str:
    """
    Format reasoning inputs as markdown for logging.
    Args:
        representation: Current/working representation
        history: Conversation history
        new_turn: New user message
        message_created_at: Message timestamp
    Returns:
        Formatted markdown string
    """
    parts: list[str] = []

    parts.append("## Reasoning Inputs\n")
    parts.append(
        f"**Current Time**: {message_created_at.strftime('%Y-%m-%d %H:%M:%S')}"
    )
    parts.append("")

    # Add representation
    parts.append("### Current Representation\n")
    parts.append(representation.format_as_markdown())

    # Add history
    if history:
        parts.append("### Conversation History\n")
        parts.append(history.strip())
        parts.append("")

    # Add new turn
    if new_turn:
        parts.append("### New Turn\n")
        parts.append(new_turn.strip())
        parts.append("")

    return "\n".join(parts)


def log_representation(
    representation: Representation,
) -> None:
    """
    Log representation in a tree structure.
    Args:
        representation: Representation to log
    """
    tree = Tree("📊 REPRESENTATION")

    type_branch = tree.add(f"[bold cyan]EXPLICIT[/] ({len(representation.explicit)})")
    for i, obs in enumerate(representation.explicit, 1):
        type_branch.add(f"[dim]{i}.[/] {obs}")

    type_branch = tree.add(f"[bold cyan]DEDUCTIVE[/] ({len(representation.deductive)})")
    for i, obs in enumerate(representation.deductive, 1):
        type_branch.add(f"[dim]{i}.[/] {obs}")

    console.print(tree)
    console.print()


def accumulate_metric(
    task_name: str,
    label: str,
    value: str | int | float,
    unit: str,
) -> None:
    """
    Accumulate a metric value to be printed the next time log_performance_metrics is called.

    This function uses a bounded OrderedDict to prevent memory leaks. If the number of
    tracked tasks exceeds MAX_ACCUMULATED_TASKS, the oldest entries are evicted (FIFO).

    Args:
        task_name: Task identifier
        label: Metric label
        value: Metric value
        unit: Metric unit
    """
    # Evict oldest entries if we've exceeded the maximum to prevent memory leaks.
    # This handles the case where exceptions occur before log_performance_metrics is called.
    while (
        len(accumulated_metrics) >= MAX_ACCUMULATED_TASKS
        and task_name not in accumulated_metrics
    ):
        # popitem(last=False) removes the oldest (first inserted) entry
        accumulated_metrics.popitem(last=False)

    accumulated_metrics.setdefault(task_name, []).append((label, value, unit))


def log_token_usage_metrics(
    task_name: str,
    input_tokens: int,
    output_tokens: int,
    cache_read_input_tokens: int,
    cache_creation_input_tokens: int,
) -> None:
    """
    Log cache-aware token usage metrics.

    Args:
        task_name: The task name for metric accumulation
        input_tokens: Total input tokens (cached + uncached)
        output_tokens: Output tokens generated
        cache_read_input_tokens: Tokens read from cache (90% cheaper)
        cache_creation_input_tokens: Tokens written to cache (25% more expensive)

    Returns:
        None
    """
    accumulate_metric(task_name, "input_tokens", input_tokens, "tokens")
    accumulate_metric(
        task_name,
        "cache_read_input_tokens",
        cache_read_input_tokens,
        "tokens",
    )
    accumulate_metric(
        task_name,
        "cache_creation_input_tokens",
        cache_creation_input_tokens,
        "tokens",
    )
    # Total uncached tokens (what you're paying full price for)
    # = total - cache_read (those were cheap) + cache_creation (those cost 1.25x)
    uncached_input_tokens = (
        input_tokens - cache_read_input_tokens + cache_creation_input_tokens
    )
    accumulate_metric(
        task_name, "uncached_input_tokens", uncached_input_tokens, "tokens"
    )
    accumulate_metric(task_name, "output_tokens", output_tokens, "tokens")


def log_performance_metrics(
    task_slug: str,
    task_name: str,
    metrics: list[tuple[str, str | int | float, str]] | None = None,
    title: str = "⚡ PERFORMANCE",
) -> None:
    """
    Log performance metrics in a clean table and optionally send to global collector.

    Args:
        task_slug: Slug of the task that generated these metrics
        task_name: Name of the task that generated these metrics
        metrics: Dictionary of metric names and (value, unit) tuples
        title: Table title
    """
    task_name = f"{task_slug}_{task_name}"
    # No-op if metrics were evicted (due to MAX_ACCUMULATED_TASKS limit) and no
    # additional metrics are passed. This handles the case where an exception
    # caused old metrics to be evicted before log_performance_metrics was called.
    if not accumulated_metrics.get(task_name) and not metrics:
        return
    if metrics is None:
        metrics = []
    metrics = accumulated_metrics.pop(task_name, []) + metrics

    if COLLECT_METRICS_LOCAL:
        append_metrics_to_file(task_slug, task_name, metrics)

    # Remove metrics with "blob" unit type. They get printed separately below the table.
    blob_metrics: list[tuple[str, str | int | float, str]] = []
    non_blob_metrics: list[tuple[str, str | int | float, str]] = []
    for metric in metrics:
        (blob_metrics if metric[2] == "blob" else non_blob_metrics).append(metric)

    table = Table(
        show_header=True,
        header_style="bold green",
        box=None,
        padding=(0, 1),
    )
    table.add_column("Metric", style="cyan", width=30)
    table.add_column("Value", justify="right", style="yellow", width=15)
    table.add_column("Unit", style="dim", width=8)

    for metric, value, unit in non_blob_metrics:
        if unit == "ms":
            formatted_value = f"{value:.0f}"
        elif unit == "s":
            formatted_value = f"{value:.3f}"
        else:
            formatted_value = str(value)

        table.add_row(metric.replace("_", " ").title(), formatted_value, unit)

    # Build content for the panel
    content_items: list[RenderableType] = [table]

    if blob_metrics:
        for metric, value, _unit in blob_metrics:
            content_items.append(
                Text.assemble("    ", (f"\n{metric}:", "bold"), "     ")
            )
            content_items.append(Text(str(value)))

    panel = Panel(
        Group(*content_items),
        title=f"[bold green]{title} - {task_name}[/]",
        box=box.ROUNDED,
        padding=(1, 2),
        width=80,
    )

    console.print(panel)
    console.print()


def normalize_template_path(path: str) -> str:
    if path != "/" and path.endswith("/"):
        return path.rstrip("/")
    return path


def get_route_template(request: Request) -> str:
    route = request.scope.get("route")
    if route and getattr(route, "path", None):
        return normalize_template_path(route.path)
    return "unknown"