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,268 @@
|
|
|
1
|
+
"""Shared instrumentation helpers used by per-provider wrappers.
|
|
2
|
+
|
|
3
|
+
A wrapper calls `start_call()` at the top of its closure to resolve the caller
|
|
4
|
+
and start a timer, then calls `emit.success(...)` on return or `emit.error(...)`
|
|
5
|
+
on exception. We deliberately avoid `@contextmanager` — a contextlib frame on
|
|
6
|
+
the stack between the wrapper and the caller would be picked as the call site.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from frugal_metrics import semconv
|
|
17
|
+
from frugal_metrics.block_hash_lru import BlockHashes, get_shared_block_hash_lru
|
|
18
|
+
from frugal_metrics.caller import (
|
|
19
|
+
CallerInfo,
|
|
20
|
+
consume_cap_hit_event,
|
|
21
|
+
resolve_caller,
|
|
22
|
+
)
|
|
23
|
+
from frugal_metrics.config import Config
|
|
24
|
+
from frugal_metrics.otel import Instruments, get_instruments
|
|
25
|
+
from frugal_metrics.prompt_analyzer import (
|
|
26
|
+
RequestPayload,
|
|
27
|
+
analyze_request,
|
|
28
|
+
should_sample,
|
|
29
|
+
structured_output_mode,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("frugal_metrics")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CallContext:
|
|
37
|
+
system: str
|
|
38
|
+
operation: str
|
|
39
|
+
request_model: str
|
|
40
|
+
skip_package_dirs: tuple[str, ...]
|
|
41
|
+
# True when this call is a batch-API submission, False for normal sync/streaming.
|
|
42
|
+
batched: bool = False
|
|
43
|
+
# Captured request kwargs for prompt analysis + cap utilization.
|
|
44
|
+
request_payload: RequestPayload | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class UsageReport:
|
|
49
|
+
response_model: str | None = None
|
|
50
|
+
input_tokens: int | None = None
|
|
51
|
+
output_tokens: int | None = None
|
|
52
|
+
cache_read_tokens: int | None = None
|
|
53
|
+
cache_write_tokens: int | None = None
|
|
54
|
+
reasoning_tokens: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _base_attrs(config: Config, ctx: CallContext, caller: CallerInfo) -> dict[str, Any]:
|
|
58
|
+
attrs: dict[str, Any] = {
|
|
59
|
+
semconv.GEN_AI_SYSTEM: ctx.system,
|
|
60
|
+
semconv.GEN_AI_OPERATION_NAME: ctx.operation,
|
|
61
|
+
semconv.GEN_AI_REQUEST_MODEL: ctx.request_model,
|
|
62
|
+
semconv.FRUGAL_PROJECT_ID: config.project_id,
|
|
63
|
+
semconv.FRUGAL_CALLER_FILE: caller.file,
|
|
64
|
+
semconv.FRUGAL_CALLER_FUNCTION: caller.function,
|
|
65
|
+
semconv.FRUGAL_BATCHED: ctx.batched,
|
|
66
|
+
}
|
|
67
|
+
if config.customer_id:
|
|
68
|
+
attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
69
|
+
return attrs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def start_call(
|
|
73
|
+
config: Config,
|
|
74
|
+
ctx: CallContext,
|
|
75
|
+
*,
|
|
76
|
+
caller_resolver: Callable[[], CallerInfo] | None = None,
|
|
77
|
+
) -> "CallEmitter":
|
|
78
|
+
"""Resolve the caller, start the timer, return an emitter.
|
|
79
|
+
|
|
80
|
+
Must be called directly from the wrapper closure so the stack walk sees the
|
|
81
|
+
user's frame immediately after our wrapper frame (no intermediate helpers).
|
|
82
|
+
"""
|
|
83
|
+
instruments = get_instruments(config)
|
|
84
|
+
resolver = caller_resolver or default_caller_resolver(config, ctx)
|
|
85
|
+
caller = resolver()
|
|
86
|
+
if consume_cap_hit_event():
|
|
87
|
+
try:
|
|
88
|
+
cap_attrs: dict[str, Any] = {
|
|
89
|
+
semconv.FRUGAL_PROJECT_ID: config.project_id,
|
|
90
|
+
"frugal.internal_error.stage": "cardinality_cap_hit",
|
|
91
|
+
}
|
|
92
|
+
if config.customer_id:
|
|
93
|
+
cap_attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
94
|
+
instruments.internal_errors.add(1, cap_attrs)
|
|
95
|
+
except Exception: # noqa: BLE001
|
|
96
|
+
# Never let our own metric emission break a customer call.
|
|
97
|
+
pass
|
|
98
|
+
return CallEmitter(
|
|
99
|
+
config=config,
|
|
100
|
+
ctx=ctx,
|
|
101
|
+
caller=caller,
|
|
102
|
+
instruments=instruments,
|
|
103
|
+
start=time.perf_counter(),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_caller_resolver(
|
|
108
|
+
config: Config, ctx: CallContext
|
|
109
|
+
) -> Callable[[], CallerInfo]:
|
|
110
|
+
def resolve() -> CallerInfo:
|
|
111
|
+
return resolve_caller(
|
|
112
|
+
skip_package_dirs=ctx.skip_package_dirs,
|
|
113
|
+
block_list=config.path_block_list,
|
|
114
|
+
repo_root=config.repo_root,
|
|
115
|
+
max_call_sites=config.max_call_sites,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return resolve
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class CallEmitter:
|
|
123
|
+
config: Config
|
|
124
|
+
ctx: CallContext
|
|
125
|
+
caller: CallerInfo
|
|
126
|
+
instruments: Instruments
|
|
127
|
+
start: float
|
|
128
|
+
finalized: bool = False
|
|
129
|
+
|
|
130
|
+
def success(self, usage: UsageReport) -> None:
|
|
131
|
+
if self.finalized:
|
|
132
|
+
return
|
|
133
|
+
self.finalized = True
|
|
134
|
+
attrs = _base_attrs(self.config, self.ctx, self.caller)
|
|
135
|
+
if usage.response_model:
|
|
136
|
+
attrs[semconv.GEN_AI_RESPONSE_MODEL] = usage.response_model
|
|
137
|
+
|
|
138
|
+
duration = time.perf_counter() - self.start
|
|
139
|
+
self.instruments.operation_duration.record(duration, attributes=attrs)
|
|
140
|
+
self.instruments.calls.add(1, attributes=attrs)
|
|
141
|
+
|
|
142
|
+
if usage.input_tokens is not None:
|
|
143
|
+
self.instruments.token_usage.record(
|
|
144
|
+
usage.input_tokens,
|
|
145
|
+
attributes={**attrs, semconv.GEN_AI_TOKEN_TYPE: semconv.TOKEN_INPUT},
|
|
146
|
+
)
|
|
147
|
+
if usage.output_tokens is not None:
|
|
148
|
+
self.instruments.token_usage.record(
|
|
149
|
+
usage.output_tokens,
|
|
150
|
+
attributes={**attrs, semconv.GEN_AI_TOKEN_TYPE: semconv.TOKEN_OUTPUT},
|
|
151
|
+
)
|
|
152
|
+
if usage.cache_read_tokens is not None:
|
|
153
|
+
self.instruments.cache_read_tokens.record(
|
|
154
|
+
usage.cache_read_tokens, attributes=attrs
|
|
155
|
+
)
|
|
156
|
+
if usage.cache_write_tokens is not None:
|
|
157
|
+
self.instruments.cache_write_tokens.record(
|
|
158
|
+
usage.cache_write_tokens, attributes=attrs
|
|
159
|
+
)
|
|
160
|
+
if usage.reasoning_tokens is not None:
|
|
161
|
+
self.instruments.reasoning_tokens.record(
|
|
162
|
+
usage.reasoning_tokens, attributes=attrs
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self._record_cap_utilization(usage, attrs)
|
|
166
|
+
self._maybe_analyze(attrs)
|
|
167
|
+
|
|
168
|
+
def _record_cap_utilization(
|
|
169
|
+
self, usage: UsageReport, attrs: dict[str, Any]
|
|
170
|
+
) -> None:
|
|
171
|
+
payload = self.ctx.request_payload
|
|
172
|
+
if (
|
|
173
|
+
payload is None
|
|
174
|
+
or payload.max_tokens is None
|
|
175
|
+
or payload.max_tokens <= 0
|
|
176
|
+
or usage.output_tokens is None
|
|
177
|
+
):
|
|
178
|
+
return
|
|
179
|
+
ratio = min(1.0, usage.output_tokens / payload.max_tokens)
|
|
180
|
+
cap_attrs = {
|
|
181
|
+
**attrs,
|
|
182
|
+
semconv.FRUGAL_STRUCTURED_OUTPUT_MODE: structured_output_mode(payload),
|
|
183
|
+
}
|
|
184
|
+
self.instruments.output_cap_utilization.record(ratio, attributes=cap_attrs)
|
|
185
|
+
|
|
186
|
+
def _maybe_analyze(self, attrs: dict[str, Any]) -> None:
|
|
187
|
+
payload = self.ctx.request_payload
|
|
188
|
+
if payload is None:
|
|
189
|
+
return
|
|
190
|
+
call_site_id = f"{self.caller.file}:{self.caller.function}"
|
|
191
|
+
if not should_sample(
|
|
192
|
+
call_site_id, self.config.prompt_analysis_sample_rate
|
|
193
|
+
):
|
|
194
|
+
return
|
|
195
|
+
try:
|
|
196
|
+
result = analyze_request(payload)
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if result.history_depth is not None:
|
|
201
|
+
self.instruments.history_depth.record(
|
|
202
|
+
result.history_depth, attributes=attrs
|
|
203
|
+
)
|
|
204
|
+
if result.few_shot_count is not None:
|
|
205
|
+
self.instruments.few_shot_count.record(
|
|
206
|
+
result.few_shot_count, attributes=attrs
|
|
207
|
+
)
|
|
208
|
+
if result.noise_ratio is not None:
|
|
209
|
+
self.instruments.noise_ratio.record(
|
|
210
|
+
result.noise_ratio, attributes=attrs
|
|
211
|
+
)
|
|
212
|
+
if result.structured_output_hint:
|
|
213
|
+
self.instruments.structured_output_hint.add(1, attributes=attrs)
|
|
214
|
+
|
|
215
|
+
lru = get_shared_block_hash_lru(self.config.max_call_sites)
|
|
216
|
+
previous = lru.get(call_site_id)
|
|
217
|
+
current = BlockHashes(
|
|
218
|
+
system_hash=result.system_hash,
|
|
219
|
+
user_hash=result.user_hash,
|
|
220
|
+
tools_hash=result.tools_hash,
|
|
221
|
+
schema_hash=result.schema_hash,
|
|
222
|
+
)
|
|
223
|
+
if previous is not None:
|
|
224
|
+
self._record_stable(
|
|
225
|
+
attrs, semconv.BLOCK_SYSTEM, previous.system_hash, current.system_hash
|
|
226
|
+
)
|
|
227
|
+
self._record_stable(
|
|
228
|
+
attrs, semconv.BLOCK_USER, previous.user_hash, current.user_hash
|
|
229
|
+
)
|
|
230
|
+
self._record_stable(
|
|
231
|
+
attrs, semconv.BLOCK_TOOLS, previous.tools_hash, current.tools_hash
|
|
232
|
+
)
|
|
233
|
+
self._record_stable(
|
|
234
|
+
attrs, semconv.BLOCK_SCHEMA, previous.schema_hash, current.schema_hash
|
|
235
|
+
)
|
|
236
|
+
lru.set(call_site_id, current)
|
|
237
|
+
|
|
238
|
+
def _record_stable(
|
|
239
|
+
self,
|
|
240
|
+
attrs: dict[str, Any],
|
|
241
|
+
block: str,
|
|
242
|
+
prev: str | None,
|
|
243
|
+
curr: str | None,
|
|
244
|
+
) -> None:
|
|
245
|
+
# Only emit when both calls had this block — comparing absent-vs-present
|
|
246
|
+
# would conflate "user didn't send a system prompt this time" with churn.
|
|
247
|
+
if prev is None or curr is None:
|
|
248
|
+
return
|
|
249
|
+
self.instruments.cache_prefix_stable.add(
|
|
250
|
+
1,
|
|
251
|
+
attributes={
|
|
252
|
+
**attrs,
|
|
253
|
+
semconv.FRUGAL_BLOCK: block,
|
|
254
|
+
semconv.FRUGAL_STABLE: prev == curr,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def error(self, exc: BaseException) -> None:
|
|
259
|
+
if self.finalized:
|
|
260
|
+
return
|
|
261
|
+
self.finalized = True
|
|
262
|
+
attrs = _base_attrs(self.config, self.ctx, self.caller)
|
|
263
|
+
attrs[semconv.FRUGAL_ERROR_TYPE] = type(exc).__name__
|
|
264
|
+
|
|
265
|
+
duration = time.perf_counter() - self.start
|
|
266
|
+
self.instruments.operation_duration.record(duration, attributes=attrs)
|
|
267
|
+
self.instruments.calls.add(1, attributes=attrs)
|
|
268
|
+
self.instruments.errors.add(1, attributes=attrs)
|
frugal_metrics/otel.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""OpenTelemetry meter provider + OTLP exporter setup.
|
|
2
|
+
|
|
3
|
+
Lazy init on first wrap call. We own our own MeterProvider (not the global one)
|
|
4
|
+
so we don't conflict with whatever the customer may have already configured.
|
|
5
|
+
Metrics go to a Frugal-namespaced provider with its own OTLP exporter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
|
16
|
+
OTLPMetricExporter,
|
|
17
|
+
)
|
|
18
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
19
|
+
from opentelemetry.sdk.metrics.export import (
|
|
20
|
+
ConsoleMetricExporter,
|
|
21
|
+
MetricExporter,
|
|
22
|
+
PeriodicExportingMetricReader,
|
|
23
|
+
)
|
|
24
|
+
from opentelemetry.sdk.metrics.view import (
|
|
25
|
+
ExplicitBucketHistogramAggregation,
|
|
26
|
+
View,
|
|
27
|
+
)
|
|
28
|
+
from opentelemetry.sdk.resources import Resource
|
|
29
|
+
|
|
30
|
+
from frugal_metrics import semconv
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from opentelemetry.metrics import Counter, Histogram, Meter
|
|
34
|
+
|
|
35
|
+
from frugal_metrics.config import Config
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("frugal_metrics")
|
|
38
|
+
|
|
39
|
+
_INSTRUMENTATION_NAME = "frugal-metrics"
|
|
40
|
+
_INSTRUMENTATION_VERSION = "0.1.0"
|
|
41
|
+
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
_instruments: "Instruments | None" = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Instruments:
|
|
48
|
+
"""The metric instruments we emit to."""
|
|
49
|
+
|
|
50
|
+
meter_provider: MeterProvider
|
|
51
|
+
operation_duration: "Histogram"
|
|
52
|
+
token_usage: "Histogram"
|
|
53
|
+
calls: "Counter"
|
|
54
|
+
cache_read_tokens: "Histogram"
|
|
55
|
+
cache_write_tokens: "Histogram"
|
|
56
|
+
reasoning_tokens: "Histogram"
|
|
57
|
+
errors: "Counter"
|
|
58
|
+
internal_errors: "Counter"
|
|
59
|
+
output_cap_utilization: "Histogram"
|
|
60
|
+
cache_prefix_stable: "Counter"
|
|
61
|
+
history_depth: "Histogram"
|
|
62
|
+
few_shot_count: "Histogram"
|
|
63
|
+
noise_ratio: "Histogram"
|
|
64
|
+
structured_output_hint: "Counter"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _FanoutCounter:
|
|
68
|
+
"""Wraps a Counter so add() emits the base series plus one per component."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, inner: "Counter", components: tuple[str, ...]) -> None:
|
|
71
|
+
self._inner = inner
|
|
72
|
+
self._components = components
|
|
73
|
+
|
|
74
|
+
def add(
|
|
75
|
+
self,
|
|
76
|
+
amount: int | float,
|
|
77
|
+
attributes: dict[str, Any] | None = None,
|
|
78
|
+
context: Any = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._inner.add(amount, attributes, context)
|
|
81
|
+
for component in self._components:
|
|
82
|
+
self._inner.add(
|
|
83
|
+
amount,
|
|
84
|
+
{**(attributes or {}), semconv.FRUGAL_COMPONENT: component},
|
|
85
|
+
context,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _FanoutHistogram:
|
|
90
|
+
"""Wraps a Histogram so record() emits the base series plus one per component."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, inner: "Histogram", components: tuple[str, ...]) -> None:
|
|
93
|
+
self._inner = inner
|
|
94
|
+
self._components = components
|
|
95
|
+
|
|
96
|
+
def record(
|
|
97
|
+
self,
|
|
98
|
+
amount: int | float,
|
|
99
|
+
attributes: dict[str, Any] | None = None,
|
|
100
|
+
context: Any = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._inner.record(amount, attributes, context)
|
|
103
|
+
for component in self._components:
|
|
104
|
+
self._inner.record(
|
|
105
|
+
amount,
|
|
106
|
+
{**(attributes or {}), semconv.FRUGAL_COMPONENT: component},
|
|
107
|
+
context,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _apply_component_fanout(
|
|
112
|
+
raw: Instruments, components: tuple[str, ...]
|
|
113
|
+
) -> Instruments:
|
|
114
|
+
"""When FRUGAL_COMPONENTS is set, every per-call gen-AI metric is emitted once
|
|
115
|
+
without a component label (the canonical project-level series) AND once per
|
|
116
|
+
component (tagged frugal.component). So usage can be queried for the whole
|
|
117
|
+
project (component absent) or split by component, with no double-count of the
|
|
118
|
+
project total. internal_errors is excluded — it's a process-level diagnostic.
|
|
119
|
+
"""
|
|
120
|
+
if not components:
|
|
121
|
+
return raw
|
|
122
|
+
return replace(
|
|
123
|
+
raw,
|
|
124
|
+
operation_duration=_FanoutHistogram(raw.operation_duration, components),
|
|
125
|
+
token_usage=_FanoutHistogram(raw.token_usage, components),
|
|
126
|
+
calls=_FanoutCounter(raw.calls, components),
|
|
127
|
+
cache_read_tokens=_FanoutHistogram(raw.cache_read_tokens, components),
|
|
128
|
+
cache_write_tokens=_FanoutHistogram(raw.cache_write_tokens, components),
|
|
129
|
+
reasoning_tokens=_FanoutHistogram(raw.reasoning_tokens, components),
|
|
130
|
+
errors=_FanoutCounter(raw.errors, components),
|
|
131
|
+
output_cap_utilization=_FanoutHistogram(raw.output_cap_utilization, components),
|
|
132
|
+
cache_prefix_stable=_FanoutCounter(raw.cache_prefix_stable, components),
|
|
133
|
+
history_depth=_FanoutHistogram(raw.history_depth, components),
|
|
134
|
+
few_shot_count=_FanoutHistogram(raw.few_shot_count, components),
|
|
135
|
+
noise_ratio=_FanoutHistogram(raw.noise_ratio, components),
|
|
136
|
+
structured_output_hint=_FanoutCounter(raw.structured_output_hint, components),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_instruments(config: "Config") -> Instruments:
|
|
141
|
+
"""Return cached instruments, initializing the MeterProvider on first call."""
|
|
142
|
+
global _instruments
|
|
143
|
+
if _instruments is not None:
|
|
144
|
+
return _instruments
|
|
145
|
+
with _lock:
|
|
146
|
+
if _instruments is not None:
|
|
147
|
+
return _instruments
|
|
148
|
+
_instruments = _build_instruments(config)
|
|
149
|
+
return _instruments
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_instruments(config: "Config") -> Instruments:
|
|
153
|
+
exporter = _build_exporter(config)
|
|
154
|
+
reader = PeriodicExportingMetricReader(
|
|
155
|
+
exporter=exporter,
|
|
156
|
+
export_interval_millis=config.export_interval_ms,
|
|
157
|
+
)
|
|
158
|
+
# service.name takes the customer suffix when configured, otherwise stays
|
|
159
|
+
# flat — the tenant label (auto-injected by VMAuth from the bearer token)
|
|
160
|
+
# is the canonical customer identifier on the server side, so a missing
|
|
161
|
+
# customer_id is not a problem.
|
|
162
|
+
service_name = (
|
|
163
|
+
f"frugal-metrics-{config.customer_id}"
|
|
164
|
+
if config.customer_id
|
|
165
|
+
else "frugal-metrics"
|
|
166
|
+
)
|
|
167
|
+
resource_attrs: dict[str, str] = {
|
|
168
|
+
"service.name": service_name,
|
|
169
|
+
semconv.FRUGAL_PROJECT_ID: config.project_id,
|
|
170
|
+
}
|
|
171
|
+
if config.customer_id:
|
|
172
|
+
resource_attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
173
|
+
resource = Resource.create(resource_attrs)
|
|
174
|
+
# Override OTel's default histogram bucket boundaries for operation
|
|
175
|
+
# duration. The default `(0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000,
|
|
176
|
+
# 2500, 5000, 7500, 10000)` is tuned for long-running operations; LLM
|
|
177
|
+
# calls almost always finish in 0.05s-30s, so the default sticks every
|
|
178
|
+
# sample in the (0, 5] bucket and histogram_quantile() returns the same
|
|
179
|
+
# P50/P95 for every call site. These boundaries give visible P50/P95/P99
|
|
180
|
+
# spread for realistic LLM workloads.
|
|
181
|
+
provider = MeterProvider(
|
|
182
|
+
metric_readers=[reader],
|
|
183
|
+
resource=resource,
|
|
184
|
+
views=[
|
|
185
|
+
View(
|
|
186
|
+
instrument_name=semconv.METRIC_OPERATION_DURATION,
|
|
187
|
+
aggregation=ExplicitBucketHistogramAggregation(
|
|
188
|
+
(0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5, 7.5, 10, 15, 20, 30, 60),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
meter = provider.get_meter(_INSTRUMENTATION_NAME, _INSTRUMENTATION_VERSION)
|
|
194
|
+
|
|
195
|
+
raw = Instruments(
|
|
196
|
+
meter_provider=provider,
|
|
197
|
+
operation_duration=meter.create_histogram(
|
|
198
|
+
name=semconv.METRIC_OPERATION_DURATION,
|
|
199
|
+
unit="s",
|
|
200
|
+
description="Duration of AI client operations.",
|
|
201
|
+
),
|
|
202
|
+
token_usage=meter.create_histogram(
|
|
203
|
+
name=semconv.METRIC_TOKEN_USAGE,
|
|
204
|
+
unit="{token}",
|
|
205
|
+
description="Token usage reported by the provider.",
|
|
206
|
+
),
|
|
207
|
+
calls=meter.create_counter(
|
|
208
|
+
name=semconv.METRIC_CALLS,
|
|
209
|
+
unit="{call}",
|
|
210
|
+
description="Count of AI client operation calls.",
|
|
211
|
+
),
|
|
212
|
+
cache_read_tokens=meter.create_histogram(
|
|
213
|
+
name=semconv.METRIC_CACHE_READ_TOKENS,
|
|
214
|
+
unit="{token}",
|
|
215
|
+
description="Tokens served from provider prompt cache.",
|
|
216
|
+
),
|
|
217
|
+
cache_write_tokens=meter.create_histogram(
|
|
218
|
+
name=semconv.METRIC_CACHE_WRITE_TOKENS,
|
|
219
|
+
unit="{token}",
|
|
220
|
+
description="Tokens written to provider prompt cache.",
|
|
221
|
+
),
|
|
222
|
+
reasoning_tokens=meter.create_histogram(
|
|
223
|
+
name=semconv.METRIC_REASONING_TOKENS,
|
|
224
|
+
unit="{token}",
|
|
225
|
+
description="Reasoning / extended thinking tokens billed at output rate.",
|
|
226
|
+
),
|
|
227
|
+
errors=meter.create_counter(
|
|
228
|
+
name=semconv.METRIC_ERRORS,
|
|
229
|
+
unit="{error}",
|
|
230
|
+
description="Count of errors raised by AI client operations.",
|
|
231
|
+
),
|
|
232
|
+
internal_errors=meter.create_counter(
|
|
233
|
+
name=semconv.METRIC_INTERNAL_ERRORS,
|
|
234
|
+
unit="{error}",
|
|
235
|
+
description="Count of frugal-metrics internal failures (instrumentation bugs).",
|
|
236
|
+
),
|
|
237
|
+
output_cap_utilization=meter.create_histogram(
|
|
238
|
+
name=semconv.METRIC_OUTPUT_CAP_UTILIZATION,
|
|
239
|
+
unit="1",
|
|
240
|
+
description="Ratio of output_tokens to the request's max_tokens cap (0 to 1).",
|
|
241
|
+
),
|
|
242
|
+
cache_prefix_stable=meter.create_counter(
|
|
243
|
+
name=semconv.METRIC_CACHE_PREFIX_STABLE,
|
|
244
|
+
unit="{call}",
|
|
245
|
+
description=(
|
|
246
|
+
"Per-call-site comparison of a request block's hash to the previous call. "
|
|
247
|
+
"Stable=true when the hash matches."
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
history_depth=meter.create_histogram(
|
|
251
|
+
name=semconv.METRIC_HISTORY_DEPTH,
|
|
252
|
+
unit="{message}",
|
|
253
|
+
description="Number of messages in the request payload.",
|
|
254
|
+
),
|
|
255
|
+
few_shot_count=meter.create_histogram(
|
|
256
|
+
name=semconv.METRIC_FEW_SHOT_COUNT,
|
|
257
|
+
unit="{example}",
|
|
258
|
+
description=(
|
|
259
|
+
"Count of consecutive user/assistant pairs preceding the final user message in "
|
|
260
|
+
"single-turn calls."
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
noise_ratio=meter.create_histogram(
|
|
264
|
+
name=semconv.METRIC_NOISE_RATIO,
|
|
265
|
+
unit="1",
|
|
266
|
+
description=(
|
|
267
|
+
"Ratio of noise characters (HTML tags, base64 runs, large whitespace) to total "
|
|
268
|
+
"input characters."
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
structured_output_hint=meter.create_counter(
|
|
272
|
+
name=semconv.METRIC_STRUCTURED_OUTPUT_HINT,
|
|
273
|
+
unit="{call}",
|
|
274
|
+
description=(
|
|
275
|
+
"Count of calls whose prompt asks for structured output in prose without using "
|
|
276
|
+
"tools or response_format."
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
return _apply_component_fanout(raw, config.components)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _build_exporter(config: "Config") -> MetricExporter:
|
|
284
|
+
if not config.otlp_endpoint:
|
|
285
|
+
logger.info(
|
|
286
|
+
"frugal-metrics: no OTLP endpoint configured; falling back to console exporter"
|
|
287
|
+
)
|
|
288
|
+
return ConsoleMetricExporter()
|
|
289
|
+
|
|
290
|
+
headers = _parse_headers(config.otlp_headers)
|
|
291
|
+
return OTLPMetricExporter(
|
|
292
|
+
endpoint=config.otlp_endpoint,
|
|
293
|
+
headers=headers or None,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _parse_headers(raw: str | None) -> dict[str, str]:
|
|
298
|
+
"""Parse OTEL-style header strings: key1=value1,key2=value2."""
|
|
299
|
+
if not raw:
|
|
300
|
+
return {}
|
|
301
|
+
headers: dict[str, str] = {}
|
|
302
|
+
for part in raw.split(","):
|
|
303
|
+
if not part.strip():
|
|
304
|
+
continue
|
|
305
|
+
key, sep, value = part.partition("=")
|
|
306
|
+
if not sep:
|
|
307
|
+
continue
|
|
308
|
+
headers[key.strip()] = value.strip()
|
|
309
|
+
return headers
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def reset_instruments_for_tests() -> None:
|
|
313
|
+
"""Test-only hook — wipe the cached instruments between tests."""
|
|
314
|
+
global _instruments
|
|
315
|
+
with _lock:
|
|
316
|
+
if _instruments is not None:
|
|
317
|
+
try:
|
|
318
|
+
_instruments.meter_provider.shutdown()
|
|
319
|
+
except Exception: # noqa: BLE001
|
|
320
|
+
pass
|
|
321
|
+
_instruments = None
|