frugal-sdk-python 0.4.0__tar.gz → 0.5.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.4.0 → frugal_sdk_python-0.5.0}/PKG-INFO +44 -7
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/README.md +43 -6
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/docs/metrics-reference.md +59 -2
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/docs/quickstart.md +122 -8
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +1 -1
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/block_hash_lru.py +22 -6
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/config.py +152 -9
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/instrument.py +105 -8
- frugal_sdk_python-0.5.0/src/frugal_metrics/model_cap.py +103 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/otel.py +270 -15
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/semconv.py +14 -3
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/track.py +5 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/conftest.py +9 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_block_hash_lru.py +31 -2
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_config.py +321 -0
- frugal_sdk_python-0.5.0/tests/test_model_cap.py +413 -0
- frugal_sdk_python-0.5.0/tests/test_never_impact_host.py +270 -0
- frugal_sdk_python-0.5.0/tests/test_otel_overflow_detection.py +439 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_track_ai_call.py +130 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/.github/workflows/cd.yml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/.gitignore +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/LICENSES/Apache-2.0.txt +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/REUSE.toml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/README.md +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/docker-compose.yml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/Dockerfile +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/package.json +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/mockllm/src/server.js +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/models.html +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/prometheus/prometheus.yml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/Dockerfile +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/clients.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/main.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/pyproject.toml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/sample-app/requirements.txt +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/examples/test-env/scripts/verify.sh +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/pyproject.toml +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/caller.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/debug.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/duplicate_filter.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/providers.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/safe.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/shutdown.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/streams.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/_batch.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/_patching.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/anthropic.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/src/frugal_metrics/wrappers/openai.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/__init__.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_bedrock_models.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_caller.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_caller_edge_cases.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_debug.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_duplicate_filter.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_providers.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_shutdown.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_anthropic.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_anthropic_batches.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_bedrock.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai_analyzer.py +0 -0
- {frugal_sdk_python-0.4.0 → frugal_sdk_python-0.5.0}/tests/test_wrap_openai_batches.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: frugal-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Instrumentation for Cost-to-Code attribution.
|
|
5
5
|
Project-URL: Homepage, https://frugal.co
|
|
6
6
|
Author: Frugal AI Inc.
|
|
@@ -77,12 +77,14 @@ Set both or the wrapper silently returns the client unmodified:
|
|
|
77
77
|
| `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
|
|
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
|
-
| `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. |
|
|
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. Deduped, and only the first **10** entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. |
|
|
81
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. |
|
|
82
82
|
| `FRUGAL_CUSTOM_LABEL` | Comma-separated allowlist of values for the `frugal.custom_label` label (e.g. `billing,search`). Applies only to `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; a value outside the first 20 entries (hard cap, not configurable) is coerced to `other`. |
|
|
83
83
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
|
|
84
84
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
|
|
85
|
-
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
|
|
85
|
+
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute; past it `frugal.caller_file` / `frugal.caller_function` become `<overflow>`. Default `500`. Set to `0` to disable. |
|
|
86
|
+
| `FRUGAL_MAX_MODELS` | Cardinality cap — max distinct model strings to attribute; past it `gen_ai.request.model` / `gen_ai.response.model` become `<overflow>`. One registry shared by both labels, so a request alias and its dated response snapshot each take a slot. Default `100`. Set to `0` to disable. |
|
|
87
|
+
| `FRUGAL_MAX_SERIES_PER_INSTRUMENT` | OpenTelemetry's per-instrument limit on distinct attribute **combinations**, raised from the SDK default. Default `50000`. Set to `0` for effectively unlimited (a 1,000,000 ceiling). Applied **only when the installed `opentelemetry-sdk` accepts a per-instrument cardinality limit** — current releases (through 1.42) do not implement one, so nothing is capped and setting this variable emits one `warn` telling you it has no effect. |
|
|
86
88
|
| `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) that run the lightweight prompt analyzer. Default `0.1`. Set to `0` to disable, `1` for every call. |
|
|
87
89
|
| `FRUGAL_DUPLICATE_REQUEST_DETECTION` | Duplicate-request detection (the `frugal.gen_ai.duplicate_requests` metric) is **on by default** and hashes each chat request in-process (the hash never leaves the SDK). Set to `0`/`false`/`no`/`off` to disable — no hashing, no filter memory, no metric. |
|
|
88
90
|
| `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
|
|
@@ -103,8 +105,9 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
|
|
|
103
105
|
| `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
|
|
104
106
|
| `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
|
|
105
107
|
| `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
|
|
108
|
+
| `frugal.gen_ai.agent.turns` | histogram | Model-response turns per successful `invoke_agent` call (manual only) | — |
|
|
106
109
|
| `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
|
|
107
|
-
| `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality
|
|
110
|
+
| `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality caps, bugs) | `frugal.internal_error.stage`, `frugal.internal_error.type`, `frugal.instrument` |
|
|
108
111
|
| `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
|
|
109
112
|
| `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
|
|
110
113
|
| `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
|
|
@@ -121,12 +124,13 @@ The analyzer-sampled metrics fire on a fraction of calls
|
|
|
121
124
|
Every metric carries this base attribute set (except
|
|
122
125
|
`frugal.gen_ai.internal_errors`, which is emitted from failure paths where
|
|
123
126
|
these attributes may not exist yet and carries only `frugal.project_id`, the
|
|
124
|
-
stage/type,
|
|
127
|
+
stage/type, `frugal.instrument` on the `otel_cardinality_overflow` stage, and
|
|
128
|
+
`frugal.customer_id` / `frugal.custom_label` when set):
|
|
125
129
|
|
|
126
130
|
- `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
|
|
127
131
|
- `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
|
|
128
132
|
- `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).
|
|
129
|
-
- `gen_ai.request.model`, `gen_ai.response.model` — the model used
|
|
133
|
+
- `gen_ai.request.model`, `gen_ai.response.model` — the model used; both draw on one shared registry and become `<overflow>` past `FRUGAL_MAX_MODELS` distinct model strings
|
|
130
134
|
- `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)
|
|
131
135
|
- `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
|
|
132
136
|
- `frugal.custom_label` — only on `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; the value must be in the `FRUGAL_CUSTOM_LABEL` allowlist or it is coerced to `other`
|
|
@@ -169,6 +173,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
|
|
|
169
173
|
)
|
|
170
174
|
```
|
|
171
175
|
|
|
176
|
+
For an enclosing agent invocation, set `operation="invoke_agent"` and report
|
|
177
|
+
the number of model-response steps performed inside it:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
with track_ai_call(
|
|
181
|
+
system="anthropic",
|
|
182
|
+
operation="invoke_agent",
|
|
183
|
+
request_model="claude-sonnet-4-20250514",
|
|
184
|
+
) as span:
|
|
185
|
+
result = my_agent_framework_call(prompt)
|
|
186
|
+
span.set_usage(
|
|
187
|
+
input_tokens=result.usage.input_tokens,
|
|
188
|
+
output_tokens=result.usage.output_tokens,
|
|
189
|
+
agent_turns=result.turn_count,
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- Provider wrappers still measure individual model calls and never set
|
|
194
|
+
`agent_turns`.
|
|
195
|
+
- Agent frameworks such as the Claude Agent SDK, OpenAI Agents SDK, and
|
|
196
|
+
LangGraph do not have dedicated wrappers yet; track them manually as shown.
|
|
197
|
+
- `frugal.gen_ai.history_depth` is the request message count
|
|
198
|
+
(`messages.length`), not a turn count.
|
|
199
|
+
- Missing, non-integer, non-positive, or otherwise invalid `agent_turns`
|
|
200
|
+
values are silently dropped. They never raise or affect customer execution.
|
|
201
|
+
- Turn data is emitted only on success, like token usage.
|
|
202
|
+
- **Avoid double-counting tokens.** Only report aggregate `input_tokens` /
|
|
203
|
+
`output_tokens` on the `invoke_agent` call when the agent's inner
|
|
204
|
+
model-client calls are **not** already wrapped by a supported provider
|
|
205
|
+
wrapper. If they are wrapped, those wrappers already emit token usage per
|
|
206
|
+
inner call — report `agent_turns` only on the `invoke_agent` call in that
|
|
207
|
+
case, or the same tokens are double-counted.
|
|
208
|
+
|
|
172
209
|
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.
|
|
173
210
|
|
|
174
211
|
For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
|
|
@@ -193,7 +230,7 @@ except BaseException as exc:
|
|
|
193
230
|
|
|
194
231
|
**Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional): `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default `"chat"`), `request_model` (default `"<unknown>"`), `custom_label` (→ the `frugal.custom_label` label on every metric of the call; trimmed, and coerced to `other` unless in the `FRUGAL_CUSTOM_LABEL` allowlist). To also run the sampled prompt analyzer, pass any of `messages` / `system_prompt` / `tools` / `response_format` / `max_tokens`.
|
|
195
232
|
|
|
196
|
-
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
|
|
233
|
+
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `agent_turns`, `response_model`.
|
|
197
234
|
|
|
198
235
|
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.
|
|
199
236
|
|
|
@@ -48,12 +48,14 @@ Set both or the wrapper silently returns the client unmodified:
|
|
|
48
48
|
| `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
|
|
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
|
-
| `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. |
|
|
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. Deduped, and only the first **10** entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. |
|
|
52
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. |
|
|
53
53
|
| `FRUGAL_CUSTOM_LABEL` | Comma-separated allowlist of values for the `frugal.custom_label` label (e.g. `billing,search`). Applies only to `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; a value outside the first 20 entries (hard cap, not configurable) is coerced to `other`. |
|
|
54
54
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
|
|
55
55
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
|
|
56
|
-
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
|
|
56
|
+
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute; past it `frugal.caller_file` / `frugal.caller_function` become `<overflow>`. Default `500`. Set to `0` to disable. |
|
|
57
|
+
| `FRUGAL_MAX_MODELS` | Cardinality cap — max distinct model strings to attribute; past it `gen_ai.request.model` / `gen_ai.response.model` become `<overflow>`. One registry shared by both labels, so a request alias and its dated response snapshot each take a slot. Default `100`. Set to `0` to disable. |
|
|
58
|
+
| `FRUGAL_MAX_SERIES_PER_INSTRUMENT` | OpenTelemetry's per-instrument limit on distinct attribute **combinations**, raised from the SDK default. Default `50000`. Set to `0` for effectively unlimited (a 1,000,000 ceiling). Applied **only when the installed `opentelemetry-sdk` accepts a per-instrument cardinality limit** — current releases (through 1.42) do not implement one, so nothing is capped and setting this variable emits one `warn` telling you it has no effect. |
|
|
57
59
|
| `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) that run the lightweight prompt analyzer. Default `0.1`. Set to `0` to disable, `1` for every call. |
|
|
58
60
|
| `FRUGAL_DUPLICATE_REQUEST_DETECTION` | Duplicate-request detection (the `frugal.gen_ai.duplicate_requests` metric) is **on by default** and hashes each chat request in-process (the hash never leaves the SDK). Set to `0`/`false`/`no`/`off` to disable — no hashing, no filter memory, no metric. |
|
|
59
61
|
| `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
|
|
@@ -74,8 +76,9 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
|
|
|
74
76
|
| `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
|
|
75
77
|
| `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
|
|
76
78
|
| `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
|
|
79
|
+
| `frugal.gen_ai.agent.turns` | histogram | Model-response turns per successful `invoke_agent` call (manual only) | — |
|
|
77
80
|
| `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
|
|
78
|
-
| `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality
|
|
81
|
+
| `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality caps, bugs) | `frugal.internal_error.stage`, `frugal.internal_error.type`, `frugal.instrument` |
|
|
79
82
|
| `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
|
|
80
83
|
| `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
|
|
81
84
|
| `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
|
|
@@ -92,12 +95,13 @@ The analyzer-sampled metrics fire on a fraction of calls
|
|
|
92
95
|
Every metric carries this base attribute set (except
|
|
93
96
|
`frugal.gen_ai.internal_errors`, which is emitted from failure paths where
|
|
94
97
|
these attributes may not exist yet and carries only `frugal.project_id`, the
|
|
95
|
-
stage/type,
|
|
98
|
+
stage/type, `frugal.instrument` on the `otel_cardinality_overflow` stage, and
|
|
99
|
+
`frugal.customer_id` / `frugal.custom_label` when set):
|
|
96
100
|
|
|
97
101
|
- `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
|
|
98
102
|
- `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
|
|
99
103
|
- `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).
|
|
100
|
-
- `gen_ai.request.model`, `gen_ai.response.model` — the model used
|
|
104
|
+
- `gen_ai.request.model`, `gen_ai.response.model` — the model used; both draw on one shared registry and become `<overflow>` past `FRUGAL_MAX_MODELS` distinct model strings
|
|
101
105
|
- `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)
|
|
102
106
|
- `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
|
|
103
107
|
- `frugal.custom_label` — only on `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; the value must be in the `FRUGAL_CUSTOM_LABEL` allowlist or it is coerced to `other`
|
|
@@ -140,6 +144,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
|
|
|
140
144
|
)
|
|
141
145
|
```
|
|
142
146
|
|
|
147
|
+
For an enclosing agent invocation, set `operation="invoke_agent"` and report
|
|
148
|
+
the number of model-response steps performed inside it:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
with track_ai_call(
|
|
152
|
+
system="anthropic",
|
|
153
|
+
operation="invoke_agent",
|
|
154
|
+
request_model="claude-sonnet-4-20250514",
|
|
155
|
+
) as span:
|
|
156
|
+
result = my_agent_framework_call(prompt)
|
|
157
|
+
span.set_usage(
|
|
158
|
+
input_tokens=result.usage.input_tokens,
|
|
159
|
+
output_tokens=result.usage.output_tokens,
|
|
160
|
+
agent_turns=result.turn_count,
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- Provider wrappers still measure individual model calls and never set
|
|
165
|
+
`agent_turns`.
|
|
166
|
+
- Agent frameworks such as the Claude Agent SDK, OpenAI Agents SDK, and
|
|
167
|
+
LangGraph do not have dedicated wrappers yet; track them manually as shown.
|
|
168
|
+
- `frugal.gen_ai.history_depth` is the request message count
|
|
169
|
+
(`messages.length`), not a turn count.
|
|
170
|
+
- Missing, non-integer, non-positive, or otherwise invalid `agent_turns`
|
|
171
|
+
values are silently dropped. They never raise or affect customer execution.
|
|
172
|
+
- Turn data is emitted only on success, like token usage.
|
|
173
|
+
- **Avoid double-counting tokens.** Only report aggregate `input_tokens` /
|
|
174
|
+
`output_tokens` on the `invoke_agent` call when the agent's inner
|
|
175
|
+
model-client calls are **not** already wrapped by a supported provider
|
|
176
|
+
wrapper. If they are wrapped, those wrappers already emit token usage per
|
|
177
|
+
inner call — report `agent_turns` only on the `invoke_agent` call in that
|
|
178
|
+
case, or the same tokens are double-counted.
|
|
179
|
+
|
|
143
180
|
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.
|
|
144
181
|
|
|
145
182
|
For calls that span function boundaries — where a single `with` block won't fit — use the manual handle:
|
|
@@ -164,7 +201,7 @@ except BaseException as exc:
|
|
|
164
201
|
|
|
165
202
|
**Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional): `system` (→ `gen_ai.system`, default `"custom"`), `operation` (default `"chat"`), `request_model` (default `"<unknown>"`), `custom_label` (→ the `frugal.custom_label` label on every metric of the call; trimmed, and coerced to `other` unless in the `FRUGAL_CUSTOM_LABEL` allowlist). To also run the sampled prompt analyzer, pass any of `messages` / `system_prompt` / `tools` / `response_format` / `max_tokens`.
|
|
166
203
|
|
|
167
|
-
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
|
|
204
|
+
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `agent_turns`, `response_model`.
|
|
168
205
|
|
|
169
206
|
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.
|
|
170
207
|
|
|
@@ -20,6 +20,7 @@ metric may carry the extra attributes shown in the rightmost column.
|
|
|
20
20
|
| `frugal.gen_ai.cache_read_tokens` | histogram | `{token}` | when provider reports cache reads | — |
|
|
21
21
|
| `frugal.gen_ai.cache_write_tokens` | histogram | `{token}` | when provider reports cache writes | — |
|
|
22
22
|
| `frugal.gen_ai.reasoning_tokens` | histogram | `{token}` | OpenAI o-series reasoning models | — |
|
|
23
|
+
| `frugal.gen_ai.agent.turns` | histogram | `{turn}` | successful `invoke_agent` calls with a valid `agent_turns` count (manual tracking only) | — |
|
|
23
24
|
| `frugal.gen_ai.errors` | counter | `{error}` | when the wrapped call raises | `frugal.error.type` |
|
|
24
25
|
| `frugal.gen_ai.internal_errors` | counter | `{error}` | frugal-metrics internal failures (cardinality cap, instrumentation bugs) | `frugal.internal_error.stage` |
|
|
25
26
|
| `frugal.gen_ai.output_cap_utilization`| histogram | `1` | when request had `max_tokens` (ratio 0..1) | `frugal.structured_output_mode` |
|
|
@@ -34,6 +35,37 @@ metric may carry the extra attributes shown in the rightmost column.
|
|
|
34
35
|
`gen_ai.client.token.usage` is split across input and output via the
|
|
35
36
|
`gen_ai.token.type` attribute (`input` | `output`).
|
|
36
37
|
|
|
38
|
+
### Agent turns
|
|
39
|
+
|
|
40
|
+
Manual `track_ai_call` and `start_ai_call` instrumentation accepts the
|
|
41
|
+
`agent_turns` usage kwarg through `AICallSpan.set_usage` and
|
|
42
|
+
`AICallSpan.success`. A sample is emitted only when the call succeeds, the
|
|
43
|
+
normalized `gen_ai.operation.name` is exactly `invoke_agent`, and the value is
|
|
44
|
+
a positive integer. Booleans, missing values, non-integers, zero, and negative
|
|
45
|
+
values are silently ignored; validation never affects customer execution.
|
|
46
|
+
Provider wrappers never set this field, so agent frameworks remain
|
|
47
|
+
manual-tracking call sites in this release. `frugal.gen_ai.history_depth`
|
|
48
|
+
continues to mean request message count, not agent turns. The histogram uses
|
|
49
|
+
explicit bucket boundaries
|
|
50
|
+
`(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 75, 100)` (mirroring
|
|
51
|
+
`gen_ai.client.operation.duration`'s view) so `histogram_quantile()` resolves
|
|
52
|
+
meaningful spread across realistic agent-run turn counts instead of
|
|
53
|
+
collapsing into OTel's duration-tuned default buckets.
|
|
54
|
+
|
|
55
|
+
Only report aggregate token usage on the `invoke_agent` call itself when the
|
|
56
|
+
agent's inner model-client calls are **not** already wrapped by a supported
|
|
57
|
+
provider wrapper; if they are wrapped, report `agent_turns` only on the
|
|
58
|
+
`invoke_agent` call, since the inner wrappers already emit token usage and
|
|
59
|
+
combining both would double-count tokens.
|
|
60
|
+
|
|
61
|
+
To derive average turns per reporting agent invocation in MetricsQL, use:
|
|
62
|
+
|
|
63
|
+
```promql
|
|
64
|
+
increase(frugal.gen_ai.agent.turns_sum[...])
|
|
65
|
+
/
|
|
66
|
+
increase(frugal.gen_ai.agent.turns_count[...])
|
|
67
|
+
```
|
|
68
|
+
|
|
37
69
|
The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
|
|
38
70
|
(default `0.1`). Sampling is deterministic per `(caller_file, caller_function,
|
|
39
71
|
minute)` — within a minute a given call site is either fully sampled or fully
|
|
@@ -182,6 +214,11 @@ Reading `duplicate_requests` correctly:
|
|
|
182
214
|
| `<lambda>` | Call originated from a `lambda` expression. This is Python's native `co_name` for lambdas. Only appears in `frugal.caller_function`. |
|
|
183
215
|
| `<overflow>` | Cardinality cap (`FRUGAL_MAX_CALL_SITES`) exceeded. See below. |
|
|
184
216
|
|
|
217
|
+
> `<overflow>` is the shared overflow sentinel, not a caller-only value: it also
|
|
218
|
+
> appears on `gen_ai.request.model` / `gen_ai.response.model` once
|
|
219
|
+
> `FRUGAL_MAX_MODELS` distinct model strings have been observed. See
|
|
220
|
+
> [Model cap behavior](#model-cap-behavior).
|
|
221
|
+
|
|
185
222
|
### Cardinality cap behavior
|
|
186
223
|
|
|
187
224
|
Once `FRUGAL_MAX_CALL_SITES` distinct `(file, function)` pairs have been
|
|
@@ -196,6 +233,23 @@ The first time the cap is hit per process, the library emits a single
|
|
|
196
233
|
`frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}` point so you can
|
|
197
234
|
see the transition in your dashboard.
|
|
198
235
|
|
|
236
|
+
### Model cap behavior
|
|
237
|
+
|
|
238
|
+
Model ids are free-form provider strings, so `FRUGAL_MAX_MODELS` (default `100`,
|
|
239
|
+
`0` disables) bounds them the same way. The first N distinct model strings a
|
|
240
|
+
process observes keep their real names; every later one is reported as
|
|
241
|
+
`<overflow>` on whichever of `gen_ai.request.model` / `gen_ai.response.model`
|
|
242
|
+
carries it. The registry is process-wide and **shared by both labels** — a model
|
|
243
|
+
first seen in a response is free to use as a request model, and vice versa — so
|
|
244
|
+
a model already in the registry is never re-charged. The default is generous
|
|
245
|
+
because one logical model normally occupies **two** slots: providers answer a
|
|
246
|
+
request alias (`claude-sonnet-4-5`) with a dated response snapshot
|
|
247
|
+
(`claude-sonnet-4-5-20250929`). The SDK's own sentinels — `<unknown>` and
|
|
248
|
+
`<overflow>` — pass through without consuming a slot. The first time the cap is
|
|
249
|
+
hit per process the library emits a single
|
|
250
|
+
`frugal.gen_ai.internal_errors{stage=model_cap_hit}` point plus one `warn`
|
|
251
|
+
through the debug callback.
|
|
252
|
+
|
|
199
253
|
### Internal-error stages
|
|
200
254
|
|
|
201
255
|
`frugal.gen_ai.internal_errors` carries a `frugal.internal_error.stage`
|
|
@@ -208,13 +262,16 @@ attribute describing where our own code failed. Non-exhaustive list:
|
|
|
208
262
|
| `emission` | Emitting a success/error metric threw |
|
|
209
263
|
| `duplicate_detection` | The duplicate-request path (signature, filter, or emission) threw — including the one-time filter allocation failure; carries `frugal.internal_error.type` with the exception class |
|
|
210
264
|
| `cardinality_cap_hit` | `FRUGAL_MAX_CALL_SITES` was just reached (once per process) |
|
|
265
|
+
| `model_cap_hit` | `FRUGAL_MAX_MODELS` was just reached (once per process); further distinct models are reported as `<overflow>` |
|
|
266
|
+
| `otel_cardinality_overflow` | OpenTelemetry merged an instrument's attribute sets into its `otel.metric.overflow` series (once per instrument name); carries `frugal.instrument` with the affected instrument |
|
|
211
267
|
|
|
212
268
|
Unlike every other metric, `frugal.gen_ai.internal_errors` does **not** carry
|
|
213
269
|
the full base attribute set — it is emitted from failure paths where the
|
|
214
270
|
caller/provider/model attributes may not exist yet. Its points carry only
|
|
215
271
|
`frugal.project_id`, `frugal.internal_error.stage` (plus
|
|
216
|
-
`frugal.internal_error.type` where noted
|
|
217
|
-
`
|
|
272
|
+
`frugal.internal_error.type` where noted, and `frugal.instrument` on the
|
|
273
|
+
`otel_cardinality_overflow` stage), and — when set — `frugal.customer_id` and
|
|
274
|
+
`frugal.custom_label`.
|
|
218
275
|
|
|
219
276
|
## Provider coverage
|
|
220
277
|
|
|
@@ -25,11 +25,13 @@ Optional:
|
|
|
25
25
|
| `FRUGAL_REPO_ROOT` | Absolute path to the source root; the strip prefix for `caller_file`. Defaults to the process working directory. |
|
|
26
26
|
| `FRUGAL_OTEL_EXPORTER_OTLP_ENDPOINT` | Frugal-specific OTLP collector URL. Defaults to `https://metrics.frugal.co/opentelemetry/v1/metrics`. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. |
|
|
27
27
|
| `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`. |
|
|
28
|
-
| `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. |
|
|
28
|
+
| `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. The list is deduped (order-preserving) and only the first **10** entries are honored (hard cap, not configurable); the rest are dropped at startup with one debug warning. See [Cardinality caps](#cardinality-caps). |
|
|
29
29
|
| `FRUGAL_CUSTOM_LABEL` | Comma-separated allowlist of values for the `frugal.custom_label` label (e.g. `billing,search`). Applies only to `track_ai_call` / `start_ai_call` calls that pass `custom_label=...`; a value outside the first 20 entries (hard cap, not configurable) is coerced to `other`. |
|
|
30
30
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip when attributing the caller (see below). |
|
|
31
31
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence. Default `60000`. |
|
|
32
|
-
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute before overflow. Default `500`, set to `0` to disable. See [Cardinality
|
|
32
|
+
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute before overflow. Default `500`, set to `0` to disable. See [Cardinality caps](#cardinality-caps). |
|
|
33
|
+
| `FRUGAL_MAX_MODELS` | Cardinality cap — max distinct model strings to attribute before `gen_ai.request.model` / `gen_ai.response.model` overflow to `<overflow>`. One registry shared by both labels, so a request alias and its dated response snapshot each take a slot. Default `100`, set to `0` to disable. See [Cardinality caps](#cardinality-caps). |
|
|
34
|
+
| `FRUGAL_MAX_SERIES_PER_INSTRUMENT` | OpenTelemetry's per-instrument limit on distinct attribute **combinations**, raised from the SDK default. Default `50000`, set to `0` for effectively unlimited (a 1,000,000 ceiling). See [Cardinality caps](#cardinality-caps). |
|
|
33
35
|
| `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) where the lightweight prompt analyzer runs and emits derived metrics (`noise_ratio`, `cache_prefix_stable`, `few_shot_*`, …). Default `0.1`. Set to `0` to disable, `1` for every call. See [Prompt analysis](#prompt-analysis). |
|
|
34
36
|
| `FRUGAL_DUPLICATE_REQUEST_DETECTION` | Duplicate-request detection (`frugal.gen_ai.duplicate_requests`) is **on by default**: every chat request is canonicalized and SHA-256 hashed in-process to classify it `first_seen`/`repeat_seen`. Set to `0`/`false`/`no`/`off` to turn it off entirely (no hashing, no filter memory, no metric). |
|
|
35
37
|
| `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
|
|
@@ -231,6 +233,39 @@ with track_ai_call(system="anthropic", request_model="claude-sonnet-4-20250514")
|
|
|
231
233
|
)
|
|
232
234
|
```
|
|
233
235
|
|
|
236
|
+
For an agent framework, manually track the enclosing invocation and report its
|
|
237
|
+
model-response turns:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
with track_ai_call(
|
|
241
|
+
system="anthropic",
|
|
242
|
+
operation="invoke_agent",
|
|
243
|
+
request_model="claude-sonnet-4-20250514",
|
|
244
|
+
) as span:
|
|
245
|
+
result = my_agent_framework_call(prompt)
|
|
246
|
+
span.set_usage(
|
|
247
|
+
input_tokens=result.usage.input_tokens,
|
|
248
|
+
output_tokens=result.usage.output_tokens,
|
|
249
|
+
agent_turns=result.turn_count,
|
|
250
|
+
)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Provider wrappers measure individual model calls and never set `agent_turns`;
|
|
254
|
+
the Claude Agent SDK, OpenAI Agents SDK, LangGraph, and other agent frameworks
|
|
255
|
+
do not have dedicated wrappers yet. `frugal.gen_ai.history_depth` is request
|
|
256
|
+
message count (`messages.length`), not turns. Missing, non-integer,
|
|
257
|
+
non-positive, or otherwise invalid values are silently dropped without
|
|
258
|
+
affecting customer execution, and turn data is success-path only, like token
|
|
259
|
+
usage. See the [metrics reference](metrics-reference.md) for the complete
|
|
260
|
+
contract.
|
|
261
|
+
|
|
262
|
+
**Avoid double-counting tokens.** Only report aggregate `input_tokens` /
|
|
263
|
+
`output_tokens` on the `invoke_agent` call when the agent's inner
|
|
264
|
+
model-client calls are **not** already wrapped by a supported provider
|
|
265
|
+
wrapper. If they are wrapped, those wrappers already emit token usage per
|
|
266
|
+
inner call — report `agent_turns` only on the `invoke_agent` call in that
|
|
267
|
+
case, or the same tokens are double-counted.
|
|
268
|
+
|
|
234
269
|
The `with` block times the call and emits metrics on exit — or an error metric
|
|
235
270
|
if the block raises (the exception always propagates). Works the same around an
|
|
236
271
|
`await` in async code. Call `span.set_usage(...)` once the response is back; if
|
|
@@ -268,7 +303,7 @@ prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
|
|
|
268
303
|
|
|
269
304
|
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional):
|
|
270
305
|
`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
|
|
271
|
-
`reasoning_tokens`, `response_model`.
|
|
306
|
+
`reasoning_tokens`, `agent_turns`, `response_model`.
|
|
272
307
|
|
|
273
308
|
The same env-var configuration, safety guarantees, and exported metrics apply.
|
|
274
309
|
|
|
@@ -291,12 +326,22 @@ FRUGAL_PATH_BLOCK_LIST="src/acme_ai_helpers,libs/llm_utils"
|
|
|
291
326
|
Paths are repo-relative (rooted at `FRUGAL_REPO_ROOT`) and match any file
|
|
292
327
|
under the given prefix.
|
|
293
328
|
|
|
294
|
-
## Cardinality
|
|
329
|
+
## Cardinality caps
|
|
330
|
+
|
|
331
|
+
Runaway metric cardinality — a bug that calls the SDK from thousands of source
|
|
332
|
+
locations, a model id with a timestamp in it, a component list that grows with
|
|
333
|
+
every deploy — is the one way this library can cost you real money. Four caps
|
|
334
|
+
bound it: **three configurable caps**, each of which takes `0` to disable, plus
|
|
335
|
+
a fixed one on components.
|
|
295
336
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
`
|
|
299
|
-
|
|
337
|
+
| Cap | Setting | Default | What overflows |
|
|
338
|
+
| --- | --- | --- | --- |
|
|
339
|
+
| Call sites | `FRUGAL_MAX_CALL_SITES` | `500` | `frugal.caller_file` / `frugal.caller_function` → `<overflow>` |
|
|
340
|
+
| Models | `FRUGAL_MAX_MODELS` | `100` | `gen_ai.request.model` / `gen_ai.response.model` → `<overflow>` |
|
|
341
|
+
| Components | fixed, not configurable | `10` | extra `FRUGAL_COMPONENTS` entries are dropped |
|
|
342
|
+
| Series per instrument | `FRUGAL_MAX_SERIES_PER_INSTRUMENT` | `50000` | OpenTelemetry's own limit (see below); `0` = effectively unlimited (a 1,000,000 ceiling) |
|
|
343
|
+
|
|
344
|
+
### Call sites (`FRUGAL_MAX_CALL_SITES`)
|
|
300
345
|
|
|
301
346
|
**First-come-first-served**: the first N unique call sites observed are
|
|
302
347
|
always reported with their real names. Additional call sites overflow:
|
|
@@ -316,6 +361,75 @@ To disable the cap entirely (you vouch that your cardinality is bounded):
|
|
|
316
361
|
FRUGAL_MAX_CALL_SITES=0
|
|
317
362
|
```
|
|
318
363
|
|
|
364
|
+
### Models (`FRUGAL_MAX_MODELS`)
|
|
365
|
+
|
|
366
|
+
Model ids are free-form provider strings, so nothing stops a gateway or a
|
|
367
|
+
fine-tune naming scheme from minting a new one per request. The first **100**
|
|
368
|
+
distinct model strings a process sees keep their real names; every one after
|
|
369
|
+
that is reported as `<overflow>`.
|
|
370
|
+
|
|
371
|
+
The registry is **shared** by `gen_ai.request.model` and
|
|
372
|
+
`gen_ai.response.model` — a model first seen in a response is free to use as a
|
|
373
|
+
request model, and vice versa — and it is process-wide, so a call site that
|
|
374
|
+
starts using an already-seen model is never charged for it. The default is
|
|
375
|
+
deliberately generous because **one logical model routinely burns two slots**:
|
|
376
|
+
providers answer a request alias (`claude-sonnet-4-5`) with a dated response
|
|
377
|
+
snapshot (`claude-sonnet-4-5-20250929`), and both labels draw on this one
|
|
378
|
+
registry. The SDK's own sentinels — `<unknown>` (a batch submission with no
|
|
379
|
+
discoverable model) and `<overflow>` itself — never consume a slot.
|
|
380
|
+
|
|
381
|
+
The first time the cap is crossed the SDK emits one
|
|
382
|
+
`frugal.gen_ai.internal_errors{stage=model_cap_hit}` point plus one `warn`
|
|
383
|
+
through the debug callback; further overflows are silent.
|
|
384
|
+
|
|
385
|
+
### Components (`FRUGAL_COMPONENTS`)
|
|
386
|
+
|
|
387
|
+
`FRUGAL_COMPONENTS` is a static list, and every entry multiplies **every**
|
|
388
|
+
metric's series count (each metric is emitted once for the base project series
|
|
389
|
+
and once per component). The list is deduplicated first (order-preserving, first
|
|
390
|
+
occurrence wins), then only the first **10** entries are honored; the rest are
|
|
391
|
+
dropped at startup with one `warn` through the debug callback naming the deduped
|
|
392
|
+
and dropped counts. Like the `FRUGAL_CUSTOM_LABEL` allowlist this is a fixed constant with no
|
|
393
|
+
env var to change it. There is no sentinel value — nothing is emitted for a
|
|
394
|
+
dropped component, and the un-attributed base series still carries every call,
|
|
395
|
+
so project totals stay correct.
|
|
396
|
+
|
|
397
|
+
### Series per instrument (`FRUGAL_MAX_SERIES_PER_INSTRUMENT`)
|
|
398
|
+
|
|
399
|
+
The three caps above bound individual **labels**. OpenTelemetry bounds something
|
|
400
|
+
different: the number of distinct label **combinations** (attribute sets) per
|
|
401
|
+
instrument. 400 call sites × 3 models × 2 token types × 7 history buckets is one
|
|
402
|
+
instrument's worth of combinations, not four labels' worth — which is why the
|
|
403
|
+
SDK raises that per-instrument limit to **50000** via
|
|
404
|
+
`FRUGAL_MAX_SERIES_PER_INSTRUMENT` (`0` = effectively unlimited; you vouch for
|
|
405
|
+
your cardinality).
|
|
406
|
+
|
|
407
|
+
When an instrument does exceed its limit, OTel does not drop the excess points —
|
|
408
|
+
it **merges them into a single overflow series with every attribute stripped**
|
|
409
|
+
and replaced by `otel.metric.overflow=true`. Project id, caller, model, token
|
|
410
|
+
type: all gone. The numbers still add up, but that instrument goes dark for
|
|
411
|
+
every breakdown you care about. The SDK detects that condition on the export
|
|
412
|
+
path and emits
|
|
413
|
+
`frugal.gen_ai.internal_errors{stage=otel_cardinality_overflow, frugal.instrument=…}`
|
|
414
|
+
once per affected instrument, plus one `error` through the debug callback.
|
|
415
|
+
|
|
416
|
+
On Python the limit kwarg is passed **only when the installed
|
|
417
|
+
`opentelemetry-sdk` accepts it** — as of 1.42 it does not implement the spec's
|
|
418
|
+
per-instrument limit at all, so nothing is merged and nothing is raised. That
|
|
419
|
+
omission is silent unless you set `FRUGAL_MAX_SERIES_PER_INSTRUMENT` yourself,
|
|
420
|
+
in which case the SDK emits one `warn` telling you the setting has no effect on
|
|
421
|
+
the installed OTel. The detector exists so that the day OTel Python ships the
|
|
422
|
+
limit, an overflow is a loud condition rather than a silent one.
|
|
423
|
+
|
|
424
|
+
> **Memory.** All 17 instruments share the same label space, but only the 3–4
|
|
425
|
+
> busiest (the calls counter, the token histogram, the duration histogram)
|
|
426
|
+
> realistically approach the limit — the rest carry a fraction of the
|
|
427
|
+
> combinations. At a few hundred bytes per attribute set, 50000 sets on the busy
|
|
428
|
+
> ones is tens of MB worst case, comparable to the duplicate filter's documented
|
|
429
|
+
> ceiling. The value is not clamped: the real cost of high cardinality is active
|
|
430
|
+
> series on the metrics server, which is why the model and component axes are
|
|
431
|
+
> capped rather than the limit being lowered.
|
|
432
|
+
|
|
319
433
|
## Prompt analysis
|
|
320
434
|
|
|
321
435
|
In addition to token-level metrics, the library runs a lightweight per-request
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"targets": [
|
|
110
110
|
{
|
|
111
111
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
112
|
-
"expr": "sum(frugal_gen_ai_internal_errors_total{frugal_internal_error_stage
|
|
112
|
+
"expr": "sum(frugal_gen_ai_internal_errors_total{frugal_internal_error_stage=~\"cardinality_cap_hit|model_cap_hit|otel_cardinality_overflow\"}) or vector(0)",
|
|
113
113
|
"legendFormat": "events",
|
|
114
114
|
"refId": "A"
|
|
115
115
|
}
|
|
@@ -11,6 +11,12 @@ from __future__ import annotations
|
|
|
11
11
|
import threading
|
|
12
12
|
from collections import OrderedDict
|
|
13
13
|
from dataclasses import dataclass
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
# Matches the default call-site cardinality cap. Also the floor: this LRU lives
|
|
17
|
+
# in the customer's process, so an unbounded one is a memory leak rather than a
|
|
18
|
+
# cardinality choice.
|
|
19
|
+
_DEFAULT_CAPACITY: Final[int] = 500
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
@dataclass(frozen=True)
|
|
@@ -22,8 +28,12 @@ class BlockHashes:
|
|
|
22
28
|
|
|
23
29
|
|
|
24
30
|
class BlockHashLRU:
|
|
25
|
-
def __init__(self, capacity: int =
|
|
26
|
-
|
|
31
|
+
def __init__(self, capacity: int = _DEFAULT_CAPACITY) -> None:
|
|
32
|
+
# The capacity is fed from FRUGAL_MAX_CALL_SITES, where 0 means "don't
|
|
33
|
+
# cap the label cardinality — I vouch for it". That is a statement about
|
|
34
|
+
# what reaches the metrics server, NOT permission to grow a process-local
|
|
35
|
+
# map forever, so a non-positive value means "default capacity" here.
|
|
36
|
+
self._capacity = capacity if capacity > 0 else _DEFAULT_CAPACITY
|
|
27
37
|
self._map: "OrderedDict[str, BlockHashes]" = OrderedDict()
|
|
28
38
|
self._lock = threading.Lock()
|
|
29
39
|
|
|
@@ -42,9 +52,10 @@ class BlockHashLRU:
|
|
|
42
52
|
self._map[call_site_id] = hashes
|
|
43
53
|
return
|
|
44
54
|
self._map[call_site_id] = hashes
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
# _capacity is normalized to a positive value in __init__, so this
|
|
56
|
+
# loop always runs — there is no "never evict" mode.
|
|
57
|
+
while len(self._map) > self._capacity:
|
|
58
|
+
self._map.popitem(last=False)
|
|
48
59
|
|
|
49
60
|
def size(self) -> int:
|
|
50
61
|
with self._lock:
|
|
@@ -65,7 +76,12 @@ def get_shared_block_hash_lru(capacity: int | None = None) -> BlockHashLRU:
|
|
|
65
76
|
return _shared
|
|
66
77
|
with _shared_lock:
|
|
67
78
|
if _shared is None:
|
|
68
|
-
|
|
79
|
+
# A None or non-positive capacity both mean "use the default" —
|
|
80
|
+
# BlockHashLRU.__init__ enforces the floor, so callers passing
|
|
81
|
+
# FRUGAL_MAX_CALL_SITES=0 straight through still get a bounded map.
|
|
82
|
+
_shared = BlockHashLRU(
|
|
83
|
+
capacity if capacity is not None else _DEFAULT_CAPACITY
|
|
84
|
+
)
|
|
69
85
|
return _shared
|
|
70
86
|
|
|
71
87
|
|