frugal-sdk-python 0.2.0__tar.gz → 0.4.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.2.0 → frugal_sdk_python-0.4.0}/PKG-INFO +26 -13
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/README.md +24 -11
- frugal_sdk_python-0.4.0/docs/metrics-reference.md +251 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/docs/quickstart.md +34 -7
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/__init__.py +18 -0
- frugal_sdk_python-0.4.0/src/frugal_metrics/config.py +448 -0
- frugal_sdk_python-0.4.0/src/frugal_metrics/duplicate_filter.py +256 -0
- frugal_sdk_python-0.4.0/src/frugal_metrics/instrument.py +520 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/otel.py +20 -0
- frugal_sdk_python-0.4.0/src/frugal_metrics/prompt_analyzer.py +554 -0
- frugal_sdk_python-0.4.0/src/frugal_metrics/providers.py +219 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/semconv.py +48 -1
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/track.py +21 -1
- frugal_sdk_python-0.4.0/src/frugal_metrics/wrappers/_batch.py +445 -0
- frugal_sdk_python-0.4.0/src/frugal_metrics/wrappers/_patching.py +51 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/anthropic.py +10 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/openai.py +44 -24
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/conftest.py +22 -0
- frugal_sdk_python-0.4.0/tests/test_config.py +357 -0
- frugal_sdk_python-0.4.0/tests/test_duplicate_filter.py +234 -0
- frugal_sdk_python-0.4.0/tests/test_prompt_analyzer.py +655 -0
- frugal_sdk_python-0.4.0/tests/test_providers.py +180 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_track_ai_call.py +167 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_anthropic.py +24 -0
- frugal_sdk_python-0.4.0/tests/test_wrap_anthropic_batches.py +244 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_openai.py +16 -0
- frugal_sdk_python-0.4.0/tests/test_wrap_openai_analyzer.py +499 -0
- frugal_sdk_python-0.4.0/tests/test_wrap_openai_batches.py +199 -0
- frugal_sdk_python-0.2.0/docs/metrics-reference.md +0 -136
- frugal_sdk_python-0.2.0/src/frugal_metrics/config.py +0 -242
- frugal_sdk_python-0.2.0/src/frugal_metrics/instrument.py +0 -276
- frugal_sdk_python-0.2.0/src/frugal_metrics/prompt_analyzer.py +0 -273
- frugal_sdk_python-0.2.0/tests/test_config.py +0 -163
- frugal_sdk_python-0.2.0/tests/test_prompt_analyzer.py +0 -203
- frugal_sdk_python-0.2.0/tests/test_wrap_openai_analyzer.py +0 -188
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/.github/workflows/cd.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/.gitignore +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/LICENSES/Apache-2.0.txt +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/REUSE.toml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/README.md +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/docker-compose.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/Dockerfile +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/package.json +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/mockllm/src/server.js +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/models.html +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/prometheus/prometheus.yml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/Dockerfile +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/clients.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/main.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/pyproject.toml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/sample-app/requirements.txt +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/examples/test-env/scripts/verify.sh +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/pyproject.toml +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/block_hash_lru.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/caller.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/debug.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/safe.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/shutdown.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/streams.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/__init__.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_bedrock_models.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_block_hash_lru.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_caller.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_caller_edge_cases.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_debug.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_shutdown.py +0 -0
- {frugal_sdk_python-0.2.0 → frugal_sdk_python-0.4.0}/tests/test_wrap_bedrock.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: frugal-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,10 +78,17 @@ 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. |
|
|
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`. |
|
|
81
83
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
|
|
82
84
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
|
|
83
85
|
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
|
|
84
86
|
| `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
|
+
| `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
|
+
| `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
|
|
89
|
+
| `FRUGAL_DUPLICATE_CAPACITY` | Expected distinct request signatures per window (sizes the in-memory filter). Default `10000`, max `1000000`. Raise it if the SDK warns the filter is saturated. |
|
|
90
|
+
| `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
|
|
91
|
+
| `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
|
|
85
92
|
| `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
|
|
86
93
|
|
|
87
94
|
## What gets captured
|
|
@@ -91,32 +98,38 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
|
|
|
91
98
|
| Metric | Kind | Description | Additional attributes |
|
|
92
99
|
| --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
|
|
93
100
|
| `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
|
|
94
|
-
| `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`
|
|
95
|
-
| `frugal.gen_ai.calls` | counter | Number of calls per site |
|
|
101
|
+
| `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`, `frugal.history_bucket` |
|
|
102
|
+
| `frugal.gen_ai.calls` | counter | Number of calls per site | `frugal.history_bucket`, `frugal.structured_output_mode` |
|
|
96
103
|
| `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
|
|
97
104
|
| `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
|
|
98
105
|
| `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
|
|
99
106
|
| `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
|
|
100
107
|
| `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
|
|
101
108
|
| `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
|
|
102
|
-
| `frugal.gen_ai.history_depth` | histogram | `messages.length` (
|
|
109
|
+
| `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
|
|
110
|
+
| `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
|
|
103
111
|
| `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
|
|
112
|
+
| `frugal.gen_ai.few_shot_tokens` | histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
|
|
104
113
|
| `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
|
|
105
114
|
| `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
|
|
106
115
|
| `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
|
|
107
116
|
|
|
108
|
-
The
|
|
109
|
-
(`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics
|
|
110
|
-
100% — never sampled.
|
|
117
|
+
The analyzer-sampled metrics fire on a fraction of calls
|
|
118
|
+
(`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics,
|
|
119
|
+
`history_depth`, and `duplicate_requests` are 100% — never sampled.
|
|
111
120
|
|
|
112
|
-
Every metric carries this base attribute set
|
|
121
|
+
Every metric carries this base attribute set (except
|
|
122
|
+
`frugal.gen_ai.internal_errors`, which is emitted from failure paths where
|
|
123
|
+
these attributes may not exist yet and carries only `frugal.project_id`, the
|
|
124
|
+
stage/type, and `frugal.customer_id` / `frugal.custom_label` when set):
|
|
113
125
|
|
|
114
126
|
- `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
127
|
- `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
|
|
116
|
-
- `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `
|
|
128
|
+
- `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
129
|
- `gen_ai.request.model`, `gen_ai.response.model` — the model used
|
|
118
|
-
- `gen_ai.operation.name` — `chat`, `text_completion`, or `
|
|
119
|
-
- `frugal.batched` — `true` for batch-API submissions, `false` otherwise
|
|
130
|
+
- `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
|
+
- `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
|
|
132
|
+
- `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`
|
|
120
133
|
|
|
121
134
|
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.
|
|
122
135
|
|
|
@@ -178,7 +191,7 @@ except BaseException as exc:
|
|
|
178
191
|
|
|
179
192
|
`success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
|
|
180
193
|
|
|
181
|
-
**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`.
|
|
194
|
+
**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`.
|
|
182
195
|
|
|
183
196
|
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
|
|
184
197
|
|
|
@@ -49,10 +49,17 @@ 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. |
|
|
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`. |
|
|
52
54
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip for caller attribution (see below). |
|
|
53
55
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence in milliseconds. Default `60000`. |
|
|
54
56
|
| `FRUGAL_MAX_CALL_SITES` | Cardinality cap — max distinct `(caller_file, caller_function)` pairs to attribute. Default `500`. Set to `0` to disable. |
|
|
55
57
|
| `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
|
+
| `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
|
+
| `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
|
|
60
|
+
| `FRUGAL_DUPLICATE_CAPACITY` | Expected distinct request signatures per window (sizes the in-memory filter). Default `10000`, max `1000000`. Raise it if the SDK warns the filter is saturated. |
|
|
61
|
+
| `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
|
|
62
|
+
| `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
|
|
56
63
|
| `FRUGAL_DISABLED` | Set to `1` to disable all instrumentation without uninstalling. |
|
|
57
64
|
|
|
58
65
|
## What gets captured
|
|
@@ -62,32 +69,38 @@ Every wrapped call emits OTel metrics tagged with the caller's source file and f
|
|
|
62
69
|
| Metric | Kind | Description | Additional attributes |
|
|
63
70
|
| --------------------------------------- | --------- | ------------------------------------------------------------ | -------------------------------------- |
|
|
64
71
|
| `gen_ai.client.operation.duration` | histogram | Call latency in seconds | — |
|
|
65
|
-
| `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`
|
|
66
|
-
| `frugal.gen_ai.calls` | counter | Number of calls per site |
|
|
72
|
+
| `gen_ai.client.token.usage` | histogram | Input and output token counts | `gen_ai.token.type`, `frugal.history_bucket` |
|
|
73
|
+
| `frugal.gen_ai.calls` | counter | Number of calls per site | `frugal.history_bucket`, `frugal.structured_output_mode` |
|
|
67
74
|
| `frugal.gen_ai.cache_read_tokens` | histogram | Prompt cache hits | — |
|
|
68
75
|
| `frugal.gen_ai.cache_write_tokens` | histogram | Prompt cache writes | — |
|
|
69
76
|
| `frugal.gen_ai.reasoning_tokens` | histogram | OpenAI reasoning tokens (o-series), Anthropic thinking tokens| — |
|
|
70
77
|
| `frugal.gen_ai.errors` | counter | Errors by exception type | `frugal.error.type` |
|
|
71
78
|
| `frugal.gen_ai.internal_errors` | counter | frugal-metrics internal failures (cardinality cap, bugs) | `frugal.internal_error.stage` |
|
|
72
79
|
| `frugal.gen_ai.output_cap_utilization` | histogram | `output_tokens / max_tokens` ratio when `max_tokens` is set | `frugal.structured_output_mode` |
|
|
73
|
-
| `frugal.gen_ai.history_depth` | histogram | `messages.length` (
|
|
80
|
+
| `frugal.gen_ai.history_depth` | histogram | `messages.length` — every chat call (100%, not sampled) | — |
|
|
81
|
+
| `frugal.gen_ai.duplicate_requests` | counter | Repeat-request detection — every chat call (100%, not sampled; disable via `FRUGAL_DUPLICATE_REQUEST_DETECTION`) | `frugal.dup_state` |
|
|
74
82
|
| `frugal.gen_ai.few_shot_count` | histogram | Few-shot exemplar pairs before final user (analyzer-sampled) | — |
|
|
83
|
+
| `frugal.gen_ai.few_shot_tokens` | histogram | Approx token size of the few-shot example block (analyzer-sampled) | — |
|
|
75
84
|
| `frugal.gen_ai.noise_ratio` | histogram | HTML/base64/whitespace fraction of input (analyzer-sampled) | — |
|
|
76
85
|
| `frugal.gen_ai.cache_prefix_stable` | counter | Per-block prompt-cache stability (analyzer-sampled) | `frugal.block`, `frugal.stable` |
|
|
77
86
|
| `frugal.gen_ai.structured_output_hint` | counter | "Return JSON" prompts missing `response_format`/`tools` | — |
|
|
78
87
|
|
|
79
|
-
The
|
|
80
|
-
(`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics
|
|
81
|
-
100% — never sampled.
|
|
88
|
+
The analyzer-sampled metrics fire on a fraction of calls
|
|
89
|
+
(`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`, default 10%); the token-side metrics,
|
|
90
|
+
`history_depth`, and `duplicate_requests` are 100% — never sampled.
|
|
82
91
|
|
|
83
|
-
Every metric carries this base attribute set
|
|
92
|
+
Every metric carries this base attribute set (except
|
|
93
|
+
`frugal.gen_ai.internal_errors`, which is emitted from failure paths where
|
|
94
|
+
these attributes may not exist yet and carries only `frugal.project_id`, the
|
|
95
|
+
stage/type, and `frugal.customer_id` / `frugal.custom_label` when set):
|
|
84
96
|
|
|
85
97
|
- `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
98
|
- `frugal.caller_file`, `frugal.caller_function` — auto-detected from the call stack
|
|
87
|
-
- `gen_ai.system` — `openai`, `anthropic`, `aws.bedrock`, or `
|
|
99
|
+
- `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
100
|
- `gen_ai.request.model`, `gen_ai.response.model` — the model used
|
|
89
|
-
- `gen_ai.operation.name` — `chat`, `text_completion`, or `
|
|
90
|
-
- `frugal.batched` — `true` for batch-API submissions, `false` otherwise
|
|
101
|
+
- `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
|
+
- `frugal.batched` — `true` for batch-API paths (OpenAI Batch API submissions; Anthropic Message Batches submissions and results), `false` otherwise
|
|
103
|
+
- `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`
|
|
91
104
|
|
|
92
105
|
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.
|
|
93
106
|
|
|
@@ -149,7 +162,7 @@ except BaseException as exc:
|
|
|
149
162
|
|
|
150
163
|
`success()` and `error()` finalize the call exactly once and are idempotent — extra calls are ignored.
|
|
151
164
|
|
|
152
|
-
**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`.
|
|
165
|
+
**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`.
|
|
153
166
|
|
|
154
167
|
**Usage** — `set_usage(...)` / `success(...)` kwargs (all optional): `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `response_model`.
|
|
155
168
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Metrics reference
|
|
2
|
+
|
|
3
|
+
All metrics follow the OpenTelemetry GenAI semantic convention where a name
|
|
4
|
+
exists; Frugal-namespaced extensions (`frugal.*`) cover signals semconv does
|
|
5
|
+
not yet define.
|
|
6
|
+
|
|
7
|
+
Every metric point carries the attribute set listed under
|
|
8
|
+
[Attributes](#attributes) below.
|
|
9
|
+
|
|
10
|
+
## Metrics
|
|
11
|
+
|
|
12
|
+
In addition to the base attribute set (see [Attributes](#attributes)), each
|
|
13
|
+
metric may carry the extra attributes shown in the rightmost column.
|
|
14
|
+
|
|
15
|
+
| Name | Kind | Unit | Emitted by | Additional attributes |
|
|
16
|
+
| ------------------------------------- | --------- | ----------- | ----------------------------------------------------- | ---------------------------------------------- |
|
|
17
|
+
| `gen_ai.client.operation.duration` | histogram | `s` | every call, success or error | — |
|
|
18
|
+
| `gen_ai.client.token.usage` | histogram | `{token}` | every call with a usage payload | `gen_ai.token.type` |
|
|
19
|
+
| `frugal.gen_ai.calls` | counter | `{call}` | every call, success or error | `frugal.history_bucket` (chat calls), `frugal.structured_output_mode` (with payload) |
|
|
20
|
+
| `frugal.gen_ai.cache_read_tokens` | histogram | `{token}` | when provider reports cache reads | — |
|
|
21
|
+
| `frugal.gen_ai.cache_write_tokens` | histogram | `{token}` | when provider reports cache writes | — |
|
|
22
|
+
| `frugal.gen_ai.reasoning_tokens` | histogram | `{token}` | OpenAI o-series reasoning models | — |
|
|
23
|
+
| `frugal.gen_ai.errors` | counter | `{error}` | when the wrapped call raises | `frugal.error.type` |
|
|
24
|
+
| `frugal.gen_ai.internal_errors` | counter | `{error}` | frugal-metrics internal failures (cardinality cap, instrumentation bugs) | `frugal.internal_error.stage` |
|
|
25
|
+
| `frugal.gen_ai.output_cap_utilization`| histogram | `1` | when request had `max_tokens` (ratio 0..1) | `frugal.structured_output_mode` |
|
|
26
|
+
| `frugal.gen_ai.history_depth` | histogram | `{message}` | every chat call (`messages.length`) — 100%, not sampled | — |
|
|
27
|
+
| `frugal.gen_ai.few_shot_count` | histogram | `{example}` | analyzer-sampled single-turn chat (count of `(user, assistant)` pairs before final user) | — |
|
|
28
|
+
| `frugal.gen_ai.few_shot_tokens` | histogram | `{token}` | analyzer-sampled single-turn chat — approx token size of the `(user, assistant)` example block, beside `few_shot_count` | — |
|
|
29
|
+
| `frugal.gen_ai.noise_ratio` | histogram | `1` | analyzer-sampled chat call (HTML / base64 / whitespace fraction of input) | — |
|
|
30
|
+
| `frugal.gen_ai.cache_prefix_stable` | counter | `{call}` | analyzer-sampled chat call, when previous call to the same call site existed (one point per block, see Attributes) | `frugal.block`, `frugal.stable` |
|
|
31
|
+
| `frugal.gen_ai.structured_output_hint`| counter | `{call}` | analyzer-sampled chat call where the prompt asks for JSON without `response_format` or `tools` | — |
|
|
32
|
+
| `frugal.gen_ai.duplicate_requests` | counter | `{call}` | every chat call with a canonicalizable request, success or error — 100%, not sampled; off when `FRUGAL_DUPLICATE_REQUEST_DETECTION=false` | `frugal.dup_state` |
|
|
33
|
+
|
|
34
|
+
`gen_ai.client.token.usage` is split across input and output via the
|
|
35
|
+
`gen_ai.token.type` attribute (`input` | `output`).
|
|
36
|
+
|
|
37
|
+
The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
|
|
38
|
+
(default `0.1`). Sampling is deterministic per `(caller_file, caller_function,
|
|
39
|
+
minute)` — within a minute a given call site is either fully sampled or fully
|
|
40
|
+
skipped, so the LRU comparisons that drive `cache_prefix_stable` see contiguous
|
|
41
|
+
data when active. The token-side metrics (cap utilization, batched label,
|
|
42
|
+
calls, token usage) are 100% — never sampled. `frugal.gen_ai.history_depth` and
|
|
43
|
+
the `frugal.history_bucket` label are likewise 100% — `messages.length` is a
|
|
44
|
+
trivially-cheap integer needing no prompt analysis, so it is emitted on every chat
|
|
45
|
+
call (`few_shot_count`, `few_shot_tokens`, `noise_ratio`, `cache_prefix_stable`, and
|
|
46
|
+
`structured_output_hint` remain analyzer-sampled). `few_shot_tokens` is emitted from
|
|
47
|
+
the same analyzer pass as `few_shot_count` (identifying which turns are examples
|
|
48
|
+
needs prompt inspection, so there is no cheap 100% path); its token count is an
|
|
49
|
+
approximate chars/4 estimate over the example block.
|
|
50
|
+
|
|
51
|
+
Label placement and cardinality: `frugal.history_bucket` rides BOTH the
|
|
52
|
+
`frugal.gen_ai.calls` counter and the `gen_ai.client.token.usage` histogram —
|
|
53
|
+
on token.usage deliberately, so spend can be cut by conversation depth.
|
|
54
|
+
Worst-case that multiplies the token.usage series by 7 buckets × 2 token types
|
|
55
|
+
per call site (in practice a site occupies 1–3 buckets, so expect 2–6× — but
|
|
56
|
+
provision for 14×). `frugal.structured_output_mode` rides ONLY `calls` (and
|
|
57
|
+
`output_cap_utilization`), NOT token.usage — the full 7×4 bucket×mode
|
|
58
|
+
cross-tab lives on `calls` alone, where worst-case it is 28 series per call
|
|
59
|
+
site.
|
|
60
|
+
|
|
61
|
+
`frugal.gen_ai.duplicate_requests` is also 100% (independent of
|
|
62
|
+
`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`). Each chat call's request is canonicalized
|
|
63
|
+
(`model + system + messages + tools + response_format + max_tokens` — plus
|
|
64
|
+
`previous_response_id` for stateful OpenAI Responses-API calls — with the call
|
|
65
|
+
site folded in) and hashed; the hash is checked against a shared, time-and-size-
|
|
66
|
+
limited rotating Bloom filter and classified `first_seen` / `repeat_seen`. Errored
|
|
67
|
+
calls are classified too — a failed-then-retried identical request reads
|
|
68
|
+
`repeat_seen`, keeping retry storms visible. The raw signature never leaves the
|
|
69
|
+
SDK — only the 2-value state is exported. Hashing is incremental and hard-capped
|
|
70
|
+
at 256 KiB of canonicalized request per call: the small discriminators (call
|
|
71
|
+
site, model, system, tools, response_format, max_tokens, previous_response_id)
|
|
72
|
+
are hashed FIRST, then `messages` under the remaining budget — so an over-budget
|
|
73
|
+
history truncates the tail of the conversation (plus a truncation marker), never
|
|
74
|
+
the fields that keep distinct call sites or models from colliding. Binary/image
|
|
75
|
+
content contributes only its type and length — never its bytes — covering raw
|
|
76
|
+
`bytes` blocks AND the base64-string forms providers actually send (OpenAI
|
|
77
|
+
`data:` URLs, Anthropic base64 source blocks). If the filter saturates (actual
|
|
78
|
+
traffic far above `FRUGAL_DUPLICATE_CAPACITY`, generation union over 50% full),
|
|
79
|
+
a one-time `warn` diagnostic is emitted through the debug callback. If the
|
|
80
|
+
filter's lazy bit-array allocation fails (`MemoryError`), duplicate detection is
|
|
81
|
+
**disabled for the rest of the process**: a one-time `error` diagnostic is
|
|
82
|
+
emitted, the first failing call records a
|
|
83
|
+
`frugal.gen_ai.internal_errors{stage=duplicate_detection}` point, and every
|
|
84
|
+
subsequent call skips detection silently (no `duplicate_requests` metric).
|
|
85
|
+
Controls:
|
|
86
|
+
|
|
87
|
+
- `FRUGAL_DUPLICATE_REQUEST_DETECTION` (default `true`) — kill switch. When
|
|
88
|
+
`false`/`0`/`no`/`off`, no filter is allocated, no signature is hashed, and the
|
|
89
|
+
metric is not emitted. Any other value keeps detection enabled and emits a
|
|
90
|
+
one-time `warn` diagnostic naming the unrecognized value.
|
|
91
|
+
- `FRUGAL_DUPLICATE_WINDOW_SECONDS` (default `3600`, clamped to `604800` — 7
|
|
92
|
+
days) — retention window. Because the filter rotates in `generations` slots,
|
|
93
|
+
a signature is remembered for **at least**
|
|
94
|
+
`window × (generations−1)/generations` and **at most** `window` seconds
|
|
95
|
+
(30–60 min at defaults) — treat the counterfactual cache TTL as that range,
|
|
96
|
+
not a flat window.
|
|
97
|
+
- `FRUGAL_DUPLICATE_CAPACITY` (default `10000`, clamped to `1000000`) — expected
|
|
98
|
+
distinct signatures per window (Bloom sizing target).
|
|
99
|
+
- `FRUGAL_DUPLICATE_FP_RATE` (default `0.01`, clamped to `[1e-6, 0.5]`) —
|
|
100
|
+
target false-positive rate.
|
|
101
|
+
- `FRUGAL_DUPLICATE_GENERATIONS` (default `2`, clamped to `16`) — rotating-Bloom
|
|
102
|
+
generation count.
|
|
103
|
+
|
|
104
|
+
Out-of-range or non-finite values for any of these fall back to the default (or
|
|
105
|
+
clamp to the bound) with a one-time named `warn` through the debug callback.
|
|
106
|
+
|
|
107
|
+
Reading `duplicate_requests` correctly:
|
|
108
|
+
|
|
109
|
+
- **Denominator.** Coverage is a per-endpoint subset of `calls` (embeddings,
|
|
110
|
+
legacy text completions, bare `track_ai_call` without messages, and other
|
|
111
|
+
non-chat surfaces emit no duplicate signal), so compute repeat rates as
|
|
112
|
+
`repeat_seen / (first_seen + repeat_seen)` — never against `calls`.
|
|
113
|
+
- **Retries count as repeats.** Errored calls insert into the filter, so a
|
|
114
|
+
retried-after-failure request reads `repeat_seen`. That keeps retry storms
|
|
115
|
+
visible, but it also means `repeat_seen` includes required retry traffic that
|
|
116
|
+
a response cache would not eliminate — net it against
|
|
117
|
+
`frugal.gen_ai.errors` when estimating cacheable waste.
|
|
118
|
+
- **Sampling params are ignored.** The signature deliberately excludes
|
|
119
|
+
`temperature`, `top_p`, `seed`, `n`, and `tool_choice`, so best-of-N /
|
|
120
|
+
self-consistency workloads read as 100% duplicates. Fine for spotting
|
|
121
|
+
caching opportunities; wrong for assuming every repeat is waste.
|
|
122
|
+
- **Why a separate counter, not a label on `calls`:** the metric has its own
|
|
123
|
+
kill switch, covers only a subset of calls, and its 2-value state would
|
|
124
|
+
otherwise double every `calls` series; keeping it separate keeps `calls`
|
|
125
|
+
stable when detection is toggled.
|
|
126
|
+
|
|
127
|
+
## Attributes
|
|
128
|
+
|
|
129
|
+
### From the GenAI semantic convention
|
|
130
|
+
|
|
131
|
+
| Attribute | Example |
|
|
132
|
+
| -------------------------- | ------------------------------------------ |
|
|
133
|
+
| `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) |
|
|
134
|
+
| `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) |
|
|
135
|
+
| `gen_ai.operation.name` | `chat` \| `text_completion` \| `embeddings` \| `generate_content` \| `execute_tool` \| `create_agent` \| `invoke_agent` \| `other` (sentinel for an unrecognized operation) |
|
|
136
|
+
| `gen_ai.request.model` | `gpt-4o` / `claude-3-5-sonnet-latest` / … |
|
|
137
|
+
| `gen_ai.response.model` | Provider-returned model id (often identical)|
|
|
138
|
+
| `gen_ai.token.type` | `input` \| `output` (token usage metric only) |
|
|
139
|
+
|
|
140
|
+
> **Provider attribute migration.** OTel renamed `gen_ai.system` to
|
|
141
|
+
> `gen_ai.provider.name` (some spellings changed, e.g. `vertex_ai` →
|
|
142
|
+
> `gcp.vertex_ai`). The SDK **dual-emits both** during the transition: every
|
|
143
|
+
> metric carries the legacy `gen_ai.system` value (so existing queries/history
|
|
144
|
+
> keep matching) and the new `gen_ai.provider.name` value. The `system` kwarg
|
|
145
|
+
> accepts either spelling. A value outside the well-known set (e.g. a typo like
|
|
146
|
+
> `amazon-bedrock`) is **coerced to `custom`** on both labels — so a wrong label
|
|
147
|
+
> never reaches metrics — and surfaced once through the debug callback
|
|
148
|
+
> (`set_debug_callback`), per distinct value (bounded so a high-cardinality
|
|
149
|
+
> caller can't grow the warning set without limit). To keep a legitimate provider
|
|
150
|
+
> that isn't in the registry, register it via `FRUGAL_CUSTOM_PROVIDERS` (up to 10)
|
|
151
|
+
> and it passes through verbatim on both labels. An `operation` outside the accepted set is coerced to
|
|
152
|
+
> the `other` sentinel (NOT `chat`, so a typo can't inflate the real chat series) with the same
|
|
153
|
+
> one-time warning — the seven listed values plus an explicit `other` pass through unchanged. The
|
|
154
|
+
> exported `GenAiSystem` / `GenAiOperation` `Literal` aliases give strict typing for callers who want
|
|
155
|
+
> it — `GenAiSystem` lists both provider spellings the runtime accepts (the kwargs stay `str` so a
|
|
156
|
+
> free-form custom value remains legal).
|
|
157
|
+
|
|
158
|
+
### Frugal-added
|
|
159
|
+
|
|
160
|
+
| Attribute | Value |
|
|
161
|
+
| ------------------------- | ---------------------------------------------------------- |
|
|
162
|
+
| `frugal.customer_id` | From `FRUGAL_CUSTOMER_ID` |
|
|
163
|
+
| `frugal.project_id` | From `FRUGAL_PROJECT_ID` |
|
|
164
|
+
| `frugal.component` | From each entry in `FRUGAL_COMPONENTS` — present only on the per-component series, absent on the base project series |
|
|
165
|
+
| `frugal.caller_file` | Source file repo-relative to `FRUGAL_REPO_ROOT` |
|
|
166
|
+
| `frugal.caller_function` | Function/method name at the call site |
|
|
167
|
+
| `frugal.error.type` | Exception class name (error-path metrics only) |
|
|
168
|
+
| `frugal.batched` | `true` when the metric came from a provider batch-API path, `false` otherwise. Present on every metric. Batch submissions (OpenAI `batches.create`, Anthropic `messages.batches.create`) emit `frugal.gen_ai.calls` with `batched="true"` — one point per request for Anthropic (the requests array is in hand, grouped by model), one per submission for OpenAI (per-request counts are not reported at submission time, so OpenAI batched call volume is a floor, never an overcount). Iterating Anthropic `messages.batches.results()` additionally emits per-entry `gen_ai.client.token.usage` (+ cache metrics) with `batched="true"`, attributed to the call site that submitted the batch when the submission happened in the same process (bounded in-process memory; falls back to the results call site). OpenAI batch token usage lives in the output file read through the generic files API and is not intercepted. The manual track API accepts a `batched` flag for hand-instrumented batch paths. An app on an SDK predating batch support reads `false` on genuinely batched traffic — the label reflects what the deployed SDK can see. |
|
|
169
|
+
| `frugal.structured_output_mode` | `none` \| `json_object` \| `json_schema` \| `tools`. Derived from request params by precedence: non-empty `tools` → `tools`; else `response_format.type` `json_object` / `json_schema` → the matching value; else `none`. On `frugal.gen_ai.output_cap_utilization` (when `max_tokens` set), and on `frugal.gen_ai.calls` for every call with a request payload (omitted when there is no payload). |
|
|
170
|
+
| `frugal.block` | `system` \| `user` \| `tools` \| `schema`. Only on `frugal.gen_ai.cache_prefix_stable`. |
|
|
171
|
+
| `frugal.stable` | `true` when the block hash matches the previous call at this site. Only on `frugal.gen_ai.cache_prefix_stable`. |
|
|
172
|
+
| `frugal.history_bucket` | Bounded bucket of `messages.length`: `0` \| `1-2` \| `3-5` \| `6-10` \| `11-20` \| `21-50` \| `51+`. On `frugal.gen_ai.calls` and `gen_ai.client.token.usage`, and only for chat calls (payload has a `messages` array); omitted otherwise. 100% (not analyzer-sampled). |
|
|
173
|
+
| `frugal.custom_label` | Custom label (e.g. a business unit) passed as `custom_label=` to `track_ai_call` / `start_ai_call` — never set by the provider wrappers. When passed, it rides EVERY metric of that call (success and error paths alike). The value is trimmed of surrounding whitespace (matching the Node SDK), then must match one of the first 20 entries of `FRUGAL_CUSTOM_LABEL` (hard cap, not configurable) or it is coerced to `other`. Omitted when not passed. |
|
|
174
|
+
| `frugal.dup_state` | `first_seen` \| `repeat_seen` — whether this request's canonical signature was already seen within the duplicate-detection window. Only on `frugal.gen_ai.duplicate_requests`. |
|
|
175
|
+
|
|
176
|
+
### Sentinel values for `frugal.caller_file` / `frugal.caller_function`
|
|
177
|
+
|
|
178
|
+
| Value | Meaning |
|
|
179
|
+
| -------------- | ---------------------------------------------------------- |
|
|
180
|
+
| `<external>` | Caller frame resolved outside `FRUGAL_REPO_ROOT` |
|
|
181
|
+
| `<unknown>` | Could not resolve a caller frame (should not happen in practice) |
|
|
182
|
+
| `<lambda>` | Call originated from a `lambda` expression. This is Python's native `co_name` for lambdas. Only appears in `frugal.caller_function`. |
|
|
183
|
+
| `<overflow>` | Cardinality cap (`FRUGAL_MAX_CALL_SITES`) exceeded. See below. |
|
|
184
|
+
|
|
185
|
+
### Cardinality cap behavior
|
|
186
|
+
|
|
187
|
+
Once `FRUGAL_MAX_CALL_SITES` distinct `(file, function)` pairs have been
|
|
188
|
+
observed, new call sites overflow. Two shapes are possible:
|
|
189
|
+
|
|
190
|
+
| Attribution | Meaning |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `(real/file.py, <overflow>)` | The file was already seen in the first N pairs; the new function inside that file is bucketed into `<overflow>` |
|
|
193
|
+
| `(<overflow>, <overflow>)` | Neither the file nor the function was seen before the cap was reached |
|
|
194
|
+
|
|
195
|
+
The first time the cap is hit per process, the library emits a single
|
|
196
|
+
`frugal.gen_ai.internal_errors{stage=cardinality_cap_hit}` point so you can
|
|
197
|
+
see the transition in your dashboard.
|
|
198
|
+
|
|
199
|
+
### Internal-error stages
|
|
200
|
+
|
|
201
|
+
`frugal.gen_ai.internal_errors` carries a `frugal.internal_error.stage`
|
|
202
|
+
attribute describing where our own code failed. Non-exhaustive list:
|
|
203
|
+
|
|
204
|
+
| Stage | When it fires |
|
|
205
|
+
| ----------------------- | ---------------------------------------------------------------------- |
|
|
206
|
+
| `setup_failed` | Our wrapper setup (OTel init, caller resolution) threw; original call continued normally |
|
|
207
|
+
| `stream_proxy_failed` | Constructing the stream wrapper threw; original response returned unwrapped |
|
|
208
|
+
| `emission` | Emitting a success/error metric threw |
|
|
209
|
+
| `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
|
+
| `cardinality_cap_hit` | `FRUGAL_MAX_CALL_SITES` was just reached (once per process) |
|
|
211
|
+
|
|
212
|
+
Unlike every other metric, `frugal.gen_ai.internal_errors` does **not** carry
|
|
213
|
+
the full base attribute set — it is emitted from failure paths where the
|
|
214
|
+
caller/provider/model attributes may not exist yet. Its points carry only
|
|
215
|
+
`frugal.project_id`, `frugal.internal_error.stage` (plus
|
|
216
|
+
`frugal.internal_error.type` where noted), and — when set —
|
|
217
|
+
`frugal.customer_id` and `frugal.custom_label`.
|
|
218
|
+
|
|
219
|
+
## Provider coverage
|
|
220
|
+
|
|
221
|
+
| Provider | Wrapper | `gen_ai.system` |
|
|
222
|
+
| ------------------------------------------------------- | ------------------------------------ | ---------------- |
|
|
223
|
+
| OpenAI | `wrap_openai` | `openai` |
|
|
224
|
+
| Anthropic (direct API) | `wrap_anthropic` | `anthropic` |
|
|
225
|
+
| Claude on AWS Bedrock (via Anthropic SDK) | `wrap_anthropic(AnthropicBedrock())` | `aws.bedrock` |
|
|
226
|
+
| AWS Bedrock — any model, AWS SDKs directly | `wrap_bedrock` | `aws.bedrock` |
|
|
227
|
+
| Claude on Google Vertex | `wrap_anthropic(AnthropicVertex())` | `vertex_ai` |
|
|
228
|
+
| Custom / manual call sites | `track_ai_call` / `start_ai_call` | `custom` (override via `system=`) |
|
|
229
|
+
|
|
230
|
+
`wrap_bedrock` covers all four `bedrock-runtime` operations (`InvokeModel`,
|
|
231
|
+
`InvokeModelWithResponseStream`, `Converse`, `ConverseStream`) on every model
|
|
232
|
+
hosted on Bedrock — Anthropic Claude, OpenAI gpt-oss, Amazon Nova, Meta Llama,
|
|
233
|
+
Mistral, Cohere, AI21 Jamba, DeepSeek, Qwen, Kimi, Twelvelabs, Nvidia, etc.
|
|
234
|
+
Per-provider response parsing handles the major families (Anthropic /
|
|
235
|
+
OpenAI / Nova / Llama / Mistral) for accurate cache + reasoning token
|
|
236
|
+
extraction; everything else falls back to the universal
|
|
237
|
+
`amazon-bedrock-invocationMetrics` (streaming) /
|
|
238
|
+
`x-amzn-bedrock-*-token-count` (non-streaming) headers that Bedrock attaches
|
|
239
|
+
to every response.
|
|
240
|
+
|
|
241
|
+
> **Reasoning tokens on Bedrock.** Anthropic's `InvokeModel` path exposes
|
|
242
|
+
> `usage.thinking_tokens` for extended-thinking calls; we surface it as
|
|
243
|
+
> `frugal.gen_ai.reasoning_tokens`. Bedrock's `Converse` API normalizes
|
|
244
|
+
> reasoning tokens into `outputTokens` rather than breaking them out, so
|
|
245
|
+
> calls made via `Converse` lose reasoning-token visibility.
|
|
246
|
+
|
|
247
|
+
Azure OpenAI clients currently fall through the OpenAI wrapper and are tagged
|
|
248
|
+
as `openai`; dedicated Azure detection is deferred.
|
|
249
|
+
|
|
250
|
+
Native Vertex SDKs (`google-cloud-aiplatform`) for non-Claude models are not
|
|
251
|
+
yet instrumented.
|
|
@@ -26,10 +26,16 @@ Optional:
|
|
|
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
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. |
|
|
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`. |
|
|
29
30
|
| `FRUGAL_PATH_BLOCK_LIST` | Comma-separated repo-relative paths to skip when attributing the caller (see below). |
|
|
30
31
|
| `FRUGAL_METRIC_EXPORT_INTERVAL_MS` | Export cadence. Default `60000`. |
|
|
31
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 cap](#cardinality-cap). |
|
|
32
|
-
| `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE` | Fraction of calls (0..1) where the lightweight prompt analyzer runs and emits derived metrics (`
|
|
33
|
+
| `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
|
+
| `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
|
+
| `FRUGAL_DUPLICATE_WINDOW_SECONDS` | How long a request signature is remembered for duplicate detection. Default `3600`, max `604800` (7 days). |
|
|
36
|
+
| `FRUGAL_DUPLICATE_CAPACITY` | Expected distinct request signatures per window — sizes the in-memory duplicate filter. Default `10000`, max `1000000`. Raise it if the debug callback warns the filter is saturated. If the filter's memory can't be allocated at all, detection disables itself for the rest of the process (one-time `error` diagnostic + one `internal_errors` point). |
|
|
37
|
+
| `FRUGAL_DUPLICATE_FP_RATE` | Target false-positive rate of the duplicate filter. Default `0.01`, clamped to `[1e-6, 0.5]`. |
|
|
38
|
+
| `FRUGAL_DUPLICATE_GENERATIONS` | Rotation granularity of the duplicate filter's time window. Default `2`, max `16`. |
|
|
33
39
|
| `FRUGAL_DISABLED` | Kill switch. Set to `1` to disable without uninstalling. |
|
|
34
40
|
|
|
35
41
|
Standard `OTEL_EXPORTER_OTLP_*` env vars are honored as fallbacks if the
|
|
@@ -92,6 +98,13 @@ token counts are not (the OpenAI SDK doesn't send them).
|
|
|
92
98
|
Use `wrap_anthropic` with the Anthropic SDK's `AnthropicBedrock` client. The
|
|
93
99
|
wrapper detects it and tags metrics with `gen_ai.system = aws.bedrock`.
|
|
94
100
|
|
|
101
|
+
> Every metric is **dual-emitted** under both `gen_ai.system` (legacy) and
|
|
102
|
+
> `gen_ai.provider.name` (current OTel spelling — e.g. `aws.bedrock`,
|
|
103
|
+
> `gcp.vertex_ai`). A provider or operation outside the known set is coerced to
|
|
104
|
+
> the `custom` / `other` sentinel with a one-time warning (via
|
|
105
|
+
> `set_debug_callback`); register extra providers with `FRUGAL_CUSTOM_PROVIDERS`.
|
|
106
|
+
> See [metrics-reference.md](metrics-reference.md#attributes).
|
|
107
|
+
|
|
95
108
|
```python
|
|
96
109
|
from anthropic import AnthropicBedrock
|
|
97
110
|
from frugal_metrics import wrap_anthropic
|
|
@@ -247,7 +260,9 @@ extra calls are ignored.
|
|
|
247
260
|
|
|
248
261
|
**Metadata** — `track_ai_call` / `start_ai_call` kwargs (all optional):
|
|
249
262
|
`system` (→ `gen_ai.system`, default `"custom"`), `operation` (default
|
|
250
|
-
`"chat"`), `request_model` (default `"<unknown>"`)
|
|
263
|
+
`"chat"`), `request_model` (default `"<unknown>"`), `custom_label` (→ the
|
|
264
|
+
`frugal.custom_label` label on every metric of the call; values outside the
|
|
265
|
+
`FRUGAL_CUSTOM_LABEL` allowlist become `other`). To also run the sampled
|
|
251
266
|
prompt analyzer, pass any of `messages` / `system_prompt` / `tools` /
|
|
252
267
|
`response_format` / `max_tokens`.
|
|
253
268
|
|
|
@@ -309,17 +324,28 @@ detect common AI cost traps:
|
|
|
309
324
|
|
|
310
325
|
| Signal | Metric | Cost trap |
|
|
311
326
|
| --- | --- | --- |
|
|
312
|
-
| Conversation depth | `frugal.gen_ai.history_depth` | unbounded history re-sent on every turn |
|
|
313
327
|
| Few-shot example count | `frugal.gen_ai.few_shot_count` | over-provisioned exemplars where accuracy plateaus |
|
|
328
|
+
| Few-shot example size | `frugal.gen_ai.few_shot_tokens` | oversized exemplar blocks re-sent on every call |
|
|
314
329
|
| Input noise (HTML tags, base64 runs, large whitespace) | `frugal.gen_ai.noise_ratio` | filterable boilerplate paying token cost |
|
|
315
330
|
| Per-block cache stability | `frugal.gen_ai.cache_prefix_stable` | system / tools / schema churn breaking provider prompt cache |
|
|
316
331
|
| Structured-output keyword in prose | `frugal.gen_ai.structured_output_hint` | "return JSON" prompts that should use `response_format` / `tools` |
|
|
317
332
|
|
|
333
|
+
Two related signals run on **every** chat call (100%, independent of the
|
|
334
|
+
analyzer sample rate): `frugal.gen_ai.history_depth` (the `messages.length`
|
|
335
|
+
count — unbounded history re-sent on every turn) and
|
|
336
|
+
`frugal.gen_ai.duplicate_requests` (repeat-request detection; disable via
|
|
337
|
+
`FRUGAL_DUPLICATE_REQUEST_DETECTION`).
|
|
338
|
+
|
|
318
339
|
> **What we read vs what we send.** The analyzer inspects the request's
|
|
319
340
|
> `messages`, `tools`, `response_format`, `system`, and `max_tokens` kwargs to
|
|
320
|
-
> compute the signals above.
|
|
321
|
-
>
|
|
322
|
-
>
|
|
341
|
+
> compute the signals above. Duplicate detection additionally canonicalizes and
|
|
342
|
+
> SHA-256 hashes those same fields on every chat call (capped at 256 KiB per
|
|
343
|
+
> request; binary/image content — raw bytes and the base64 strings providers
|
|
344
|
+
> actually send — contributes only its type and length). We never send raw
|
|
345
|
+
> request content over the wire — only stats (counts, ratios); the hashes are
|
|
346
|
+
> used in-process for the stability comparisons and the duplicate filter, and
|
|
347
|
+
> never leave the process. For `duplicate_requests`, only the two-value
|
|
348
|
+
> `first_seen`/`repeat_seen` state is exported.
|
|
323
349
|
|
|
324
350
|
Sampling defaults to **10% per call site** and is deterministic by call site +
|
|
325
351
|
minute, so a given call site is either fully sampled or fully skipped within a
|
|
@@ -328,7 +354,8 @@ paying analyzer CPU cost on every call. Tune via
|
|
|
328
354
|
`FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`.
|
|
329
355
|
|
|
330
356
|
The token-side metrics (`gen_ai.client.token.usage`, `frugal.gen_ai.calls`,
|
|
331
|
-
`frugal.gen_ai.cache_*`, `frugal.gen_ai.output_cap_utilization`)
|
|
357
|
+
`frugal.gen_ai.cache_*`, `frugal.gen_ai.output_cap_utilization`) plus
|
|
358
|
+
`frugal.gen_ai.history_depth` and `frugal.gen_ai.duplicate_requests` are 100% —
|
|
332
359
|
never sampled.
|
|
333
360
|
|
|
334
361
|
## See why nothing is exporting (debug callback)
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
from importlib.metadata import PackageNotFoundError, version
|
|
4
4
|
|
|
5
5
|
from frugal_metrics.debug import set_debug_callback
|
|
6
|
+
from frugal_metrics.semconv import DupState, HistoryBucket, StructuredOutputMode
|
|
7
|
+
from frugal_metrics.providers import (
|
|
8
|
+
PROVIDERS,
|
|
9
|
+
GenAiOperation,
|
|
10
|
+
GenAiSystem,
|
|
11
|
+
ProviderEntry,
|
|
12
|
+
normalize_operation,
|
|
13
|
+
normalize_provider,
|
|
14
|
+
)
|
|
6
15
|
from frugal_metrics.shutdown import enable_auto_shutdown, flush, shutdown
|
|
7
16
|
from frugal_metrics.track import AICallSpan, start_ai_call, track_ai_call
|
|
8
17
|
from frugal_metrics.wrappers.openai import wrap_openai
|
|
@@ -20,6 +29,15 @@ __all__ = [
|
|
|
20
29
|
"flush",
|
|
21
30
|
"shutdown",
|
|
22
31
|
"enable_auto_shutdown",
|
|
32
|
+
"PROVIDERS",
|
|
33
|
+
"ProviderEntry",
|
|
34
|
+
"GenAiSystem",
|
|
35
|
+
"GenAiOperation",
|
|
36
|
+
"HistoryBucket",
|
|
37
|
+
"StructuredOutputMode",
|
|
38
|
+
"DupState",
|
|
39
|
+
"normalize_provider",
|
|
40
|
+
"normalize_operation",
|
|
23
41
|
]
|
|
24
42
|
try:
|
|
25
43
|
__version__ = version("frugal-sdk-python")
|