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.
Files changed (92) hide show
  1. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/PKG-INFO +11 -3
  2. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/README.md +10 -2
  3. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/docs/metrics-reference.md +13 -2
  4. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/docs/quickstart.md +8 -3
  5. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/mockllm/package.json +1 -1
  6. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/config.py +4 -2
  7. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/otel.py +61 -12
  8. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/semconv.py +4 -0
  9. frugal_sdk_python-0.7.0/tests/test_otel_temporality.py +171 -0
  10. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/.github/workflows/cd.yml +0 -0
  11. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/.gitignore +0 -0
  12. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/LICENSES/Apache-2.0.txt +0 -0
  13. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/REUSE.toml +0 -0
  14. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/README.md +0 -0
  15. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/collector/otel-collector-config.yaml +0 -0
  16. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/docker-compose.yml +0 -0
  17. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/grafana/dashboards/frugal-metrics.json +0 -0
  18. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/grafana/provisioning/dashboards/frugal.yml +0 -0
  19. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/grafana/provisioning/datasources/prometheus.yml +0 -0
  20. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/mockllm/Dockerfile +0 -0
  21. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/mockllm/src/server.js +0 -0
  22. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/index.html.example +0 -0
  23. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/models.html +0 -0
  24. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/overview.html +0 -0
  25. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/consoles/sites.html +0 -0
  26. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/prometheus/prometheus.yml +0 -0
  27. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/Dockerfile +0 -0
  28. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/__init__.py +0 -0
  29. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/clients.py +0 -0
  30. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/__init__.py +0 -0
  31. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/anon.py +0 -0
  32. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/classify.py +0 -0
  33. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/deferred.py +0 -0
  34. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/fail.py +0 -0
  35. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/pipeline.py +0 -0
  36. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/summarize.py +0 -0
  37. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/handlers/translate.py +0 -0
  38. {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
  39. {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
  40. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/internal_lib/__init__.py +0 -0
  41. {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
  42. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/main.py +0 -0
  43. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/app/sibling_lib/__init__.py +0 -0
  44. {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
  45. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/pyproject.toml +0 -0
  46. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/sample-app/requirements.txt +0 -0
  47. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/examples/test-env/scripts/verify.sh +0 -0
  48. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/pyproject.toml +0 -0
  49. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/__init__.py +0 -0
  50. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/block_hash_lru.py +0 -0
  51. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/caller.py +0 -0
  52. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/debug.py +0 -0
  53. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/duplicate_filter.py +0 -0
  54. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/instrument.py +0 -0
  55. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/model_cap.py +0 -0
  56. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/prompt_analyzer.py +0 -0
  57. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/providers.py +0 -0
  58. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/safe.py +0 -0
  59. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/shutdown.py +0 -0
  60. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/streams.py +0 -0
  61. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/track.py +0 -0
  62. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/__init__.py +0 -0
  63. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/_batch.py +0 -0
  64. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/_patching.py +0 -0
  65. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/anthropic.py +0 -0
  66. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/bedrock.py +0 -0
  67. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/bedrock_models.py +0 -0
  68. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/src/frugal_metrics/wrappers/openai.py +0 -0
  69. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/__init__.py +0 -0
  70. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/conftest.py +0 -0
  71. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_bedrock_models.py +0 -0
  72. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_block_hash_lru.py +0 -0
  73. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_caller.py +0 -0
  74. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_caller_edge_cases.py +0 -0
  75. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_config.py +0 -0
  76. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_debug.py +0 -0
  77. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_duplicate_filter.py +0 -0
  78. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_model_cap.py +0 -0
  79. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_never_impact_host.py +0 -0
  80. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_node_parity.py +0 -0
  81. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_otel_overflow_detection.py +0 -0
  82. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_prompt_analyzer.py +0 -0
  83. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_providers.py +0 -0
  84. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_shutdown.py +0 -0
  85. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_sibling_parity.py +0 -0
  86. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_track_ai_call.py +0 -0
  87. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_anthropic.py +0 -0
  88. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_anthropic_batches.py +0 -0
  89. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_bedrock.py +0 -0
  90. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_openai.py +0 -0
  91. {frugal_sdk_python-0.6.0 → frugal_sdk_python-0.7.0}/tests/test_wrap_openai_analyzer.py +0 -0
  92. {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.6.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, timeout and temporality variables.)
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 every 5 seconds.
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, timeout and temporality variables.)
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 every 5 seconds.
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
- increase(frugal.gen_ai.agent.turns_sum[...])
64
+ sum_over_time(frugal.gen_ai.agent.turns_sum{frugal.temporality="delta"}[...])
65
65
  /
66
- increase(frugal.gen_ai.agent.turns_count[...])
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, timeout and temporality variables.)
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
- Every five seconds the collector should receive metric records with
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.
@@ -7,6 +7,6 @@
7
7
  "start": "node src/server.js"
8
8
  },
9
9
  "dependencies": {
10
- "express": "^4.19.2"
10
+ "express": "^5.2.1"
11
11
  }
12
12
  }
@@ -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, timeout
37
- and temporality variables.
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 MeterProvider
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(ConsoleMetricExporter(), identity=identity)
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
- The periodic reader retries silently forever; without this, a wrong
513
- endpoint / bad token / down backend is invisible to the customer. We emit on
514
- the FIRST failure after a success (0->1 edge) and re-arm on the next success,
515
- so a flapping backend produces one alert per failure run, not one per export
516
- tick. Every other attribute delegates to the inner exporter.
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__ — we delegate everything to the inner
523
- # exporter, including the preferred temporality/aggregation it was built
524
- # with. Accessing those goes through __getattr__.
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 preferred_temporality / preferred_aggregation and any other
622
- # attribute the SDK reads off the exporter to the inner instance.
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()