frugal-sdk-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frugal_metrics/__init__.py +13 -0
- frugal_metrics/block_hash_lru.py +75 -0
- frugal_metrics/caller.py +213 -0
- frugal_metrics/config.py +231 -0
- frugal_metrics/instrument.py +268 -0
- frugal_metrics/otel.py +321 -0
- frugal_metrics/prompt_analyzer.py +273 -0
- frugal_metrics/safe.py +34 -0
- frugal_metrics/semconv.py +70 -0
- frugal_metrics/streams.py +172 -0
- frugal_metrics/wrappers/__init__.py +1 -0
- frugal_metrics/wrappers/anthropic.py +277 -0
- frugal_metrics/wrappers/bedrock.py +358 -0
- frugal_metrics/wrappers/bedrock_models.py +430 -0
- frugal_metrics/wrappers/openai.py +465 -0
- frugal_sdk_python-0.1.0.dist-info/METADATA +195 -0
- frugal_sdk_python-0.1.0.dist-info/RECORD +18 -0
- frugal_sdk_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""frugal-metrics: call-site instrumentation for AI API calls."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from frugal_metrics.wrappers.openai import wrap_openai
|
|
6
|
+
from frugal_metrics.wrappers.anthropic import wrap_anthropic
|
|
7
|
+
from frugal_metrics.wrappers.bedrock import wrap_bedrock
|
|
8
|
+
|
|
9
|
+
__all__ = ["wrap_openai", "wrap_anthropic", "wrap_bedrock"]
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("frugal-sdk-python")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Per-call-site LRU storing the previous call's block hashes.
|
|
2
|
+
|
|
3
|
+
Used by the prompt analyzer to derive cache-prefix-stable signals without
|
|
4
|
+
leaving raw hashes anywhere outside the process. Default capacity matches the
|
|
5
|
+
existing call-site cardinality cap so a busy process can't blow memory by
|
|
6
|
+
tracking thousands of distinct sites.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class BlockHashes:
|
|
18
|
+
system_hash: str | None = None
|
|
19
|
+
user_hash: str | None = None
|
|
20
|
+
tools_hash: str | None = None
|
|
21
|
+
schema_hash: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BlockHashLRU:
|
|
25
|
+
def __init__(self, capacity: int = 500) -> None:
|
|
26
|
+
self._capacity = capacity
|
|
27
|
+
self._map: "OrderedDict[str, BlockHashes]" = OrderedDict()
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
def get(self, call_site_id: str) -> BlockHashes | None:
|
|
31
|
+
with self._lock:
|
|
32
|
+
entry = self._map.get(call_site_id)
|
|
33
|
+
if entry is None:
|
|
34
|
+
return None
|
|
35
|
+
self._map.move_to_end(call_site_id)
|
|
36
|
+
return entry
|
|
37
|
+
|
|
38
|
+
def set(self, call_site_id: str, hashes: BlockHashes) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
if call_site_id in self._map:
|
|
41
|
+
self._map.move_to_end(call_site_id)
|
|
42
|
+
self._map[call_site_id] = hashes
|
|
43
|
+
return
|
|
44
|
+
self._map[call_site_id] = hashes
|
|
45
|
+
if self._capacity > 0:
|
|
46
|
+
while len(self._map) > self._capacity:
|
|
47
|
+
self._map.popitem(last=False)
|
|
48
|
+
|
|
49
|
+
def size(self) -> int:
|
|
50
|
+
with self._lock:
|
|
51
|
+
return len(self._map)
|
|
52
|
+
|
|
53
|
+
def clear(self) -> None:
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._map.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_shared: BlockHashLRU | None = None
|
|
59
|
+
_shared_lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_shared_block_hash_lru(capacity: int | None = None) -> BlockHashLRU:
|
|
63
|
+
global _shared
|
|
64
|
+
if _shared is not None:
|
|
65
|
+
return _shared
|
|
66
|
+
with _shared_lock:
|
|
67
|
+
if _shared is None:
|
|
68
|
+
_shared = BlockHashLRU(capacity if capacity is not None else 500)
|
|
69
|
+
return _shared
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def reset_shared_block_hash_lru_for_tests() -> None:
|
|
73
|
+
global _shared
|
|
74
|
+
with _shared_lock:
|
|
75
|
+
_shared = None
|
frugal_metrics/caller.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Stack walking to resolve the call site of a wrapped AI call.
|
|
2
|
+
|
|
3
|
+
Algorithm: starting from the frame above the wrapper, walk upward until we find
|
|
4
|
+
a frame whose source file is not (a) inside our own package, (b) inside the
|
|
5
|
+
wrapped SDK's package, or (c) under any FRUGAL_PATH_BLOCK_LIST prefix. Return
|
|
6
|
+
the file relative to FRUGAL_REPO_ROOT plus the function name.
|
|
7
|
+
|
|
8
|
+
Performance: realpath results and full caller resolutions are cached so the
|
|
9
|
+
stack walk and path resolution only happen once per unique call site. Subsequent
|
|
10
|
+
calls from the same site are a dict lookup.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from frugal_metrics.semconv import (
|
|
21
|
+
CALLER_FILE_EXTERNAL,
|
|
22
|
+
CALLER_FILE_UNKNOWN,
|
|
23
|
+
CALLER_OVERFLOW,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# One entry per distinct (filename, function) pair that ever calls a wrapped AI
|
|
27
|
+
# method. Even a very large codebase has <10k unique sites.
|
|
28
|
+
_CACHE_MAX_SIZE = 10_000
|
|
29
|
+
|
|
30
|
+
_realpath_cache: dict[str, str] = {}
|
|
31
|
+
_realpath_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
# Keyed on (raw_filename, function_name) — the skip_dirs, block_list, and
|
|
34
|
+
# repo_root are stable for the lifetime of a process (they come from env vars
|
|
35
|
+
# loaded once at config time), so we don't need them in the key.
|
|
36
|
+
_caller_cache: dict[tuple[str, str], CallerInfo | None] = {}
|
|
37
|
+
_caller_cache_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
# Cardinality cap state — first-come-first-served. Once `_seen_call_sites`
|
|
40
|
+
# hits the configured max, subsequent unseen pairs overflow (partial if the
|
|
41
|
+
# file is already in `_seen_files`, full otherwise).
|
|
42
|
+
_seen_call_sites: set[tuple[str, str]] = set()
|
|
43
|
+
_seen_files: set[str] = set()
|
|
44
|
+
_cap_state_lock = threading.Lock()
|
|
45
|
+
_cap_hit_notified = False
|
|
46
|
+
_cap_hit_pending = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CallerInfo:
|
|
51
|
+
file: str
|
|
52
|
+
function: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_caller(
|
|
56
|
+
*,
|
|
57
|
+
skip_package_dirs: tuple[str, ...],
|
|
58
|
+
block_list: tuple[str, ...],
|
|
59
|
+
repo_root: str,
|
|
60
|
+
max_call_sites: int = 0,
|
|
61
|
+
) -> CallerInfo:
|
|
62
|
+
"""Walk the stack for the first frame outside our wrapper / wrapped SDK / block list.
|
|
63
|
+
|
|
64
|
+
Results are cached by (raw_filename, function_name) so the expensive path
|
|
65
|
+
resolution only runs once per call site.
|
|
66
|
+
"""
|
|
67
|
+
frame = sys._getframe(1)
|
|
68
|
+
# Skip the wrapper frame that called us.
|
|
69
|
+
if frame is not None:
|
|
70
|
+
frame = frame.f_back
|
|
71
|
+
|
|
72
|
+
while frame is not None:
|
|
73
|
+
raw_filename = frame.f_code.co_filename
|
|
74
|
+
function = frame.f_code.co_name
|
|
75
|
+
|
|
76
|
+
# Fast path: check the caller cache before doing any path work.
|
|
77
|
+
cache_key = (raw_filename, function)
|
|
78
|
+
cached = _caller_cache.get(cache_key)
|
|
79
|
+
if cached is not None:
|
|
80
|
+
return cached
|
|
81
|
+
|
|
82
|
+
abs_filename = _cached_realpath(raw_filename) if raw_filename else ""
|
|
83
|
+
|
|
84
|
+
if _is_in_any(abs_filename, skip_package_dirs):
|
|
85
|
+
# Cache this as a "skip" so we don't realpath again next time.
|
|
86
|
+
_cache_put(cache_key, None)
|
|
87
|
+
frame = frame.f_back
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
rel = _repo_relative(abs_filename, repo_root)
|
|
91
|
+
if rel is not None and _is_blocked(rel, block_list):
|
|
92
|
+
_cache_put(cache_key, None)
|
|
93
|
+
frame = frame.f_back
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
resolved_file = CALLER_FILE_EXTERNAL if rel is None else rel
|
|
97
|
+
result = _apply_cap(resolved_file, function, max_call_sites)
|
|
98
|
+
|
|
99
|
+
_cache_put(cache_key, result)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
return CallerInfo(file=CALLER_FILE_UNKNOWN, function="<unknown>")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _apply_cap(file: str, function: str, max_call_sites: int) -> CallerInfo:
|
|
106
|
+
global _cap_hit_notified, _cap_hit_pending
|
|
107
|
+
if max_call_sites <= 0:
|
|
108
|
+
return CallerInfo(file=file, function=function)
|
|
109
|
+
key = (file, function)
|
|
110
|
+
# Fast path — already known, no lock needed for read (sets are thread-safe
|
|
111
|
+
# for membership in CPython; we take the lock only on mutation).
|
|
112
|
+
if key in _seen_call_sites:
|
|
113
|
+
return CallerInfo(file=file, function=function)
|
|
114
|
+
with _cap_state_lock:
|
|
115
|
+
# Re-check under the lock.
|
|
116
|
+
if key in _seen_call_sites:
|
|
117
|
+
return CallerInfo(file=file, function=function)
|
|
118
|
+
if len(_seen_call_sites) < max_call_sites:
|
|
119
|
+
_seen_call_sites.add(key)
|
|
120
|
+
_seen_files.add(file)
|
|
121
|
+
return CallerInfo(file=file, function=function)
|
|
122
|
+
# At cap.
|
|
123
|
+
if not _cap_hit_notified:
|
|
124
|
+
_cap_hit_notified = True
|
|
125
|
+
_cap_hit_pending = True
|
|
126
|
+
if file in _seen_files:
|
|
127
|
+
return CallerInfo(file=file, function=CALLER_OVERFLOW)
|
|
128
|
+
return CallerInfo(file=CALLER_OVERFLOW, function=CALLER_OVERFLOW)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def consume_cap_hit_event() -> bool:
|
|
132
|
+
"""Returns True once per process when the cap is first crossed.
|
|
133
|
+
|
|
134
|
+
The instrument layer calls this after each resolve; on True it emits a
|
|
135
|
+
single `frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}`.
|
|
136
|
+
"""
|
|
137
|
+
global _cap_hit_pending
|
|
138
|
+
with _cap_state_lock:
|
|
139
|
+
if _cap_hit_pending:
|
|
140
|
+
_cap_hit_pending = False
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cached_realpath(path: str) -> str:
|
|
146
|
+
cached = _realpath_cache.get(path)
|
|
147
|
+
if cached is not None:
|
|
148
|
+
return cached
|
|
149
|
+
resolved = os.path.realpath(path)
|
|
150
|
+
with _realpath_lock:
|
|
151
|
+
if len(_realpath_cache) < _CACHE_MAX_SIZE:
|
|
152
|
+
_realpath_cache[path] = resolved
|
|
153
|
+
return resolved
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cache_put(key: tuple[str, str], value: CallerInfo | None) -> None:
|
|
157
|
+
with _caller_cache_lock:
|
|
158
|
+
if len(_caller_cache) < _CACHE_MAX_SIZE:
|
|
159
|
+
_caller_cache[key] = value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_in_any(abs_filename: str, dirs: tuple[str, ...]) -> bool:
|
|
163
|
+
if not abs_filename:
|
|
164
|
+
return False
|
|
165
|
+
for d in dirs:
|
|
166
|
+
if not d:
|
|
167
|
+
continue
|
|
168
|
+
if abs_filename == d or abs_filename.startswith(d.rstrip(os.sep) + os.sep):
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _repo_relative(abs_filename: str, repo_root: str) -> str | None:
|
|
174
|
+
if not abs_filename:
|
|
175
|
+
return None
|
|
176
|
+
root = repo_root.rstrip(os.sep)
|
|
177
|
+
if abs_filename == root:
|
|
178
|
+
return ""
|
|
179
|
+
prefix = root + os.sep
|
|
180
|
+
if abs_filename.startswith(prefix):
|
|
181
|
+
return abs_filename[len(prefix):]
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_blocked(rel_path: str, block_list: tuple[str, ...]) -> bool:
|
|
186
|
+
for entry in block_list:
|
|
187
|
+
if not entry:
|
|
188
|
+
continue
|
|
189
|
+
if rel_path == entry or rel_path.startswith(entry.rstrip("/") + "/"):
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def package_dir_for(module: object) -> str:
|
|
195
|
+
"""Return the absolute directory of a module, suitable for skip_package_dirs."""
|
|
196
|
+
module_file = getattr(module, "__file__", None)
|
|
197
|
+
if not module_file:
|
|
198
|
+
return ""
|
|
199
|
+
return os.path.realpath(os.path.dirname(module_file))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def reset_caches_for_tests() -> None:
|
|
203
|
+
"""Clear all caches — test-only hook."""
|
|
204
|
+
global _cap_hit_notified, _cap_hit_pending
|
|
205
|
+
with _realpath_lock:
|
|
206
|
+
_realpath_cache.clear()
|
|
207
|
+
with _caller_cache_lock:
|
|
208
|
+
_caller_cache.clear()
|
|
209
|
+
with _cap_state_lock:
|
|
210
|
+
_seen_call_sites.clear()
|
|
211
|
+
_seen_files.clear()
|
|
212
|
+
_cap_hit_notified = False
|
|
213
|
+
_cap_hit_pending = False
|
frugal_metrics/config.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Environment-variable configuration loading.
|
|
2
|
+
|
|
3
|
+
All configuration comes from env vars — no init() API. If any required variable
|
|
4
|
+
is missing, load_config() returns None and the wrappers no-op (returning the
|
|
5
|
+
client unwrapped). A single warning is logged on the first no-op.
|
|
6
|
+
|
|
7
|
+
Minimum required production config (two env vars):
|
|
8
|
+
FRUGAL_API_KEY — per-customer bearer token (Frugal mints these per tenant)
|
|
9
|
+
FRUGAL_PROJECT_ID — the Frugal project this deployment belongs to. Stats are
|
|
10
|
+
attributed per project (file/function is unique within a
|
|
11
|
+
project), so the same repo deployed across microservices
|
|
12
|
+
no longer collides. Frugal shows this value in the UI.
|
|
13
|
+
|
|
14
|
+
Everything else has sensible defaults:
|
|
15
|
+
- Endpoint defaults to https://metrics.frugal.co/opentelemetry/v1/metrics
|
|
16
|
+
- REPO_ROOT defaults to os.getcwd() (the strip prefix for caller_file)
|
|
17
|
+
- CUSTOMER_ID is optional; when unset, the tenant label (auto-injected
|
|
18
|
+
server-side by VMAuth from the bearer token) is the canonical customer
|
|
19
|
+
identifier and the frugal.customer_id label is omitted.
|
|
20
|
+
- COMPONENTS is optional; a comma-separated list (e.g. "ai-handler,reco").
|
|
21
|
+
When set, every metric is ALSO emitted once per component (tagged with
|
|
22
|
+
frugal.component) on top of the base project-level series, so usage can be
|
|
23
|
+
viewed for the whole project (component absent) or split by component.
|
|
24
|
+
|
|
25
|
+
The older env vars (FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
26
|
+
FRUGAL_OTEL_EXPORTER_OTLP_HEADERS, FRUGAL_REPO_ROOT, FRUGAL_CUSTOMER_ID,
|
|
27
|
+
plus the standard OTel ones) all still work — explicit values override the
|
|
28
|
+
defaults so existing customers don't need to change anything.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import threading
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("frugal_metrics")
|
|
39
|
+
|
|
40
|
+
_ENV_API_KEY = "FRUGAL_API_KEY"
|
|
41
|
+
_ENV_CUSTOMER_ID = "FRUGAL_CUSTOMER_ID"
|
|
42
|
+
_ENV_PROJECT_ID = "FRUGAL_PROJECT_ID"
|
|
43
|
+
_ENV_COMPONENTS = "FRUGAL_COMPONENTS"
|
|
44
|
+
_ENV_REPO_ROOT = "FRUGAL_REPO_ROOT"
|
|
45
|
+
_ENV_PATH_BLOCK_LIST = "FRUGAL_PATH_BLOCK_LIST"
|
|
46
|
+
_ENV_DISABLED = "FRUGAL_DISABLED"
|
|
47
|
+
|
|
48
|
+
_ENV_FRUGAL_OTLP_ENDPOINT = "FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
49
|
+
_ENV_FRUGAL_OTLP_HEADERS = "FRUGAL_OTEL_EXPORTER_OTLP_HEADERS"
|
|
50
|
+
_ENV_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
51
|
+
_ENV_OTLP_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"
|
|
52
|
+
_ENV_EXPORT_INTERVAL_MS = "FRUGAL_METRIC_EXPORT_INTERVAL_MS"
|
|
53
|
+
_ENV_MAX_CALL_SITES = "FRUGAL_MAX_CALL_SITES"
|
|
54
|
+
_ENV_PROMPT_ANALYSIS_SAMPLE_RATE = "FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE"
|
|
55
|
+
|
|
56
|
+
_DEFAULT_OTLP_ENDPOINT = "https://metrics.frugal.co/opentelemetry/v1/metrics"
|
|
57
|
+
_DEFAULT_MAX_CALL_SITES = 500
|
|
58
|
+
_DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE = 0.1
|
|
59
|
+
|
|
60
|
+
_warned_once = False
|
|
61
|
+
_warned_lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Config:
|
|
66
|
+
# Optional now — when None the tenant label (auto-injected by VMAuth)
|
|
67
|
+
# identifies the customer.
|
|
68
|
+
customer_id: str | None
|
|
69
|
+
project_id: str
|
|
70
|
+
repo_root: str
|
|
71
|
+
# Optional logical components; metrics fan out one extra series per entry.
|
|
72
|
+
components: tuple[str, ...] = field(default_factory=tuple)
|
|
73
|
+
path_block_list: tuple[str, ...] = field(default_factory=tuple)
|
|
74
|
+
otlp_endpoint: str = _DEFAULT_OTLP_ENDPOINT
|
|
75
|
+
otlp_headers: str | None = None
|
|
76
|
+
export_interval_ms: int = 60_000
|
|
77
|
+
# Max distinct (file, function) combinations to attribute before overflow.
|
|
78
|
+
# 0 disables the cap.
|
|
79
|
+
max_call_sites: int = _DEFAULT_MAX_CALL_SITES
|
|
80
|
+
# Fraction of analyzed calls to emit prompt-analysis metrics for, 0..1.
|
|
81
|
+
prompt_analysis_sample_rate: float = _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _truthy(value: str | None) -> bool:
|
|
85
|
+
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _warn_once(message: str) -> None:
|
|
89
|
+
global _warned_once
|
|
90
|
+
with _warned_lock:
|
|
91
|
+
if _warned_once:
|
|
92
|
+
return
|
|
93
|
+
_warned_once = True
|
|
94
|
+
logger.warning("frugal-metrics disabled: %s", message)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_config() -> Config | None:
|
|
98
|
+
"""Read env vars and return a Config, or None if disabled or misconfigured.
|
|
99
|
+
|
|
100
|
+
Side effect: logs a single warning the first time we return None due to
|
|
101
|
+
missing required vars. Repeated calls stay silent.
|
|
102
|
+
"""
|
|
103
|
+
if _truthy(os.environ.get(_ENV_DISABLED)):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
customer_id = os.environ.get(_ENV_CUSTOMER_ID) or None
|
|
107
|
+
project_id = os.environ.get(_ENV_PROJECT_ID)
|
|
108
|
+
repo_root_env = os.environ.get(_ENV_REPO_ROOT)
|
|
109
|
+
api_key = os.environ.get(_ENV_API_KEY)
|
|
110
|
+
|
|
111
|
+
# Explicit headers override anything we'd build from the API key — keeps
|
|
112
|
+
# existing deployments that set FRUGAL_OTEL_EXPORTER_OTLP_HEADERS directly
|
|
113
|
+
# working unchanged.
|
|
114
|
+
explicit_headers = (
|
|
115
|
+
os.environ.get(_ENV_FRUGAL_OTLP_HEADERS)
|
|
116
|
+
or os.environ.get(_ENV_OTLP_HEADERS)
|
|
117
|
+
)
|
|
118
|
+
if explicit_headers:
|
|
119
|
+
otlp_headers: str | None = explicit_headers
|
|
120
|
+
elif api_key:
|
|
121
|
+
otlp_headers = f"Authorization=Bearer {api_key}"
|
|
122
|
+
else:
|
|
123
|
+
otlp_headers = None
|
|
124
|
+
|
|
125
|
+
# Required for any useful run: PROJECT_ID (the attribution key) and SOME auth
|
|
126
|
+
# (either FRUGAL_API_KEY or an explicit OTLP_HEADERS containing the bearer).
|
|
127
|
+
missing = []
|
|
128
|
+
if not project_id:
|
|
129
|
+
missing.append(_ENV_PROJECT_ID)
|
|
130
|
+
if not otlp_headers:
|
|
131
|
+
missing.append(f"{_ENV_API_KEY} or {_ENV_FRUGAL_OTLP_HEADERS}")
|
|
132
|
+
if missing:
|
|
133
|
+
_warn_once(f"missing required env vars: {', '.join(missing)}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# Normalize repo_root to a real absolute path so caller-file relativization
|
|
137
|
+
# is consistent even when the process sees symlinked mounts (e.g. macOS
|
|
138
|
+
# /var -> /private/var). Default to os.getcwd() — works for the standard
|
|
139
|
+
# container pattern where WORKDIR is set to the source-code root.
|
|
140
|
+
repo_root_raw = repo_root_env or os.getcwd()
|
|
141
|
+
repo_root_abs = os.path.realpath(repo_root_raw)
|
|
142
|
+
|
|
143
|
+
block_list_raw = os.environ.get(_ENV_PATH_BLOCK_LIST, "")
|
|
144
|
+
block_list = tuple(
|
|
145
|
+
_normalize_block_entry(entry)
|
|
146
|
+
for entry in block_list_raw.split(",")
|
|
147
|
+
if entry.strip()
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Optional comma-separated logical components (e.g. "ai-handler,reco"). Each
|
|
151
|
+
# becomes an extra per-component series on top of the base project series.
|
|
152
|
+
components_raw = os.environ.get(_ENV_COMPONENTS, "")
|
|
153
|
+
components = tuple(
|
|
154
|
+
entry.strip() for entry in components_raw.split(",") if entry.strip()
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
otlp_endpoint = (
|
|
158
|
+
os.environ.get(_ENV_FRUGAL_OTLP_ENDPOINT)
|
|
159
|
+
or os.environ.get(_ENV_OTLP_ENDPOINT)
|
|
160
|
+
or _DEFAULT_OTLP_ENDPOINT
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
export_interval_ms = _parse_int(
|
|
164
|
+
os.environ.get(_ENV_EXPORT_INTERVAL_MS), default=60_000
|
|
165
|
+
)
|
|
166
|
+
max_call_sites = _parse_int(
|
|
167
|
+
os.environ.get(_ENV_MAX_CALL_SITES),
|
|
168
|
+
default=_DEFAULT_MAX_CALL_SITES,
|
|
169
|
+
)
|
|
170
|
+
if max_call_sites < 0:
|
|
171
|
+
max_call_sites = _DEFAULT_MAX_CALL_SITES
|
|
172
|
+
|
|
173
|
+
sample_rate = _parse_float(
|
|
174
|
+
os.environ.get(_ENV_PROMPT_ANALYSIS_SAMPLE_RATE),
|
|
175
|
+
default=_DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE,
|
|
176
|
+
)
|
|
177
|
+
if sample_rate < 0 or sample_rate > 1:
|
|
178
|
+
sample_rate = _DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE
|
|
179
|
+
|
|
180
|
+
return Config(
|
|
181
|
+
customer_id=customer_id,
|
|
182
|
+
project_id=project_id, # type: ignore[arg-type]
|
|
183
|
+
repo_root=repo_root_abs,
|
|
184
|
+
components=components,
|
|
185
|
+
path_block_list=block_list,
|
|
186
|
+
otlp_endpoint=otlp_endpoint,
|
|
187
|
+
otlp_headers=otlp_headers,
|
|
188
|
+
export_interval_ms=export_interval_ms,
|
|
189
|
+
max_call_sites=max_call_sites,
|
|
190
|
+
prompt_analysis_sample_rate=sample_rate,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _normalize_block_entry(entry: str) -> str:
|
|
195
|
+
stripped = entry.strip().strip("/")
|
|
196
|
+
return stripped
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_int(raw: str | None, *, default: int) -> int:
|
|
200
|
+
if raw is None or not raw.strip():
|
|
201
|
+
return default
|
|
202
|
+
try:
|
|
203
|
+
return int(raw)
|
|
204
|
+
except ValueError:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"frugal-metrics: could not parse int env var value %r, using default %d",
|
|
207
|
+
raw,
|
|
208
|
+
default,
|
|
209
|
+
)
|
|
210
|
+
return default
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_float(raw: str | None, *, default: float) -> float:
|
|
214
|
+
if raw is None or not raw.strip():
|
|
215
|
+
return default
|
|
216
|
+
try:
|
|
217
|
+
return float(raw)
|
|
218
|
+
except ValueError:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"frugal-metrics: could not parse float env var value %r, using default %s",
|
|
221
|
+
raw,
|
|
222
|
+
default,
|
|
223
|
+
)
|
|
224
|
+
return default
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def reset_warning_state_for_tests() -> None:
|
|
228
|
+
"""Test-only hook to re-arm the one-shot warning."""
|
|
229
|
+
global _warned_once
|
|
230
|
+
with _warned_lock:
|
|
231
|
+
_warned_once = False
|