frugal-sdk-python 0.6.0__tar.gz → 0.7.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.6.0 → frugal_sdk_python-0.7.0}/PKG-INFO +11 -3
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/README.md +10 -2
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/docs/metrics-reference.md +13 -2
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/docs/quickstart.md +8 -3
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/mockllm/package.json +1 -1
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/config.py +4 -2
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/otel.py +61 -12
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/semconv.py +4 -0
- frugal_sdk_python-0.7.0/tests/test_otel_temporality.py +171 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/.github/workflows/cd.yml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/.gitignore +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/LICENSES/Apache-2.0.txt +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/REUSE.toml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/README.md +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/docker-compose.yml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/mockllm/Dockerfile +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/mockllm/src/server.js +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/models.html +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/prometheus.yml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/Dockerfile +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/clients.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/via_blocked_lib.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/via_unblocked_lib.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/internal_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/main.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/sibling_lib/ai_helper.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/pyproject.toml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/requirements.txt +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/scripts/verify.sh +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/pyproject.toml +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/block_hash_lru.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/caller.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/debug.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/duplicate_filter.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/instrument.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/model_cap.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/providers.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/safe.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/shutdown.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/streams.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/track.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/_batch.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/_patching.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/anthropic.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/openai.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/__init__.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/conftest.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_bedrock_models.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_block_hash_lru.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_caller.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_caller_edge_cases.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_config.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_debug.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_duplicate_filter.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_model_cap.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_never_impact_host.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_node_parity.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_otel_overflow_detection.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_prompt_analyzer.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_providers.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_shutdown.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_sibling_parity.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_track_ai_call.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_anthropic.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_anthropic_batches.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_bedrock.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_openai.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_openai_analyzer.py +0 -0
- {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.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.7.0
|
|
4
4
|
Summary: Instrumentation for Cost-to-Code attribution.
|
|
5
5
|
Project-URL: Homepage, https://frugal.co
|
|
6
6
|
Author: Frugal AI Inc.
|
|
@@ -71,7 +71,13 @@ Console mode (`FRUGAL_METRICS_EXPORTER=console`) needs only `FRUGAL_PROJECT_ID`;
|
|
|
71
71
|
|
|
72
72
|
### Optional
|
|
73
73
|
|
|
74
|
-
The export endpoint and authentication come only from `FRUGAL_`-prefixed variables, so the SDK never crosses wires with the host's own OTel pipeline: the generic `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` are never read. (The OTel exporter library still reads its own generic TLS, compression
|
|
74
|
+
The export endpoint and authentication come only from `FRUGAL_`-prefixed variables, so the SDK never crosses wires with the host's own OTel pipeline: the generic `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` are never read. (The OTel exporter library still reads its own generic TLS, compression and timeout variables.)
|
|
75
|
+
|
|
76
|
+
Export temporality is pinned by the SDK: Counter, Histogram and ObservableCounter instruments are exported as OTLP **delta**, everything else as cumulative. `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` is ignored, and every export carries the resource attribute `frugal.temporality="delta"`.
|
|
77
|
+
|
|
78
|
+
Delta delivery is best-effort: the exporter retries transient failures (throttling, gateway errors, network errors). An interval whose export still fails after those retries (for example a long outage), or fails with a non-retryable error such as a rejected token, is dropped, not carried into the next export. A retry after a lost response can occasionally deliver an interval twice. Each run of failed exports surfaces once through the `error` debug callback.
|
|
79
|
+
|
|
80
|
+
**Querying:** use `sum_over_time` / `rate_over_sum`, not `increase` / `rate`, and select `frugal.temporality="delta"`.
|
|
75
81
|
|
|
76
82
|
| Variable | Purpose |
|
|
77
83
|
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
@@ -328,6 +334,8 @@ enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
|
|
|
328
334
|
|
|
329
335
|
`enable_auto_shutdown` always registers an `atexit` hook and, when `handle_signals=True` (the default), installs SIGTERM/SIGINT handlers that flush+shutdown and then **chain to whatever handler was previously installed** (so your own handler still runs). Off the main thread, signal registration is unavailable and it falls back to atexit-only. It is **idempotent** — repeat calls register nothing extra.
|
|
330
336
|
|
|
337
|
+
Exports are delta: each carries only its own interval, so a skipped final flush loses at most the last interval.
|
|
338
|
+
|
|
331
339
|
It is **opt-in** because installing signal handlers is intrusive. If you run your own signal handling, do **not** call `enable_auto_shutdown(handle_signals=True)`; prefer calling `shutdown()` from inside your own handler instead.
|
|
332
340
|
|
|
333
341
|
## Verifying the setup
|
|
@@ -355,4 +363,4 @@ export FRUGAL_METRIC_EXPORT_INTERVAL_MS=5000
|
|
|
355
363
|
python my_script.py
|
|
356
364
|
```
|
|
357
365
|
|
|
358
|
-
The collector should receive metric records with `gen_ai.*` and `frugal.*` attributes
|
|
366
|
+
The collector should receive metric records with `gen_ai.*` and `frugal.*` attributes on each 5-second export that follows at least one wrapped call. Counters and histograms are exported as delta, so an idle interval carries no counter or histogram points: make a call, then watch the next export.
|
|
@@ -42,7 +42,13 @@ Console mode (`FRUGAL_METRICS_EXPORTER=console`) needs only `FRUGAL_PROJECT_ID`;
|
|
|
42
42
|
|
|
43
43
|
### Optional
|
|
44
44
|
|
|
45
|
-
The export endpoint and authentication come only from `FRUGAL_`-prefixed variables, so the SDK never crosses wires with the host's own OTel pipeline: the generic `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` are never read. (The OTel exporter library still reads its own generic TLS, compression
|
|
45
|
+
The export endpoint and authentication come only from `FRUGAL_`-prefixed variables, so the SDK never crosses wires with the host's own OTel pipeline: the generic `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` are never read. (The OTel exporter library still reads its own generic TLS, compression and timeout variables.)
|
|
46
|
+
|
|
47
|
+
Export temporality is pinned by the SDK: Counter, Histogram and ObservableCounter instruments are exported as OTLP **delta**, everything else as cumulative. `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` is ignored, and every export carries the resource attribute `frugal.temporality="delta"`.
|
|
48
|
+
|
|
49
|
+
Delta delivery is best-effort: the exporter retries transient failures (throttling, gateway errors, network errors). An interval whose export still fails after those retries (for example a long outage), or fails with a non-retryable error such as a rejected token, is dropped, not carried into the next export. A retry after a lost response can occasionally deliver an interval twice. Each run of failed exports surfaces once through the `error` debug callback.
|
|
50
|
+
|
|
51
|
+
**Querying:** use `sum_over_time` / `rate_over_sum`, not `increase` / `rate`, and select `frugal.temporality="delta"`.
|
|
46
52
|
|
|
47
53
|
| Variable | Purpose |
|
|
48
54
|
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
@@ -299,6 +305,8 @@ enable_auto_shutdown() # flush + shutdown via atexit, plus SIGTERM / SIGINT
|
|
|
299
305
|
|
|
300
306
|
`enable_auto_shutdown` always registers an `atexit` hook and, when `handle_signals=True` (the default), installs SIGTERM/SIGINT handlers that flush+shutdown and then **chain to whatever handler was previously installed** (so your own handler still runs). Off the main thread, signal registration is unavailable and it falls back to atexit-only. It is **idempotent** — repeat calls register nothing extra.
|
|
301
307
|
|
|
308
|
+
Exports are delta: each carries only its own interval, so a skipped final flush loses at most the last interval.
|
|
309
|
+
|
|
302
310
|
It is **opt-in** because installing signal handlers is intrusive. If you run your own signal handling, do **not** call `enable_auto_shutdown(handle_signals=True)`; prefer calling `shutdown()` from inside your own handler instead.
|
|
303
311
|
|
|
304
312
|
## Verifying the setup
|
|
@@ -326,4 +334,4 @@ export FRUGAL_METRIC_EXPORT_INTERVAL_MS=5000
|
|
|
326
334
|
python my_script.py
|
|
327
335
|
```
|
|
328
336
|
|
|
329
|
-
The collector should receive metric records with `gen_ai.*` and `frugal.*` attributes
|
|
337
|
+
The collector should receive metric records with `gen_ai.*` and `frugal.*` attributes on each 5-second export that follows at least one wrapped call. Counters and histograms are exported as delta, so an idle interval carries no counter or histogram points: make a call, then watch the next export.
|
|
@@ -61,9 +61,9 @@ combining both would double-count tokens.
|
|
|
61
61
|
To derive average turns per reporting agent invocation in MetricsQL, use:
|
|
62
62
|
|
|
63
63
|
```promql
|
|
64
|
-
|
|
64
|
+
sum_over_time(frugal.gen_ai.agent.turns_sum{frugal.temporality="delta"}[...])
|
|
65
65
|
/
|
|
66
|
-
|
|
66
|
+
sum_over_time(frugal.gen_ai.agent.turns_count{frugal.temporality="delta"}[...])
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
The analyzer-sampled metrics are gated by `FRUGAL_PROMPT_ANALYSIS_SAMPLE_RATE`
|
|
@@ -165,6 +165,16 @@ Reading `duplicate_requests` correctly:
|
|
|
165
165
|
otherwise double every `calls` series; keeping it separate keeps `calls`
|
|
166
166
|
stable when detection is toggled.
|
|
167
167
|
|
|
168
|
+
## Temporality
|
|
169
|
+
|
|
170
|
+
Export temporality is pinned by the SDK: Counter, Histogram and ObservableCounter instruments are exported as OTLP **delta**, everything else as cumulative. `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` is ignored, and every export carries the resource attribute `frugal.temporality="delta"`.
|
|
171
|
+
|
|
172
|
+
Delta delivery is best-effort: the exporter retries transient failures (throttling, gateway errors, network errors). An interval whose export still fails after those retries (for example a long outage), or fails with a non-retryable error such as a rejected token, is dropped, not carried into the next export. A retry after a lost response can occasionally deliver an interval twice. Each run of failed exports surfaces once through the `error` debug callback.
|
|
173
|
+
|
|
174
|
+
**Querying:** use `sum_over_time` / `rate_over_sum`, not `increase` / `rate`, and select `frugal.temporality="delta"`.
|
|
175
|
+
|
|
176
|
+
Each export carries only its own interval, so a process that exits without `shutdown()` (or `enable_auto_shutdown()`) loses at most its last interval.
|
|
177
|
+
|
|
168
178
|
## Attributes
|
|
169
179
|
|
|
170
180
|
### From the GenAI semantic convention
|
|
@@ -202,6 +212,7 @@ Reading `duplicate_requests` correctly:
|
|
|
202
212
|
| ------------------------- | ---------------------------------------------------------- |
|
|
203
213
|
| `frugal.customer_id` | From `FRUGAL_CUSTOMER_ID` — present only when set |
|
|
204
214
|
| `frugal.project_id` | From `FRUGAL_METRICS_PROJECT_ID`, else `FRUGAL_PROJECT_ID` |
|
|
215
|
+
| `frugal.temporality` | Constant resource attribute `delta`, stamped on every export. Select it in queries to skip older cumulative samples that share the same series. |
|
|
205
216
|
| `frugal.component` | From each entry in `FRUGAL_COMPONENTS` — present only on the per-component series, absent on the base project series |
|
|
206
217
|
| `frugal.caller_file` | Source file repo-relative to `FRUGAL_REPO_ROOT` |
|
|
207
218
|
| `frugal.caller_function` | Function/method name at the call site |
|
|
@@ -21,7 +21,13 @@ Optional — the export endpoint and authentication come only from
|
|
|
21
21
|
`FRUGAL_`-prefixed variables, so the SDK never crosses wires with the host's
|
|
22
22
|
own OTel pipeline: the generic `OTEL_EXPORTER_OTLP_ENDPOINT` and
|
|
23
23
|
`OTEL_EXPORTER_OTLP_HEADERS` are never read. (The OTel exporter library still
|
|
24
|
-
reads its own generic TLS, compression
|
|
24
|
+
reads its own generic TLS, compression and timeout variables.)
|
|
25
|
+
|
|
26
|
+
Export temporality is pinned by the SDK: Counter, Histogram and ObservableCounter instruments are exported as OTLP **delta**, everything else as cumulative. `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` is ignored, and every export carries the resource attribute `frugal.temporality="delta"`.
|
|
27
|
+
|
|
28
|
+
Delta delivery is best-effort: the exporter retries transient failures (throttling, gateway errors, network errors). An interval whose export still fails after those retries (for example a long outage), or fails with a non-retryable error such as a rejected token, is dropped, not carried into the next export. A retry after a lost response can occasionally deliver an interval twice. Each run of failed exports surfaces once through the `error` debug callback.
|
|
29
|
+
|
|
30
|
+
**Querying:** use `sum_over_time` / `rate_over_sum`, not `increase` / `rate`, and select `frugal.temporality="delta"`.
|
|
25
31
|
|
|
26
32
|
| Var | Purpose |
|
|
27
33
|
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
@@ -541,5 +547,4 @@ export FRUGAL_METRIC_EXPORT_INTERVAL_MS=5000
|
|
|
541
547
|
python my_script.py
|
|
542
548
|
```
|
|
543
549
|
|
|
544
|
-
|
|
545
|
-
`gen_ai.*` and `frugal.*` attributes.
|
|
550
|
+
The collector should receive metric records with `gen_ai.*` and `frugal.*` attributes on each 5-second export that follows at least one wrapped call. Counters and histograms are exported as delta, so an idle interval carries no counter or histogram points: make a call, then watch the next export.
|
|
@@ -33,8 +33,10 @@ variables, so the SDK never crosses wires with the host's own OTel pipeline:
|
|
|
33
33
|
else "Authorization: Bearer <key>" built verbatim from the API key.
|
|
34
34
|
The generic OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS are never
|
|
35
35
|
read; a deployment that relied on them must set the FRUGAL_ names instead. The
|
|
36
|
-
OTel exporter library still reads its own generic TLS, compression
|
|
37
|
-
and
|
|
36
|
+
OTel exporter library still reads its own generic TLS, compression and timeout
|
|
37
|
+
variables. Temporality is pinned in code (delta for counters and histograms,
|
|
38
|
+
cumulative for the rest) and ignores
|
|
39
|
+
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE.
|
|
38
40
|
"""
|
|
39
41
|
|
|
40
42
|
from __future__ import annotations
|
|
@@ -17,8 +17,17 @@ from typing import TYPE_CHECKING, Any
|
|
|
17
17
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
|
18
18
|
OTLPMetricExporter,
|
|
19
19
|
)
|
|
20
|
-
from opentelemetry.sdk.metrics import
|
|
20
|
+
from opentelemetry.sdk.metrics import (
|
|
21
|
+
Counter,
|
|
22
|
+
Histogram,
|
|
23
|
+
MeterProvider,
|
|
24
|
+
ObservableCounter,
|
|
25
|
+
ObservableGauge,
|
|
26
|
+
ObservableUpDownCounter,
|
|
27
|
+
UpDownCounter,
|
|
28
|
+
)
|
|
21
29
|
from opentelemetry.sdk.metrics.export import (
|
|
30
|
+
AggregationTemporality,
|
|
22
31
|
ConsoleMetricExporter,
|
|
23
32
|
MetricExporter,
|
|
24
33
|
MetricExportResult,
|
|
@@ -291,6 +300,33 @@ def _build_instruments(config: "Config") -> Instruments:
|
|
|
291
300
|
raise
|
|
292
301
|
|
|
293
302
|
|
|
303
|
+
# The one place export temporality is decided. Monotonic instruments are DELTA
|
|
304
|
+
# (each export carries only its own interval, so many short-lived processes
|
|
305
|
+
# sharing a label set add up exactly); everything else stays CUMULATIVE. An
|
|
306
|
+
# explicit map covering every instrument class overrides the exporter's
|
|
307
|
+
# env-derived defaults, so OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE
|
|
308
|
+
# has nothing left to change.
|
|
309
|
+
# Delta delivery is best-effort per interval: an export that still fails after
|
|
310
|
+
# the OTLP exporter's own retries drops that interval rather than folding it
|
|
311
|
+
# into the next export, and a retry after a lost response can deliver an
|
|
312
|
+
# interval twice. _HealthCheckingExporter surfaces the failure run but
|
|
313
|
+
# deliberately does not replay batches.
|
|
314
|
+
_FRUGAL_PREFERRED_TEMPORALITY: dict[type, AggregationTemporality] = {
|
|
315
|
+
Counter: AggregationTemporality.DELTA,
|
|
316
|
+
Histogram: AggregationTemporality.DELTA,
|
|
317
|
+
ObservableCounter: AggregationTemporality.DELTA,
|
|
318
|
+
UpDownCounter: AggregationTemporality.CUMULATIVE,
|
|
319
|
+
ObservableUpDownCounter: AggregationTemporality.CUMULATIVE,
|
|
320
|
+
ObservableGauge: AggregationTemporality.CUMULATIVE,
|
|
321
|
+
}
|
|
322
|
+
try:
|
|
323
|
+
from opentelemetry.sdk.metrics import _Gauge # type: ignore[attr-defined]
|
|
324
|
+
|
|
325
|
+
_FRUGAL_PREFERRED_TEMPORALITY[_Gauge] = AggregationTemporality.CUMULATIVE
|
|
326
|
+
except ImportError: # pragma: no cover - SDKs without the synchronous Gauge
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
|
|
294
330
|
def _instruments_for_reader(
|
|
295
331
|
config: "Config", reader: PeriodicExportingMetricReader
|
|
296
332
|
) -> Instruments:
|
|
@@ -306,6 +342,7 @@ def _instruments_for_reader(
|
|
|
306
342
|
resource_attrs: dict[str, str] = {
|
|
307
343
|
"service.name": service_name,
|
|
308
344
|
semconv.FRUGAL_PROJECT_ID: config.project_id,
|
|
345
|
+
semconv.FRUGAL_TEMPORALITY: semconv.TEMPORALITY_DELTA,
|
|
309
346
|
}
|
|
310
347
|
if config.customer_id:
|
|
311
348
|
resource_attrs[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
@@ -460,7 +497,10 @@ def _build_exporter(config: "Config") -> MetricExporter:
|
|
|
460
497
|
identity[semconv.FRUGAL_CUSTOMER_ID] = config.customer_id
|
|
461
498
|
|
|
462
499
|
if config.exporter == "console":
|
|
463
|
-
return wrap_exporter_for_health(
|
|
500
|
+
return wrap_exporter_for_health(
|
|
501
|
+
ConsoleMetricExporter(preferred_temporality=_FRUGAL_PREFERRED_TEMPORALITY),
|
|
502
|
+
identity=identity,
|
|
503
|
+
)
|
|
464
504
|
|
|
465
505
|
if config.otlp_headers_from_api_key and config.otlp_headers:
|
|
466
506
|
# Built from the API key: send verbatim, never split or decode it.
|
|
@@ -472,6 +512,7 @@ def _build_exporter(config: "Config") -> MetricExporter:
|
|
|
472
512
|
OTLPMetricExporter(
|
|
473
513
|
endpoint=config.otlp_endpoint,
|
|
474
514
|
headers=headers or None,
|
|
515
|
+
preferred_temporality=_FRUGAL_PREFERRED_TEMPORALITY,
|
|
475
516
|
),
|
|
476
517
|
identity=identity,
|
|
477
518
|
)
|
|
@@ -509,20 +550,26 @@ class _HealthCheckingExporter(MetricExporter):
|
|
|
509
550
|
"""Wrap an exporter so persistent export failures surface as a single
|
|
510
551
|
de-duped ``error`` debug callback.
|
|
511
552
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
553
|
+
A failed export is not replayed: under delta temporality its interval is
|
|
554
|
+
lost and the periodic reader just moves on to the next tick, so without
|
|
555
|
+
this a wrong endpoint / bad token / down backend is invisible to the
|
|
556
|
+
customer. We emit on the FIRST failure after a success (0->1 edge) and
|
|
557
|
+
re-arm on the next success, so a flapping backend produces one alert per
|
|
558
|
+
failure run, not one per export tick. Export temporality is pinned to
|
|
559
|
+
_FRUGAL_PREFERRED_TEMPORALITY, ignoring the inner exporter's own map; every
|
|
560
|
+
other attribute delegates to the inner exporter.
|
|
517
561
|
"""
|
|
518
562
|
|
|
519
563
|
def __init__(
|
|
520
564
|
self, inner: MetricExporter, identity: dict[str, Any] | None = None
|
|
521
565
|
) -> None:
|
|
522
|
-
# Do NOT call super().__init__ —
|
|
523
|
-
#
|
|
524
|
-
#
|
|
566
|
+
# Do NOT call super().__init__ — every attribute except the pinned
|
|
567
|
+
# _preferred_temporality below (aggregation included) delegates to the
|
|
568
|
+
# inner exporter through __getattr__.
|
|
525
569
|
self._inner = inner
|
|
570
|
+
# Pinned here too so the reader always sees the Frugal map, whatever
|
|
571
|
+
# exporter is wrapped.
|
|
572
|
+
self._preferred_temporality: dict[Any, Any] = _FRUGAL_PREFERRED_TEMPORALITY
|
|
526
573
|
self._consecutive_failures = 0
|
|
527
574
|
# Project/customer attributes for the internal_errors point the overflow
|
|
528
575
|
# detector emits — instrument attrs don't exist on this path.
|
|
@@ -618,8 +665,9 @@ class _HealthCheckingExporter(MetricExporter):
|
|
|
618
665
|
self._inner.shutdown(timeout_millis=timeout_millis, **kwargs)
|
|
619
666
|
|
|
620
667
|
def __getattr__(self, name: str) -> Any:
|
|
621
|
-
# Delegate
|
|
622
|
-
#
|
|
668
|
+
# Delegate preferred_aggregation and any other attribute the SDK reads
|
|
669
|
+
# off the exporter to the inner instance (the temporality map is pinned
|
|
670
|
+
# in __init__).
|
|
623
671
|
return getattr(self._inner, name)
|
|
624
672
|
|
|
625
673
|
|
|
@@ -628,6 +676,7 @@ def wrap_exporter_for_health(
|
|
|
628
676
|
) -> MetricExporter:
|
|
629
677
|
"""Wrap an exporter so persistent export failures emit one de-duped error,
|
|
630
678
|
and so an OTel cardinality-overflow series is reported once per instrument.
|
|
679
|
+
Its export temporality is pinned to the Frugal map.
|
|
631
680
|
"""
|
|
632
681
|
return _HealthCheckingExporter(exporter, identity=identity)
|
|
633
682
|
|
|
@@ -68,6 +68,10 @@ METRIC_AGENT_TURNS = "frugal.gen_ai.agent.turns"
|
|
|
68
68
|
# Frugal-namespaced attributes.
|
|
69
69
|
FRUGAL_CUSTOMER_ID = "frugal.customer_id"
|
|
70
70
|
FRUGAL_PROJECT_ID = "frugal.project_id"
|
|
71
|
+
# Resource attribute, constant "delta": the SDK exports counters and histograms
|
|
72
|
+
# with DELTA temporality, so queries select on it to skip older cumulative data.
|
|
73
|
+
FRUGAL_TEMPORALITY = "frugal.temporality"
|
|
74
|
+
TEMPORALITY_DELTA = "delta"
|
|
71
75
|
FRUGAL_COMPONENT = "frugal.component"
|
|
72
76
|
FRUGAL_CALLER_FILE = "frugal.caller_file"
|
|
73
77
|
FRUGAL_CALLER_FUNCTION = "frugal.caller_function"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Export temporality is pinned in code: DELTA for monotonic instruments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from opentelemetry.sdk.metrics import (
|
|
9
|
+
Counter,
|
|
10
|
+
Histogram,
|
|
11
|
+
MeterProvider,
|
|
12
|
+
ObservableCounter,
|
|
13
|
+
ObservableGauge,
|
|
14
|
+
ObservableUpDownCounter,
|
|
15
|
+
UpDownCounter,
|
|
16
|
+
)
|
|
17
|
+
from opentelemetry.sdk.metrics import _Gauge # type: ignore[attr-defined]
|
|
18
|
+
from opentelemetry.sdk.metrics._internal import instrument
|
|
19
|
+
from opentelemetry.sdk.metrics.export import (
|
|
20
|
+
AggregationTemporality,
|
|
21
|
+
InMemoryMetricReader,
|
|
22
|
+
MetricExporter,
|
|
23
|
+
MetricExportResult,
|
|
24
|
+
PeriodicExportingMetricReader,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from frugal_metrics import config as config_module
|
|
28
|
+
from frugal_metrics import otel as otel_module
|
|
29
|
+
from frugal_metrics import semconv
|
|
30
|
+
|
|
31
|
+
DELTA = AggregationTemporality.DELTA
|
|
32
|
+
CUMULATIVE = AggregationTemporality.CUMULATIVE
|
|
33
|
+
|
|
34
|
+
_EXPECTED = {
|
|
35
|
+
Counter: DELTA,
|
|
36
|
+
Histogram: DELTA,
|
|
37
|
+
ObservableCounter: DELTA,
|
|
38
|
+
UpDownCounter: CUMULATIVE,
|
|
39
|
+
ObservableUpDownCounter: CUMULATIVE,
|
|
40
|
+
ObservableGauge: CUMULATIVE,
|
|
41
|
+
_Gauge: CUMULATIVE,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_ENV = "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load() -> Any:
|
|
48
|
+
cfg = config_module.load_config()
|
|
49
|
+
assert cfg is not None
|
|
50
|
+
return cfg
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_selector_table_covers_every_instrument_kind():
|
|
54
|
+
assert otel_module._FRUGAL_PREFERRED_TEMPORALITY == _EXPECTED
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("mode", ["otlp", "console"])
|
|
58
|
+
@pytest.mark.parametrize("env", [None, "cumulative", "delta", "lowmemory"])
|
|
59
|
+
def test_production_exporter_reports_pinned_temporality(
|
|
60
|
+
required_env, monkeypatch, mode, env
|
|
61
|
+
):
|
|
62
|
+
# env=None covers the default; the others prove the host's generic OTel
|
|
63
|
+
# variable cannot change the map (production-path + env-immunity).
|
|
64
|
+
if env is None:
|
|
65
|
+
monkeypatch.delenv(_ENV, raising=False)
|
|
66
|
+
else:
|
|
67
|
+
monkeypatch.setenv(_ENV, env)
|
|
68
|
+
if mode == "console":
|
|
69
|
+
monkeypatch.setenv("FRUGAL_METRICS_EXPORTER", "console")
|
|
70
|
+
exporter = otel_module._build_exporter(_load())
|
|
71
|
+
for kind, expected in _EXPECTED.items():
|
|
72
|
+
assert exporter._preferred_temporality[kind] == expected
|
|
73
|
+
# The inner exporter agrees with the wrapper.
|
|
74
|
+
for kind, expected in _EXPECTED.items():
|
|
75
|
+
assert exporter._inner._preferred_temporality[kind] == expected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_reader_sees_pinned_map_over_a_cumulative_inner(monkeypatch):
|
|
79
|
+
monkeypatch.setenv(_ENV, "cumulative")
|
|
80
|
+
inner = _CapturingExporter(preferred_temporality=None)
|
|
81
|
+
wrapped = otel_module.wrap_exporter_for_health(inner)
|
|
82
|
+
reader = PeriodicExportingMetricReader(wrapped, export_interval_millis=3_600_000)
|
|
83
|
+
try:
|
|
84
|
+
# The reader keys its map by the private instrument base classes.
|
|
85
|
+
got = reader._instrument_class_temporality
|
|
86
|
+
assert got[instrument._Counter] == DELTA
|
|
87
|
+
assert got[instrument._Histogram] == DELTA
|
|
88
|
+
assert got[instrument._ObservableCounter] == DELTA
|
|
89
|
+
assert got[instrument._UpDownCounter] == CUMULATIVE
|
|
90
|
+
assert got[instrument._Gauge] == CUMULATIVE
|
|
91
|
+
finally:
|
|
92
|
+
reader.shutdown()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _CapturingExporter(MetricExporter):
|
|
96
|
+
def __init__(self, preferred_temporality: Any = None) -> None:
|
|
97
|
+
super().__init__(preferred_temporality=preferred_temporality)
|
|
98
|
+
self.batches: list[Any] = []
|
|
99
|
+
|
|
100
|
+
def export(self, metrics_data, timeout_millis=10_000, **kwargs):
|
|
101
|
+
self.batches.append(metrics_data)
|
|
102
|
+
return MetricExportResult.SUCCESS
|
|
103
|
+
|
|
104
|
+
def force_flush(self, timeout_millis=10_000) -> bool:
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def shutdown(self, timeout_millis=30_000, **kwargs) -> None:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _metric(batch: Any, name: str) -> Any:
|
|
112
|
+
for rm in batch.resource_metrics:
|
|
113
|
+
for sm in rm.scope_metrics:
|
|
114
|
+
for m in sm.metrics:
|
|
115
|
+
if m.name == name:
|
|
116
|
+
return m
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_end_to_end_delta_exports(monkeypatch):
|
|
121
|
+
monkeypatch.setenv(_ENV, "cumulative")
|
|
122
|
+
inner = _CapturingExporter()
|
|
123
|
+
wrapped = otel_module.wrap_exporter_for_health(inner)
|
|
124
|
+
reader = PeriodicExportingMetricReader(wrapped, export_interval_millis=3_600_000)
|
|
125
|
+
provider = MeterProvider(metric_readers=[reader])
|
|
126
|
+
meter = provider.get_meter("t")
|
|
127
|
+
calls = meter.create_counter("t.calls")
|
|
128
|
+
turns = meter.create_histogram(
|
|
129
|
+
"t.turns",
|
|
130
|
+
explicit_bucket_boundaries_advisory=[1, 5, 10],
|
|
131
|
+
)
|
|
132
|
+
attrs = {"k": "v"}
|
|
133
|
+
try:
|
|
134
|
+
calls.add(2, attrs)
|
|
135
|
+
turns.record(3, attrs)
|
|
136
|
+
provider.force_flush()
|
|
137
|
+
calls.add(3, attrs)
|
|
138
|
+
turns.record(7, attrs)
|
|
139
|
+
provider.force_flush()
|
|
140
|
+
provider.force_flush()
|
|
141
|
+
|
|
142
|
+
# The idle third flush exports nothing: a delta stream with no
|
|
143
|
+
# measurements is dropped.
|
|
144
|
+
assert len(inner.batches) == 2
|
|
145
|
+
first, second = inner.batches
|
|
146
|
+
m = _metric(second, "t.calls")
|
|
147
|
+
assert m.data.aggregation_temporality == DELTA
|
|
148
|
+
assert m.data.data_points[0].value == 3
|
|
149
|
+
h = _metric(second, "t.turns")
|
|
150
|
+
assert h.data.aggregation_temporality == DELTA
|
|
151
|
+
point = h.data.data_points[0]
|
|
152
|
+
assert point.count == 1
|
|
153
|
+
assert point.sum == 7
|
|
154
|
+
assert list(point.bucket_counts) == [0, 0, 1, 0]
|
|
155
|
+
assert _metric(first, "t.calls").data.data_points[0].value == 2
|
|
156
|
+
finally:
|
|
157
|
+
provider.shutdown()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_resource_carries_delta_attribute(required_env):
|
|
161
|
+
reader = InMemoryMetricReader()
|
|
162
|
+
instruments = otel_module._instruments_for_reader(_load(), reader)
|
|
163
|
+
try:
|
|
164
|
+
instruments.calls.add(1, {"k": "v"})
|
|
165
|
+
data = reader.get_metrics_data()
|
|
166
|
+
attrs = data.resource_metrics[0].resource.attributes
|
|
167
|
+
assert attrs[semconv.FRUGAL_TEMPORALITY] == "delta"
|
|
168
|
+
assert semconv.FRUGAL_TEMPORALITY == "frugal.temporality"
|
|
169
|
+
assert semconv.TEMPORALITY_DELTA == "delta"
|
|
170
|
+
finally:
|
|
171
|
+
instruments.meter_provider.shutdown()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/sites.html
RENAMED
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/prometheus.yml
RENAMED
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/__init__.py
RENAMED
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/clients.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/main.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/pyproject.toml
RENAMED
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/requirements.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/_patching.py
RENAMED
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/anthropic.py
RENAMED
|
File without changes
|
|
File without changes
|
{frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/bedrock_models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|