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.
@@ -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