frugal-sdk-python 0.1.0__tar.gz → 0.3.0__tar.gz
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_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/PKG-INFO +99 -3
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/README.md +98 -2
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/docs/metrics-reference.md +22 -2
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/docs/quickstart.md +106 -0
- frugal_sdk_python-0.3.0/src/frugal_metrics/__init__.py +41 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/config.py +41 -0
- frugal_sdk_python-0.3.0/src/frugal_metrics/debug.py +109 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/instrument.py +16 -2
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/otel.py +85 -5
- frugal_sdk_python-0.3.0/src/frugal_metrics/providers.py +219 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/safe.py +11 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/semconv.py +13 -1
- frugal_sdk_python-0.3.0/src/frugal_metrics/shutdown.py +140 -0
- frugal_sdk_python-0.3.0/src/frugal_metrics/track.py +341 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/anthropic.py +8 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock.py +8 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/openai.py +8 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/conftest.py +4 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_config.py +18 -0
- frugal_sdk_python-0.3.0/tests/test_debug.py +133 -0
- frugal_sdk_python-0.3.0/tests/test_providers.py +180 -0
- frugal_sdk_python-0.3.0/tests/test_shutdown.py +130 -0
- frugal_sdk_python-0.3.0/tests/test_track_ai_call.py +240 -0
- frugal_sdk_python-0.1.0/src/frugal_metrics/__init__.py +0 -13
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/.github/workflows/cd.yml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/.gitignore +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/REUSE.toml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/README.md +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/docker-compose.yml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/Dockerfile +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/package.json +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/mockllm/src/server.js +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/models.html +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/prometheus/prometheus.yml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/Dockerfile +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/__init__.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/clients.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/main.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/pyproject.toml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/sample-app/requirements.txt +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/examples/test-env/scripts/verify.sh +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/pyproject.toml +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/block_hash_lru.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/caller.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/streams.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/__init__.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_bedrock_models.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_block_hash_lru.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_caller.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_caller_edge_cases.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_anthropic.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_bedrock.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai.py +0 -0
- {frugal_sdk_python-0.1.0 → frugal_sdk_python-0.3.0}/tests/test_wrap_openai_analyzer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: frugal-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Instrumentation for Cost-to-Code attribution.
|
|
5
5
|
Project-URL: Homepage, https://frugal.co
|
|
6
6
|
Author: Frugal AI Inc.
|
|
@@ -78,6 +78,7 @@ Set both or the wrapper silently returns the client unmodified:
|
|
|
78
78
|
| `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
|
|
79
79
|
| `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
|
|
80
80
|
| `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
|
|
81
|
+
| `FRUGAL_CUSTOM_PROVIDERS` | Comma-separated allowlist of provider names beyond the built-in registry (e.g. `together_ai,fireworks`) that should pass through verbatim on `gen_ai.system` / `gen_ai.provider.name` instead of being coerced to `custom`. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
|
|
81
82
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
|
|
82
83
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
|
|
83
84
|
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
|
|
@@ -113,9 +114,9 @@ Every metric carries this base attribute set:
|
|
|
113
114
|
|
|
114
115
|
- `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
|
|
115
116
|
- `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
|
|
116
|
-
- `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `
|
|
117
|
+
- `gen_ai.system` + `gen_ai.provider.name` — the provider, dual-emitted under both the legacy and current OTel attribute (`openai`, `anthropic`, `aws.bedrock`, `vertex_ai`/`gcp.vertex_ai`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
|
|
117
118
|
- `gen_ai.request.model`, `gen_ai.response.model` — the model used
|
|
118
|
-
- `gen_ai.operation.name` — `chat`, `text_completion`, or `
|
|
119
|
+
- `gen_ai.operation.name` — `chat`, `text_completion`, `embeddings`, `generate_content`, `execute_tool`, `create_agent`, `invoke_agent`, or `other` (the sentinel an unrecognized operation coerces to)
|
|
119
120
|
- `frugal.batched` — `true` for batch-API submissions, `false` otherwise
|
|
120
121
|
|
|
121
122
|
The "Additional attributes" column above lists what each metric carries on top of the base set. See [`docs/metrics-reference.md`](docs/metrics-reference.md) for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
|
|
@@ -140,6 +141,50 @@ profile prefixes (`us.`, `eu.`, `jp.`, `apac.`, `au.`, `global.`) are
|
|
|
140
141
|
preserved in `gen_ai.request.model`. Async clients (`aiobotocore` /
|
|
141
142
|
`aioboto3`) are detected automatically. See [`docs/quickstart.md`](docs/quickstart.md#native-aws-bedrock-wrap_bedrock) for the full Bedrock walkthrough.
|
|
142
143
|
|
|
144
|
+
## Custom call sites
|
|
145
|
+
|
|
146
|
+
For AI calls that don't go through a wrapped client — an HTTP call to a model gateway, a CLI shell-out, or an internal helper that returns raw usage — mark the call site yourself and feed in the usage you have. You get the same metrics and the same caller attribution as the auto-wrappers.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from frugal_metrics import track_ai_call
|
|
150
|
+
|
|
151
|
+
with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
|
|
152
|
+
res = my_custom_llm_call(prompt)
|
|
153
|
+
span.set_usage(
|
|
154
|
+
input_tokens=res.usage.input_tokens,
|
|
155
|
+
output_tokens=res.usage.output_tokens,
|
|
156
|
+
response_model=res.model,
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The `with` block times the call and emits metrics on exit — or an error metric if the block raises (the exception always propagates). Works the same around an `await` in async code. Call `span.set_usage(...)` once the response is back; if you never call it, you still get a call-count and latency point.
|
|
161
|
+
|
|
162
|
+
For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from frugal_metrics import start_ai_call
|
|
166
|
+
|
|
167
|
+
span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
|
|
168
|
+
try:
|
|
169
|
+
res = my_custom_llm_call(prompt)
|
|
170
|
+
span.success(
|
|
171
|
+
input_tokens=res.usage.input_tokens,
|
|
172
|
+
output_tokens=res.usage.output_tokens,
|
|
173
|
+
response_model=res.model,
|
|
174
|
+
)
|
|
175
|
+
except BaseException as exc:
|
|
176
|
+
span.error(exc)
|
|
177
|
+
raise
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
|
|
181
|
+
|
|
182
|
+
**Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional): `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default `"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled prompt analyzer, pass any of `messages` / `system_prompt` / `tools` / `response_format` / `max_tokens`.
|
|
183
|
+
|
|
184
|
+
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
|
|
185
|
+
|
|
186
|
+
The same env-var configuration, safety guarantees, and exported metrics apply. See [`docs/quickstart.md`](docs/quickstart.md#custom-call-sites) for the full custom call-site walkthrough.
|
|
187
|
+
|
|
143
188
|
## Streaming
|
|
144
189
|
|
|
145
190
|
Streaming works with no extra setup. The wrapper returns a drop-in replacement that passes events through and emits metrics when the stream finishes.
|
|
@@ -180,6 +225,57 @@ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
|
|
|
180
225
|
- If any part of our instrumentation fails at runtime (OTel init, stack walking, metric emission), the original SDK call executes normally. We never raise our own exceptions into your code.
|
|
181
226
|
- Wrapping is idempotent — calling `wrap_openai(client)` twice on the same client is safe.
|
|
182
227
|
|
|
228
|
+
## Debugging
|
|
229
|
+
|
|
230
|
+
The SDK is silent by design — when misconfigured it degrades to a no-op. To see *why* it is doing nothing (missing env var, instrument-build failure, cardinality-cap hit, a metric export that keeps failing), register a debug callback:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from frugal_metrics import set_debug_callback
|
|
234
|
+
|
|
235
|
+
def on_debug(level, message, context=None):
|
|
236
|
+
# level: "info" | "warn" | "error"
|
|
237
|
+
# message: human-readable string
|
|
238
|
+
# context: optional dict of structured detail, e.g. {"stage": "setup_failed"}
|
|
239
|
+
getattr(my_logger, level if level != "warn" else "warning")(
|
|
240
|
+
"[frugal-metrics] %s", message, extra=context or {}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
set_debug_callback(on_debug)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This is a **structural-diagnostics hook, not a per-call trace**. It only fires for structural or persistent problems — missing/invalid configuration, setup failures, cardinality-cap hits, and *consecutive* export failures (one de-duped `error`, re-armed on the next success). Ordinary transient per-call API errors are never emitted — those are your own API errors and already visible to you.
|
|
247
|
+
|
|
248
|
+
Notes:
|
|
249
|
+
|
|
250
|
+
- **A throwing callback is swallowed.** If your callback raises, the exception never escapes into the SDK or your code.
|
|
251
|
+
- **Issues are replayed.** Configuration runs lazily and may happen before you register, so init/config diagnostics emitted beforehand are held in a small bounded buffer and replayed in order the moment you call `set_debug_callback`. Register once at startup to catch them all.
|
|
252
|
+
- The callback is global and thread-safe. Pass `None` to clear it.
|
|
253
|
+
|
|
254
|
+
## Flushing & graceful shutdown
|
|
255
|
+
|
|
256
|
+
Metrics export on a periodic interval (default 60s). A short-lived process — a CLI, a job, a serverless invocation, a test run — can exit before the next export, silently dropping its final window of metrics. Use `flush` / `shutdown` to avoid that:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from frugal_metrics import flush, shutdown
|
|
260
|
+
|
|
261
|
+
flush() # force an immediate export; SDK keeps recording afterwards
|
|
262
|
+
shutdown() # flush + tear down the provider (rebuilt lazily on next wrap)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Both are **safe no-ops when the SDK was never initialized** and never raise.
|
|
266
|
+
|
|
267
|
+
For processes that exit on their own, opt in to automatic flush-on-exit:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from frugal_metrics import enable_auto_shutdown
|
|
271
|
+
|
|
272
|
+
enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`enable_auto_shutdown` always registers an `atexit` hook and, when `handle_signals=True` (the default), installs SIGTERM/SIGINT handlers that flush+shutdown and then **chain to whatever handler was previously installed** (so your own handler still runs). Off the main thread, signal registration is unavailable and it falls back to atexit-only. It is **idempotent** — repeat calls register nothing extra.
|
|
276
|
+
|
|
277
|
+
It is **opt-in** because installing signal handlers is intrusive. If you run your own signal handling, do **not** call `enable_auto_shutdown(handle_signals=True)`; prefer calling `shutdown()` from inside your own handler instead.
|
|
278
|
+
|
|
183
279
|
## Verifying the setup
|
|
184
280
|
|
|
185
281
|
Set the OTLP endpoint to empty to print metrics to the console instead of exporting them:
|
|
@@ -49,6 +49,7 @@ Set both or the wrapper silently returns the client unmodified:
|
|
|
49
49
|
| `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL for Frugal metrics. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
|
|
50
50
|
| `FRUGAL_OTEL_EXPORTER_OTLP_HEADERS` | Export auth headers (e.g. `Authorization=Bearer ...`). Overrides the header built from `FRUGAL_API_KEY`. Falls back to `OTEL_EXPORTER_OTLP_HEADERS`. |
|
|
51
51
|
| `FRUGAL_COMPONENTS` | Comma-separated logical components (e.g. `ai-handler,reco`). When set, each metric is ALSO emitted once per component (tagged `frugal.component`) on top of the base project series, so usage can be viewed for the whole project or split by component. |
|
|
52
|
+
| `FRUGAL_CUSTOM_PROVIDERS` | Comma-separated allowlist of provider names beyond the built-in registry (e.g. `together_ai,fireworks`) that should pass through verbatim on `gen_ai.system` / `gen_ai.provider.name` instead of being coerced to `custom`. Capped at 10 (extras are coerced) to keep label cardinality bounded. |
|
|
52
53
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
|
|
53
54
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
|
|
54
55
|
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
|
|
@@ -84,9 +85,9 @@ Every metric carries this base attribute set:
|
|
|
84
85
|
|
|
85
86
|
- `frugal.project_id` — from your env vars (`frugal.customer_id` too when set); `frugal.component` is added on the per-component series when `FRUGAL_COMPONENTS` is set
|
|
86
87
|
- `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
|
|
87
|
-
- `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `
|
|
88
|
+
- `gen_ai.system` + `gen_ai.provider.name` — the provider, dual-emitted under both the legacy and current OTel attribute (`openai`, `anthropic`, `aws.bedrock`, `vertex_ai`/`gcp.vertex_ai`, …, or `custom`). Set via the `system` kwarg; an unrecognized value is coerced to `custom` with a one-time warning. See [`docs/metrics-reference.md`](docs/metrics-reference.md#attributes).
|
|
88
89
|
- `gen_ai.request.model`, `gen_ai.response.model` — the model used
|
|
89
|
-
- `gen_ai.operation.name` — `chat`, `text_completion`, or `
|
|
90
|
+
- `gen_ai.operation.name` — `chat`, `text_completion`, `embeddings`, `generate_content`, `execute_tool`, `create_agent`, `invoke_agent`, or `other` (the sentinel an unrecognized operation coerces to)
|
|
90
91
|
- `frugal.batched` — `true` for batch-API submissions, `false` otherwise
|
|
91
92
|
|
|
92
93
|
The "Additional attributes" column above lists what each metric carries on top of the base set. See [`docs/metrics-reference.md`](docs/metrics-reference.md) for the values each attribute takes, sentinel values for caller fields, and the full internal-error-stage list.
|
|
@@ -111,6 +112,50 @@ profile prefixes (`us.`, `eu.`, `jp.`, `apac.`, `au.`, `global.`) are
|
|
|
111
112
|
preserved in `gen_ai.request.model`. Async clients (`aiobotocore` /
|
|
112
113
|
`aioboto3`) are detected automatically. See [`docs/quickstart.md`](docs/quickstart.md#native-aws-bedrock-wrap_bedrock) for the full Bedrock walkthrough.
|
|
113
114
|
|
|
115
|
+
## Custom call sites
|
|
116
|
+
|
|
117
|
+
For AI calls that don't go through a wrapped client — an HTTP call to a model gateway, a CLI shell-out, or an internal helper that returns raw usage — mark the call site yourself and feed in the usage you have. You get the same metrics and the same caller attribution as the auto-wrappers.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from frugal_metrics import track_ai_call
|
|
121
|
+
|
|
122
|
+
with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
|
|
123
|
+
res = my_custom_llm_call(prompt)
|
|
124
|
+
span.set_usage(
|
|
125
|
+
input_tokens=res.usage.input_tokens,
|
|
126
|
+
output_tokens=res.usage.output_tokens,
|
|
127
|
+
response_model=res.model,
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The `with` block times the call and emits metrics on exit — or an error metric if the block raises (the exception always propagates). Works the same around an `await` in async code. Call `span.set_usage(...)` once the response is back; if you never call it, you still get a call-count and latency point.
|
|
132
|
+
|
|
133
|
+
For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from frugal_metrics import start_ai_call
|
|
137
|
+
|
|
138
|
+
span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
|
|
139
|
+
try:
|
|
140
|
+
res = my_custom_llm_call(prompt)
|
|
141
|
+
span.success(
|
|
142
|
+
input_tokens=res.usage.input_tokens,
|
|
143
|
+
output_tokens=res.usage.output_tokens,
|
|
144
|
+
response_model=res.model,
|
|
145
|
+
)
|
|
146
|
+
except BaseException as exc:
|
|
147
|
+
span.error(exc)
|
|
148
|
+
raise
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
|
|
152
|
+
|
|
153
|
+
**Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional): `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default `"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled prompt analyzer, pass any of `messages` / `system_prompt` / `tools` / `response_format` / `max_tokens`.
|
|
154
|
+
|
|
155
|
+
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
|
|
156
|
+
|
|
157
|
+
The same env-var configuration, safety guarantees, and exported metrics apply. See [`docs/quickstart.md`](docs/quickstart.md#custom-call-sites) for the full custom call-site walkthrough.
|
|
158
|
+
|
|
114
159
|
## Streaming
|
|
115
160
|
|
|
116
161
|
Streaming works with no extra setup. The wrapper returns a drop-in replacement that passes events through and emits metrics when the stream finishes.
|
|
@@ -151,6 +196,57 @@ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
|
|
|
151
196
|
- If any part of our instrumentation fails at runtime (OTel init, stack walking, metric emission), the original SDK call executes normally. We never raise our own exceptions into your code.
|
|
152
197
|
- Wrapping is idempotent — calling `wrap_openai(client)` twice on the same client is safe.
|
|
153
198
|
|
|
199
|
+
## Debugging
|
|
200
|
+
|
|
201
|
+
The SDK is silent by design — when misconfigured it degrades to a no-op. To see *why* it is doing nothing (missing env var, instrument-build failure, cardinality-cap hit, a metric export that keeps failing), register a debug callback:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from frugal_metrics import set_debug_callback
|
|
205
|
+
|
|
206
|
+
def on_debug(level, message, context=None):
|
|
207
|
+
# level: "info" | "warn" | "error"
|
|
208
|
+
# message: human-readable string
|
|
209
|
+
# context: optional dict of structured detail, e.g. {"stage": "setup_failed"}
|
|
210
|
+
getattr(my_logger, level if level != "warn" else "warning")(
|
|
211
|
+
"[frugal-metrics] %s", message, extra=context or {}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
set_debug_callback(on_debug)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
This is a **structural-diagnostics hook, not a per-call trace**. It only fires for structural or persistent problems — missing/invalid configuration, setup failures, cardinality-cap hits, and *consecutive* export failures (one de-duped `error`, re-armed on the next success). Ordinary transient per-call API errors are never emitted — those are your own API errors and already visible to you.
|
|
218
|
+
|
|
219
|
+
Notes:
|
|
220
|
+
|
|
221
|
+
- **A throwing callback is swallowed.** If your callback raises, the exception never escapes into the SDK or your code.
|
|
222
|
+
- **Issues are replayed.** Configuration runs lazily and may happen before you register, so init/config diagnostics emitted beforehand are held in a small bounded buffer and replayed in order the moment you call `set_debug_callback`. Register once at startup to catch them all.
|
|
223
|
+
- The callback is global and thread-safe. Pass `None` to clear it.
|
|
224
|
+
|
|
225
|
+
## Flushing & graceful shutdown
|
|
226
|
+
|
|
227
|
+
Metrics export on a periodic interval (default 60s). A short-lived process — a CLI, a job, a serverless invocation, a test run — can exit before the next export, silently dropping its final window of metrics. Use `flush` / `shutdown` to avoid that:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from frugal_metrics import flush, shutdown
|
|
231
|
+
|
|
232
|
+
flush() # force an immediate export; SDK keeps recording afterwards
|
|
233
|
+
shutdown() # flush + tear down the provider (rebuilt lazily on next wrap)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Both are **safe no-ops when the SDK was never initialized** and never raise.
|
|
237
|
+
|
|
238
|
+
For processes that exit on their own, opt in to automatic flush-on-exit:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from frugal_metrics import enable_auto_shutdown
|
|
242
|
+
|
|
243
|
+
enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`enable_auto_shutdown` always registers an `atexit` hook and, when `handle_signals=True` (the default), installs SIGTERM/SIGINT handlers that flush+shutdown and then **chain to whatever handler was previously installed** (so your own handler still runs). Off the main thread, signal registration is unavailable and it falls back to atexit-only. It is **idempotent** — repeat calls register nothing extra.
|
|
247
|
+
|
|
248
|
+
It is **opt-in** because installing signal handlers is intrusive. If you run your own signal handling, do **not** call `enable_auto_shutdown(handle_signals=True)`; prefer calling `shutdown()` from inside your own handler instead.
|
|
249
|
+
|
|
154
250
|
## Verifying the setup
|
|
155
251
|
|
|
156
252
|
Set the OTLP endpoint to empty to print metrics to the console instead of exporting them:
|
|
@@ -45,12 +45,31 @@ calls, token usage) are 100% — never sampled.
|
|
|
45
45
|
|
|
46
46
|
| Attribute | Example |
|
|
47
47
|
| -------------------------- | ------------------------------------------ |
|
|
48
|
-
| `gen_ai.system` | `openai` \| `anthropic` \| `aws.bedrock` \| `vertex_ai` |
|
|
49
|
-
| `gen_ai.
|
|
48
|
+
| `gen_ai.system` | `openai` \| `anthropic` \| `aws.bedrock` \| `vertex_ai` \| `az.ai.openai` \| `cohere` \| `mistral_ai` \| `deepseek` \| `groq` \| `perplexity` \| `x_ai` \| `ibm.watsonx.ai` \| `gcp.gemini` \| `custom` (legacy spelling — see below) |
|
|
49
|
+
| `gen_ai.provider.name` | `openai` \| `anthropic` \| `aws.bedrock` \| `gcp.vertex_ai` \| `azure.ai.openai` \| `cohere` \| `mistral_ai` \| `deepseek` \| `groq` \| `perplexity` \| `x_ai` \| `ibm.watsonx.ai` \| `gcp.gemini` \| `custom` (current OTel attribute) |
|
|
50
|
+
| `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` \| `generate_content` \| `execute_tool` \| `create_agent` \| `invoke_agent` \| `other` (sentinel for an unrecognized operation) |
|
|
50
51
|
| `gen_ai.request.model` | `gpt-4o` / `claude-3-5-sonnet-latest` / … |
|
|
51
52
|
| `gen_ai.response.model` | Provider-returned model id (often identical)|
|
|
52
53
|
| `gen_ai.token.type` | `input` \| `output` (token usage metric only) |
|
|
53
54
|
|
|
55
|
+
> **Provider attribute migration.** OTel renamed `gen_ai.system` to
|
|
56
|
+
> `gen_ai.provider.name` (some spellings changed, e.g. `vertex_ai` →
|
|
57
|
+
> `gcp.vertex_ai`). The SDK **dual-emits both** during the transition: every
|
|
58
|
+
> metric carries the legacy `gen_ai.system` value (so existing queries/history
|
|
59
|
+
> keep matching) and the new `gen_ai.provider.name` value. The `system` kwarg
|
|
60
|
+
> accepts either spelling. A value outside the well-known set (e.g. a typo like
|
|
61
|
+
> `amazon-bedrock`) is **coerced to `custom`** on both labels — so a wrong label
|
|
62
|
+
> never reaches metrics — and surfaced once through the debug callback
|
|
63
|
+
> (`set_debug_callback`), per distinct value (bounded so a high-cardinality
|
|
64
|
+
> caller can't grow the warning set without limit). To keep a legitimate provider
|
|
65
|
+
> that isn't in the registry, register it via `FRUGAL_CUSTOM_PROVIDERS` (up to 10)
|
|
66
|
+
> and it passes through verbatim on both labels. An `operation` outside the accepted set is coerced to
|
|
67
|
+
> the `other` sentinel (NOT `chat`, so a typo can't inflate the real chat series) with the same
|
|
68
|
+
> one-time warning — the seven listed values plus an explicit `other` pass through unchanged. The
|
|
69
|
+
> exported `GenAiSystem` / `GenAiOperation` `Literal` aliases give strict typing for callers who want
|
|
70
|
+
> it — `GenAiSystem` lists both provider spellings the runtime accepts (the kwargs stay `str` so a
|
|
71
|
+
> free-form custom value remains legal).
|
|
72
|
+
|
|
54
73
|
### Frugal-added
|
|
55
74
|
|
|
56
75
|
| Attribute | Value |
|
|
@@ -110,6 +129,7 @@ attribute describing where our own code failed. Non-exhaustive list:
|
|
|
110
129
|
| Claude on AWS Bedrock (via Anthropic SDK) | `wrap_anthropic(AnthropicBedrock())` | `aws.bedrock` |
|
|
111
130
|
| AWS Bedrock — any model, AWS SDKs directly | `wrap_bedrock` | `aws.bedrock` |
|
|
112
131
|
| Claude on Google Vertex | `wrap_anthropic(AnthropicVertex())` | `vertex_ai` |
|
|
132
|
+
| Custom / manual call sites | `track_ai_call` / `start_ai_call` | `custom` (override via `system=`) |
|
|
113
133
|
|
|
114
134
|
`wrap_bedrock` covers all four `bedrock-runtime` operations (`InvokeModel`,
|
|
115
135
|
`InvokeModelWithResponseStream`, `Converse`, `ConverseStream`) on every model
|
|
@@ -92,6 +92,13 @@ token counts are not (the OpenAI SDK doesn't send them).
|
|
|
92
92
|
Use `wrap_anthropic` with the Anthropic SDK's `AnthropicBedrock` client. The
|
|
93
93
|
wrapper detects it and tags metrics with `gen_ai.system = aws.bedrock`.
|
|
94
94
|
|
|
95
|
+
> Every metric is **dual-emitted** under both `gen_ai.system` (legacy) and
|
|
96
|
+
> `gen_ai.provider.name` (current OTel spelling — e.g. `aws.bedrock`,
|
|
97
|
+
> `gcp.vertex_ai`). A provider or operation outside the known set is coerced to
|
|
98
|
+
> the `custom` / `other` sentinel with a one-time warning (via
|
|
99
|
+
> `set_debug_callback`); register extra providers with `FRUGAL_CUSTOM_PROVIDERS`.
|
|
100
|
+
> See [metrics-reference.md](metrics-reference.md#attributes).
|
|
101
|
+
|
|
95
102
|
```python
|
|
96
103
|
from anthropic import AnthropicBedrock
|
|
97
104
|
from frugal_metrics import wrap_anthropic
|
|
@@ -198,6 +205,65 @@ client = wrap_anthropic(AnthropicVertex(region="us-central1", project_id="my-pro
|
|
|
198
205
|
> integrations are not yet instrumented. The block-list approach below
|
|
199
206
|
> usually covers framework wrappers in the meantime.
|
|
200
207
|
|
|
208
|
+
## Custom call sites
|
|
209
|
+
|
|
210
|
+
For AI calls that don't go through a wrapped client — an HTTP call to a model
|
|
211
|
+
gateway, a CLI shell-out, or an internal helper that returns raw usage — mark
|
|
212
|
+
the call site yourself and feed in the usage you have. You get the same metrics
|
|
213
|
+
(tagged `gen_ai.system = custom` by default) and the same caller attribution as
|
|
214
|
+
the auto-wrappers.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from frugal_metrics import track_ai_call
|
|
218
|
+
|
|
219
|
+
with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514") as span:
|
|
220
|
+
res = my_custom_llm_call(prompt)
|
|
221
|
+
span.set_usage(
|
|
222
|
+
input_tokens=res.usage.input_tokens,
|
|
223
|
+
output_tokens=res.usage.output_tokens,
|
|
224
|
+
response_model=res.model,
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The `with` block times the call and emits metrics on exit — or an error metric
|
|
229
|
+
if the block raises (the exception always propagates). Works the same around an
|
|
230
|
+
`await` in async code. Call `span.set_usage(...)` once the response is back; if
|
|
231
|
+
you never call it, you still get a call-count and latency point.
|
|
232
|
+
|
|
233
|
+
For calls that span function boundaries — where a single `with` block won't fit
|
|
234
|
+
— use the manual handle:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from frugal_metrics import start_ai_call
|
|
238
|
+
|
|
239
|
+
span = start_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
|
|
240
|
+
try:
|
|
241
|
+
res = my_custom_llm_call(prompt)
|
|
242
|
+
span.success(
|
|
243
|
+
input_tokens=res.usage.input_tokens,
|
|
244
|
+
output_tokens=res.usage.output_tokens,
|
|
245
|
+
response_model=res.model,
|
|
246
|
+
)
|
|
247
|
+
except BaseException as exc:
|
|
248
|
+
span.error(exc)
|
|
249
|
+
raise
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`success()` and `error()` finalize the call exactly once and are idempotent —
|
|
253
|
+
extra calls are ignored.
|
|
254
|
+
|
|
255
|
+
**Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional):
|
|
256
|
+
`system` (→ `gen_ai.system`, default `"custom"`), `operation` (default
|
|
257
|
+
`"chat"`), `request_model` (default `"<unknown>"`). To also run the sampled
|
|
258
|
+
prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
|
|
259
|
+
`response_format` / `max_tokens`.
|
|
260
|
+
|
|
261
|
+
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional):
|
|
262
|
+
`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
|
|
263
|
+
`reasoning_tokens`, `response_model`.
|
|
264
|
+
|
|
265
|
+
The same env-var configuration, safety guarantees, and exported metrics apply.
|
|
266
|
+
|
|
201
267
|
## Attributing the right caller
|
|
202
268
|
|
|
203
269
|
By default we walk the stack and pick the first frame that is:
|
|
@@ -272,6 +338,46 @@ The token-side metrics (`gen_ai.client.token.usage`, `frugal.gen_ai.calls`,
|
|
|
272
338
|
`frugal.gen_ai.cache_*`, `frugal.gen_ai.output_cap_utilization`) are 100% —
|
|
273
339
|
never sampled.
|
|
274
340
|
|
|
341
|
+
## See why nothing is exporting (debug callback)
|
|
342
|
+
|
|
343
|
+
The SDK is silent by design, so when it no-ops you get no feedback. Register a
|
|
344
|
+
debug callback once at startup to surface structural diagnostics (missing config,
|
|
345
|
+
setup failures, cardinality-cap hits, persistent export failures) through your own
|
|
346
|
+
logger:
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from frugal_metrics import set_debug_callback
|
|
350
|
+
|
|
351
|
+
def on_debug(level, message, context=None):
|
|
352
|
+
print(f"[frugal-metrics] {level}: {message}", context or {})
|
|
353
|
+
|
|
354
|
+
set_debug_callback(on_debug)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
It is a structural-diagnostics hook, not a per-call trace. A throwing callback is
|
|
358
|
+
swallowed, and issues emitted before you register are replayed on registration —
|
|
359
|
+
so register early. Pass `None` to clear it.
|
|
360
|
+
|
|
361
|
+
## Flush on shutdown
|
|
362
|
+
|
|
363
|
+
Export runs on an interval, so a short-lived process can exit before the final
|
|
364
|
+
batch ships. Flush (or shut down) before exit:
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
from frugal_metrics import flush, shutdown, enable_auto_shutdown
|
|
368
|
+
|
|
369
|
+
flush() # immediate export, keep recording
|
|
370
|
+
shutdown() # flush + tear down (safe even if never initialized)
|
|
371
|
+
|
|
372
|
+
enable_auto_shutdown() # or: opt in to auto flush+shutdown on exit/SIGTERM/SIGINT
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`flush` / `shutdown` are safe no-ops when the SDK was never initialized.
|
|
376
|
+
`enable_auto_shutdown` registers an `atexit` hook and (by default) chained
|
|
377
|
+
SIGTERM/SIGINT handlers, and is idempotent; if you manage your own signal
|
|
378
|
+
handlers, call it with `handle_signals=False` (or just call `shutdown()` from
|
|
379
|
+
your handler).
|
|
380
|
+
|
|
275
381
|
## Verifying the setup
|
|
276
382
|
|
|
277
383
|
Point the library at a local collector (or set the endpoint empty to print
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""frugal-metrics: call-site instrumentation for AI API calls."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from frugal_metrics.debug import set_debug_callback
|
|
6
|
+
from frugal_metrics.providers import (
|
|
7
|
+
PROVIDERS,
|
|
8
|
+
GenAiOperation,
|
|
9
|
+
GenAiSystem,
|
|
10
|
+
ProviderEntry,
|
|
11
|
+
normalize_operation,
|
|
12
|
+
normalize_provider,
|
|
13
|
+
)
|
|
14
|
+
from frugal_metrics.shutdown import enable_auto_shutdown, flush, shutdown
|
|
15
|
+
from frugal_metrics.track import AICallSpan, start_ai_call, track_ai_call
|
|
16
|
+
from frugal_metrics.wrappers.openai import wrap_openai
|
|
17
|
+
from frugal_metrics.wrappers.anthropic import wrap_anthropic
|
|
18
|
+
from frugal_metrics.wrappers.bedrock import wrap_bedrock
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"wrap_openai",
|
|
22
|
+
"wrap_anthropic",
|
|
23
|
+
"wrap_bedrock",
|
|
24
|
+
"track_ai_call",
|
|
25
|
+
"start_ai_call",
|
|
26
|
+
"AICallSpan",
|
|
27
|
+
"set_debug_callback",
|
|
28
|
+
"flush",
|
|
29
|
+
"shutdown",
|
|
30
|
+
"enable_auto_shutdown",
|
|
31
|
+
"PROVIDERS",
|
|
32
|
+
"ProviderEntry",
|
|
33
|
+
"GenAiSystem",
|
|
34
|
+
"GenAiOperation",
|
|
35
|
+
"normalize_provider",
|
|
36
|
+
"normalize_operation",
|
|
37
|
+
]
|
|
38
|
+
try:
|
|
39
|
+
__version__ = version("frugal-sdk-python")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
__version__ = "0+unknown"
|
|
@@ -35,12 +35,15 @@ import os
|
|
|
35
35
|
import threading
|
|
36
36
|
from dataclasses import dataclass, field
|
|
37
37
|
|
|
38
|
+
from frugal_metrics.debug import emit_log, emit_log_once
|
|
39
|
+
|
|
38
40
|
logger = logging.getLogger("frugal_metrics")
|
|
39
41
|
|
|
40
42
|
_ENV_API_KEY = "FRUGAL_API_KEY"
|
|
41
43
|
_ENV_CUSTOMER_ID = "FRUGAL_CUSTOMER_ID"
|
|
42
44
|
_ENV_PROJECT_ID = "FRUGAL_PROJECT_ID"
|
|
43
45
|
_ENV_COMPONENTS = "FRUGAL_COMPONENTS"
|
|
46
|
+
_ENV_CUSTOM_PROVIDERS = "FRUGAL_CUSTOM_PROVIDERS"
|
|
44
47
|
_ENV_REPO_ROOT = "FRUGAL_REPO_ROOT"
|
|
45
48
|
_ENV_PATH_BLOCK_LIST = "FRUGAL_PATH_BLOCK_LIST"
|
|
46
49
|
_ENV_DISABLED = "FRUGAL_DISABLED"
|
|
@@ -56,6 +59,8 @@ _ENV_PROMPT_ANALYSIS_SAMPLE_RATE = "FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE"
|
|
|
56
59
|
_DEFAULT_OTLP_ENDPOINT = "https://metrics.frugal.co/opentelemetry/v1/metrics"
|
|
57
60
|
_DEFAULT_MAX_CALL_SITES = 500
|
|
58
61
|
_DEFAULT_PROMPT_ANALYSIS_SAMPLE_RATE = 0.1
|
|
62
|
+
# Upper bound on customer-registered provider names — keeps label cardinality bounded.
|
|
63
|
+
_MAX_CUSTOM_PROVIDERS = 10
|
|
59
64
|
|
|
60
65
|
_warned_once = False
|
|
61
66
|
_warned_lock = threading.Lock()
|
|
@@ -70,6 +75,10 @@ class Config:
|
|
|
70
75
|
repo_root: str
|
|
71
76
|
# Optional logical components; metrics fan out one extra series per entry.
|
|
72
77
|
components: tuple[str, ...] = field(default_factory=tuple)
|
|
78
|
+
# Customer-registered provider names (beyond the built-in OTel registry) that
|
|
79
|
+
# pass through verbatim instead of being coerced to "custom". Capped to keep
|
|
80
|
+
# gen_ai.system / gen_ai.provider.name cardinality bounded.
|
|
81
|
+
custom_providers: tuple[str, ...] = field(default_factory=tuple)
|
|
73
82
|
path_block_list: tuple[str, ...] = field(default_factory=tuple)
|
|
74
83
|
otlp_endpoint: str = _DEFAULT_OTLP_ENDPOINT
|
|
75
84
|
otlp_headers: str | None = None
|
|
@@ -92,6 +101,7 @@ def _warn_once(message: str) -> None:
|
|
|
92
101
|
return
|
|
93
102
|
_warned_once = True
|
|
94
103
|
logger.warning("frugal-metrics disabled: %s", message)
|
|
104
|
+
emit_log("warn", f"disabled: {message}")
|
|
95
105
|
|
|
96
106
|
|
|
97
107
|
def load_config() -> Config | None:
|
|
@@ -154,6 +164,28 @@ def load_config() -> Config | None:
|
|
|
154
164
|
entry.strip() for entry in components_raw.split(",") if entry.strip()
|
|
155
165
|
)
|
|
156
166
|
|
|
167
|
+
# Optional comma-separated allowlist of custom provider names that pass
|
|
168
|
+
# through verbatim (e.g. "together_ai,fireworks"). Deduped (order-preserving)
|
|
169
|
+
# and capped at _MAX_CUSTOM_PROVIDERS so a misconfigured value can't blow up
|
|
170
|
+
# label cardinality.
|
|
171
|
+
custom_providers_raw = os.environ.get(_ENV_CUSTOM_PROVIDERS, "")
|
|
172
|
+
custom_providers_all = list(
|
|
173
|
+
dict.fromkeys(
|
|
174
|
+
entry.strip()
|
|
175
|
+
for entry in custom_providers_raw.split(",")
|
|
176
|
+
if entry.strip()
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
custom_providers = tuple(custom_providers_all[:_MAX_CUSTOM_PROVIDERS])
|
|
180
|
+
if len(custom_providers_all) > _MAX_CUSTOM_PROVIDERS:
|
|
181
|
+
emit_log_once(
|
|
182
|
+
"custom_providers_truncated",
|
|
183
|
+
"warn",
|
|
184
|
+
f"{_ENV_CUSTOM_PROVIDERS} lists {len(custom_providers_all)} entries; "
|
|
185
|
+
f"only the first {_MAX_CUSTOM_PROVIDERS} are honored, the rest are "
|
|
186
|
+
f'coerced to "custom".',
|
|
187
|
+
)
|
|
188
|
+
|
|
157
189
|
otlp_endpoint = (
|
|
158
190
|
os.environ.get(_ENV_FRUGAL_OTLP_ENDPOINT)
|
|
159
191
|
or os.environ.get(_ENV_OTLP_ENDPOINT)
|
|
@@ -182,6 +214,7 @@ def load_config() -> Config | None:
|
|
|
182
214
|
project_id=project_id, # type: ignore[arg-type]
|
|
183
215
|
repo_root=repo_root_abs,
|
|
184
216
|
components=components,
|
|
217
|
+
custom_providers=custom_providers,
|
|
185
218
|
path_block_list=block_list,
|
|
186
219
|
otlp_endpoint=otlp_endpoint,
|
|
187
220
|
otlp_headers=otlp_headers,
|
|
@@ -207,6 +240,10 @@ def _parse_int(raw: str | None, *, default: int) -> int:
|
|
|
207
240
|
raw,
|
|
208
241
|
default,
|
|
209
242
|
)
|
|
243
|
+
emit_log(
|
|
244
|
+
"warn",
|
|
245
|
+
f"could not parse int env var value {raw!r}; using default {default}",
|
|
246
|
+
)
|
|
210
247
|
return default
|
|
211
248
|
|
|
212
249
|
|
|
@@ -221,6 +258,10 @@ def _parse_float(raw: str | None, *, default: float) -> float:
|
|
|
221
258
|
raw,
|
|
222
259
|
default,
|
|
223
260
|
)
|
|
261
|
+
emit_log(
|
|
262
|
+
"warn",
|
|
263
|
+
f"could not parse float env var value {raw!r}; using default {default}",
|
|
264
|
+
)
|
|
224
265
|
return default
|
|
225
266
|
|
|
226
267
|
|